chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
@@ -0,0 +1,660 @@
|
||||
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceTransport,
|
||||
Service,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { ServiceSlot } from "./handle.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
|
||||
import { ReplicatedStateReplica, serviceDeliveryContext } from "./state.ts";
|
||||
|
||||
type ErrorReporter = (error: Error) => void;
|
||||
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
|
||||
|
||||
class MemberSlot {
|
||||
readonly #serviceId: string;
|
||||
readonly #member: string;
|
||||
readonly #invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>;
|
||||
readonly #state: ReplicatedStateReplica;
|
||||
readonly #isActive: () => boolean;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly value: unknown;
|
||||
#kind: ServiceMemberKind | undefined;
|
||||
#expectedKind: ServiceMemberKind | undefined;
|
||||
|
||||
constructor(
|
||||
serviceId: string,
|
||||
member: string,
|
||||
invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>,
|
||||
isActive: () => boolean,
|
||||
assertAccess: () => void,
|
||||
reportError: ErrorReporter,
|
||||
) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#member = member;
|
||||
this.#invoke = invoke;
|
||||
this.#isActive = isActive;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#state = new ReplicatedStateReplica(reportError);
|
||||
const callable = (): void => {};
|
||||
this.value = new Proxy(callable, {
|
||||
apply: (_target, _thisArg, args) => this.#call(args),
|
||||
get: (_target, property) => {
|
||||
if (property === "value") {
|
||||
this.#assertAccess();
|
||||
this.#expect("state");
|
||||
return this.#state.value;
|
||||
}
|
||||
if (property === "subscribe") return this.#subscribe.bind(this);
|
||||
if (property === Symbol.toStringTag) return "RemoteServiceMember";
|
||||
if (property === "then") return undefined;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setDescription(kind: ServiceMemberKind): void {
|
||||
if (this.#kind !== undefined && this.#kind !== kind) {
|
||||
throw new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);
|
||||
}
|
||||
this.#kind = kind;
|
||||
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.hydrate(sequence, ops, context);
|
||||
}
|
||||
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
this.setDescription("state");
|
||||
this.#state.update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#state.clear();
|
||||
}
|
||||
|
||||
#subscribe(listener: (value: JsonValue, context: Context) => void): () => void {
|
||||
this.#assertAccess();
|
||||
if (typeof listener !== "function") {
|
||||
throw new TypeError("Replicated state subscription listener must be a function");
|
||||
}
|
||||
this.#expect("state");
|
||||
return this.#state.subscribe(listener);
|
||||
}
|
||||
|
||||
#expect(kind: ServiceMemberKind): void {
|
||||
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`,
|
||||
);
|
||||
}
|
||||
this.#expectedKind = kind;
|
||||
if (this.#kind !== undefined && this.#kind !== kind) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#call(args: unknown[]): Promise<JsonValue | undefined> {
|
||||
this.#assertAccess();
|
||||
this.#expect("method");
|
||||
if (!this.#isActive()) {
|
||||
return Promise.reject(
|
||||
new RemoteServiceError("service_stale_instance", `Remote service ${this.#serviceId} binding is closed`),
|
||||
);
|
||||
}
|
||||
const context = args.at(-1);
|
||||
if (!isContext(context)) {
|
||||
return Promise.reject(
|
||||
new RemoteServiceError(
|
||||
"service_invalid_value",
|
||||
`Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const businessArgs = args.slice(0, -1) as JsonValue[];
|
||||
return this.#invoke(businessArgs, context);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceFacade {
|
||||
readonly #serviceId: string;
|
||||
readonly #address: ServiceInstanceAddress | undefined;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #slots = new Map<string, MemberSlot>();
|
||||
readonly #descriptions = new Map<string, ServiceMemberKind>();
|
||||
readonly #isActive: () => boolean;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(
|
||||
serviceId: string,
|
||||
address: ServiceInstanceAddress | undefined,
|
||||
transport: RemoteServiceTransport,
|
||||
isActive: () => boolean,
|
||||
assertAccess: () => void,
|
||||
reportError: ErrorReporter,
|
||||
) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#address = address;
|
||||
this.#transport = transport;
|
||||
this.#isActive = isActive;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#reportError = reportError;
|
||||
this.proxy = new Proxy(Object.create(null) as object, {
|
||||
get: (_target, property) => {
|
||||
if (typeof property !== "string") return undefined;
|
||||
return this.#slot(property).value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
install(snapshot: ServiceInstanceSnapshot, context: Context): void {
|
||||
if (!sameAddress(snapshot.instance, this.#address))
|
||||
throw new Error("Remote service snapshot has the wrong address");
|
||||
const members = validateMembers(snapshot.members);
|
||||
for (const name of this.#slots.keys()) {
|
||||
if (!members.has(name)) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_not_found",
|
||||
`Unknown remote service member ${this.#serviceId}.${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#descriptions.clear();
|
||||
for (const [name, member] of members) this.#descriptions.set(name, member.kind);
|
||||
for (const member of members.values()) {
|
||||
const slot = this.#slots.get(member.name);
|
||||
if (member.kind === "state") {
|
||||
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);
|
||||
} else {
|
||||
slot?.setDescription(member.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(member: string, sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#descriptions.get(member) !== "state") {
|
||||
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
|
||||
}
|
||||
this.#slot(member).update(sequence, ops, context);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const slot of this.#slots.values()) slot.clear();
|
||||
}
|
||||
|
||||
#slot(member: string): MemberSlot {
|
||||
let slot = this.#slots.get(member);
|
||||
if (slot !== undefined) return slot;
|
||||
slot = new MemberSlot(
|
||||
this.#serviceId,
|
||||
member,
|
||||
(args, context) =>
|
||||
this.#transport.invoke(
|
||||
{
|
||||
serviceId: this.#serviceId,
|
||||
...(this.#address === undefined ? {} : { instance: this.#address }),
|
||||
member,
|
||||
args,
|
||||
},
|
||||
context,
|
||||
),
|
||||
this.#isActive,
|
||||
this.#assertAccess,
|
||||
this.#reportError,
|
||||
);
|
||||
const kind = this.#descriptions.get(member);
|
||||
if (kind !== undefined) slot.setDescription(kind);
|
||||
this.#slots.set(member, slot);
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
interface SingletonBinding {
|
||||
facade: ServiceFacade;
|
||||
subscription?: ServiceSubscription;
|
||||
starting?: Promise<void>;
|
||||
active: boolean;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface KeyedInstance extends InstanceDirectoryEntry {
|
||||
readonly facade: ServiceFacade;
|
||||
}
|
||||
|
||||
class KeyedBinding<T> {
|
||||
readonly #service: Service<T>;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #onEmpty: () => void;
|
||||
readonly #instances: InstanceDirectory<KeyedInstance>;
|
||||
#subscription: ServiceSubscription | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
#bound: boolean;
|
||||
#revision = 0;
|
||||
|
||||
constructor(
|
||||
service: Service<T>,
|
||||
transport: RemoteServiceTransport,
|
||||
reportError: ErrorReporter,
|
||||
assertAccess: () => void,
|
||||
onEmpty: () => void,
|
||||
bound: boolean,
|
||||
) {
|
||||
this.#service = service;
|
||||
this.#transport = transport;
|
||||
this.#reportError = reportError;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#onEmpty = onEmpty;
|
||||
this.#bound = bound;
|
||||
this.#instances = new InstanceDirectory({ ready: false, onError: reportError });
|
||||
}
|
||||
|
||||
observe(handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
if (this.#closed) throw new Error("Remote keyed service binding is closed");
|
||||
let stopped = false;
|
||||
const stop = this.#instances.observe<T>((service, context) => {
|
||||
const slot = new ServiceSlot(this.#service.id, true);
|
||||
void slot.bind(service as object);
|
||||
return handler(
|
||||
slot.view<T>(() => {
|
||||
this.#assertAccess();
|
||||
if (stopped || context.abortSignal?.aborted) {
|
||||
throw new RemoteServiceError(
|
||||
"service_stale_instance",
|
||||
`Remote service ${this.#service.id} observation is closed`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
context,
|
||||
);
|
||||
});
|
||||
if (this.#bound && this.#starting === undefined) {
|
||||
const revision = this.#revision;
|
||||
const starting = this.#start(revision);
|
||||
this.#starting = starting;
|
||||
void starting.catch((error: unknown) => {
|
||||
if (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
stop();
|
||||
if (this.#instances.observerCount === 0) this.#onEmpty();
|
||||
};
|
||||
}
|
||||
|
||||
async rebind(bound: boolean, context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#bound = bound;
|
||||
const revision = ++this.#revision;
|
||||
await this.#reset(context, false);
|
||||
if (this.#closed || this.#revision !== revision || this.#bound !== bound) return;
|
||||
if (bound && this.#instances.observerCount > 0) {
|
||||
const starting = this.#start(revision);
|
||||
this.#starting = starting;
|
||||
await starting;
|
||||
}
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.#starting ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async close(context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#revision += 1;
|
||||
await this.#reset(context, true);
|
||||
this.#instances.dispose();
|
||||
}
|
||||
|
||||
async #reset(context: Context, waitForStarting: boolean): Promise<void> {
|
||||
this.#instances.reset();
|
||||
const starting = this.#starting;
|
||||
this.#starting = undefined;
|
||||
const subscription = this.#subscription;
|
||||
this.#subscription = undefined;
|
||||
await Promise.all([
|
||||
waitForStarting ? starting?.catch(() => {}) : undefined,
|
||||
subscription === undefined ? undefined : subscription.close(context),
|
||||
]);
|
||||
}
|
||||
|
||||
async #start(revision: number): Promise<void> {
|
||||
const subscription = await this.#transport.subscribe(
|
||||
this.#service.id,
|
||||
"keyed",
|
||||
(update, context) => {
|
||||
if (this.#revision === revision) this.#update(update, context);
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (this.#closed || !this.#bound || this.#revision !== revision) {
|
||||
await subscription.close(BACKGROUND_CONTEXT);
|
||||
return;
|
||||
}
|
||||
this.#subscription = subscription;
|
||||
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
|
||||
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
|
||||
}
|
||||
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, serviceDeliveryContext());
|
||||
subscription.activate();
|
||||
this.#instances.ready();
|
||||
}
|
||||
|
||||
#update(update: ServiceProviderUpdate, context: Context): void {
|
||||
if (this.#closed) return;
|
||||
try {
|
||||
switch (update.type) {
|
||||
case "unavailable":
|
||||
case "replaced":
|
||||
throw new Error("Keyed service received a singleton lifecycle update");
|
||||
case "spawned":
|
||||
this.#spawn(update.instance, context);
|
||||
break;
|
||||
case "closed": {
|
||||
const instance = this.#instances.get(update.instance.key);
|
||||
if (instance?.generation === update.instance.generation) this.#instances.remove(instance);
|
||||
break;
|
||||
}
|
||||
case "state": {
|
||||
if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
|
||||
const instance = this.#instances.get(update.instance.key);
|
||||
if (instance?.generation !== update.instance.generation) return;
|
||||
instance.facade.update(update.member, update.sequence, update.ops, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#spawn(snapshot: ServiceInstanceSnapshot, context: Context): void {
|
||||
const address = snapshot.instance;
|
||||
if (address === undefined) throw new Error("Keyed service instance snapshot has no address");
|
||||
let active = true;
|
||||
const facade = new ServiceFacade(
|
||||
this.#service.id,
|
||||
address,
|
||||
this.#transport,
|
||||
() => active && !this.#closed,
|
||||
this.#assertAccess,
|
||||
this.#reportError,
|
||||
);
|
||||
facade.install(snapshot, context);
|
||||
this.#instances.replace({
|
||||
key: address.key,
|
||||
generation: address.generation,
|
||||
service: facade.proxy,
|
||||
facade,
|
||||
deactivate: () => {
|
||||
active = false;
|
||||
facade.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #allowlist = new Set<string>();
|
||||
readonly #reportError: ErrorReporter;
|
||||
readonly #modes = new Map<string, "singleton" | "keyed">();
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #singletons = new Map<string, SingletonBinding>();
|
||||
readonly #keyed = new Map<string, KeyedBinding<unknown>>();
|
||||
#bound: boolean;
|
||||
#readinessRevision = 0;
|
||||
#bindingTransition = Promise.resolve();
|
||||
#disposed = false;
|
||||
|
||||
constructor(options: RemoteServiceBindingOptions) {
|
||||
this.#transport = options.transport;
|
||||
const ids = options.services.map(({ id }) => id);
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service binding has duplicate service IDs");
|
||||
for (const id of ids) this.#allowlist.add(id);
|
||||
this.#reportError = options.onError ?? (() => {});
|
||||
this.#assertAccess = options.assertAccess ?? (() => {});
|
||||
this.#bound = options.bound ?? true;
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAvailable(service.id, "singleton");
|
||||
let binding = this.#singletons.get(service.id);
|
||||
if (binding !== undefined) return binding.facade.proxy as T;
|
||||
binding = { facade: undefined as unknown as ServiceFacade, active: true, revision: 0 };
|
||||
binding.facade = new ServiceFacade(
|
||||
service.id,
|
||||
undefined,
|
||||
this.#transport,
|
||||
() => binding!.active && !this.#disposed && this.#bound,
|
||||
() => this.#assertHandleAccess(),
|
||||
this.#reportError,
|
||||
);
|
||||
this.#singletons.set(service.id, binding);
|
||||
this.#readinessRevision += 1;
|
||||
if (this.#bound) {
|
||||
const revision = binding.revision;
|
||||
const starting = this.#startSingleton(service.id, binding, revision);
|
||||
binding.starting = starting;
|
||||
void starting.catch((error: unknown) => {
|
||||
if (binding.active && binding.revision === revision && !this.#disposed && this.#bound) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
return binding.facade.proxy as T;
|
||||
}
|
||||
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAvailable(service.id, "keyed");
|
||||
let binding = this.#keyed.get(service.id) as KeyedBinding<T> | undefined;
|
||||
if (binding === undefined) {
|
||||
binding = new KeyedBinding(
|
||||
service,
|
||||
this.#transport,
|
||||
this.#reportError,
|
||||
() => this.#assertHandleAccess(),
|
||||
() => {
|
||||
if (this.#keyed.get(service.id) !== binding) return;
|
||||
this.#keyed.delete(service.id);
|
||||
this.#readinessRevision += 1;
|
||||
void binding!.close(BACKGROUND_CONTEXT).catch(this.#reportError);
|
||||
},
|
||||
this.#bound,
|
||||
);
|
||||
this.#keyed.set(service.id, binding as KeyedBinding<unknown>);
|
||||
this.#readinessRevision += 1;
|
||||
}
|
||||
return binding.observe(handler);
|
||||
}
|
||||
|
||||
async ready(context: Context): Promise<void> {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
while (true) {
|
||||
const revision = this.#readinessRevision;
|
||||
const starts = [
|
||||
this.#bindingTransition,
|
||||
...[...this.#singletons.values()].flatMap((binding) =>
|
||||
binding.starting === undefined ? [] : [binding.starting],
|
||||
),
|
||||
...[...this.#keyed.values()].map((binding) => binding.ready()),
|
||||
];
|
||||
await awaitWithContext(
|
||||
Promise.all(starts).then(() => undefined),
|
||||
context,
|
||||
);
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
if (revision === this.#readinessRevision) return;
|
||||
}
|
||||
}
|
||||
|
||||
async rebind(bound: boolean, context: Context): Promise<void> {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
this.#bound = bound;
|
||||
this.#readinessRevision += 1;
|
||||
const transitions: Promise<void>[] = [];
|
||||
for (const [serviceId, binding] of this.#singletons) {
|
||||
binding.revision += 1;
|
||||
binding.facade.clear();
|
||||
const subscription = binding.subscription;
|
||||
delete binding.subscription;
|
||||
const revision = binding.revision;
|
||||
const starting = (async () => {
|
||||
await subscription?.close(context);
|
||||
if (bound) await this.#startSingleton(serviceId, binding, revision);
|
||||
})();
|
||||
binding.starting = starting;
|
||||
transitions.push(starting);
|
||||
}
|
||||
for (const binding of this.#keyed.values()) transitions.push(binding.rebind(bound, context));
|
||||
const completion = (async () => {
|
||||
const results = await Promise.allSettled(transitions);
|
||||
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (errors.length > 0)
|
||||
throw new AggregateError(
|
||||
errors.map(({ reason }) => reason),
|
||||
"Failed to rebind services",
|
||||
);
|
||||
})();
|
||||
this.#bindingTransition = completion;
|
||||
await completion;
|
||||
}
|
||||
|
||||
async dispose(context: Context): Promise<void> {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
const closes: Promise<void>[] = [];
|
||||
for (const binding of this.#singletons.values()) {
|
||||
binding.active = false;
|
||||
binding.facade.clear();
|
||||
if (binding.starting) closes.push(binding.starting.catch(() => {}));
|
||||
if (binding.subscription) closes.push(Promise.resolve(binding.subscription.close(context)));
|
||||
}
|
||||
for (const binding of this.#keyed.values()) closes.push(binding.close(context));
|
||||
this.#singletons.clear();
|
||||
this.#keyed.clear();
|
||||
const results = await Promise.allSettled(closes);
|
||||
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (errors.length > 0)
|
||||
throw new AggregateError(
|
||||
errors.map(({ reason }) => reason),
|
||||
"Failed to dispose services",
|
||||
);
|
||||
}
|
||||
|
||||
async #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {
|
||||
const subscription = await this.#transport.subscribe(
|
||||
serviceId,
|
||||
"singleton",
|
||||
(update, context) => {
|
||||
if (!binding.active || binding.revision !== revision) return;
|
||||
try {
|
||||
if (update.type === "unavailable") {
|
||||
binding.facade.clear();
|
||||
} else if (update.type === "replaced") {
|
||||
if (update.snapshot.instance !== undefined) {
|
||||
throw new Error("Singleton replacement has an instance address");
|
||||
}
|
||||
binding.facade.install(update.snapshot, context);
|
||||
} else if (update.type === "state" && update.instance === undefined) {
|
||||
binding.facade.update(update.member, update.sequence, update.ops, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {
|
||||
await subscription.close(BACKGROUND_CONTEXT);
|
||||
return;
|
||||
}
|
||||
binding.subscription = subscription;
|
||||
const snapshot = subscription.snapshot;
|
||||
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
|
||||
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
|
||||
}
|
||||
binding.facade.install(snapshot.instances[0]!, serviceDeliveryContext());
|
||||
subscription.activate();
|
||||
}
|
||||
|
||||
#assertHandleAccess(): void {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
this.#assertAccess();
|
||||
}
|
||||
|
||||
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
|
||||
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
|
||||
}
|
||||
|
||||
#assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void {
|
||||
if (this.#disposed) throw new Error("Remote service binding is disposed");
|
||||
if (!this.#allowlist.has(serviceId)) {
|
||||
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
|
||||
}
|
||||
const existing = this.#modes.get(serviceId);
|
||||
if (existing !== undefined && existing !== mode) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${serviceId} is already used as ${existing}`,
|
||||
);
|
||||
}
|
||||
this.#modes.set(serviceId, mode);
|
||||
}
|
||||
}
|
||||
|
||||
function validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {
|
||||
const result = new Map<string, ServiceMemberSnapshot>();
|
||||
for (const member of members) {
|
||||
if (member.name.length === 0 || result.has(member.name)) {
|
||||
throw new Error("Remote service has invalid member descriptions");
|
||||
}
|
||||
result.set(member.name, member);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress | undefined): boolean {
|
||||
if (left === undefined || right === undefined) return left === right;
|
||||
return left.key === right.key && left.generation === right.generation;
|
||||
}
|
||||
|
||||
function isContext(value: unknown): value is Context {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<Context>;
|
||||
return typeof candidate.value === "function" && typeof candidate.toString === "function";
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export const REMOTE_SERVICE_ERROR_CODES = [
|
||||
"service_not_allowed",
|
||||
"service_not_found",
|
||||
"service_mode_mismatch",
|
||||
"service_member_not_found",
|
||||
"service_member_mismatch",
|
||||
"service_instance_not_found",
|
||||
"service_stale_instance",
|
||||
"service_invalid_value",
|
||||
] as const;
|
||||
|
||||
export type RemoteServiceErrorCode = (typeof REMOTE_SERVICE_ERROR_CODES)[number];
|
||||
|
||||
export function isRemoteServiceErrorCode(value: unknown): value is RemoteServiceErrorCode {
|
||||
return typeof value === "string" && REMOTE_SERVICE_ERROR_CODES.includes(value as RemoteServiceErrorCode);
|
||||
}
|
||||
|
||||
export class RemoteServiceError extends Error {
|
||||
readonly code: RemoteServiceErrorCode;
|
||||
|
||||
constructor(code: RemoteServiceErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "RemoteServiceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
Normal file
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
interface ResolvedValue {
|
||||
readonly value: unknown;
|
||||
readonly receiver: object;
|
||||
}
|
||||
|
||||
type ValueResolver = () => ResolvedValue;
|
||||
|
||||
/** Host-owned mutable target with consumer-owned guarded views. */
|
||||
export class ServiceSlot {
|
||||
readonly #serviceId: string;
|
||||
readonly #wrapObjects: boolean;
|
||||
#implementation: object | undefined;
|
||||
|
||||
constructor(serviceId: string, wrapObjects: boolean) {
|
||||
this.#serviceId = serviceId;
|
||||
this.#wrapObjects = wrapObjects;
|
||||
}
|
||||
|
||||
view<T>(assertAccess: () => void): T {
|
||||
return new ServiceView(this, assertAccess, this.#wrapObjects).proxy as T;
|
||||
}
|
||||
|
||||
bind(implementation: object): void {
|
||||
this.#implementation = implementation;
|
||||
}
|
||||
|
||||
unbind(): void {
|
||||
this.#implementation = undefined;
|
||||
}
|
||||
|
||||
resolve(property: PropertyKey, assertAccess: () => void): ResolvedValue {
|
||||
assertAccess();
|
||||
const implementation = this.#implementation;
|
||||
if (implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`);
|
||||
return {
|
||||
value: Reflect.get(implementation, property, implementation),
|
||||
receiver: implementation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceView {
|
||||
readonly #slot: ServiceSlot;
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #wrapObjects: boolean;
|
||||
readonly #members = new Map<PropertyKey, ValueView>();
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(slot: ServiceSlot, assertAccess: () => void, wrapObjects: boolean) {
|
||||
this.#slot = slot;
|
||||
this.#assertAccess = assertAccess;
|
||||
this.#wrapObjects = wrapObjects;
|
||||
this.proxy = new Proxy(Object.create(null) as object, {
|
||||
get: (_target, property) => this.#getMember(property),
|
||||
});
|
||||
}
|
||||
|
||||
#getMember(property: PropertyKey): unknown {
|
||||
const resolve = (): ResolvedValue => this.#slot.resolve(property, this.#assertAccess);
|
||||
const current = resolve().value;
|
||||
if (!isObject(current) || (typeof current !== "function" && !this.#wrapObjects)) return current;
|
||||
let member = this.#members.get(property);
|
||||
if (member === undefined) {
|
||||
member = new ValueView(resolve, typeof current === "function");
|
||||
this.#members.set(property, member);
|
||||
}
|
||||
return member.proxy;
|
||||
}
|
||||
}
|
||||
|
||||
class ValueView {
|
||||
readonly #resolve: ValueResolver;
|
||||
readonly #children = new Map<PropertyKey, ValueView>();
|
||||
readonly proxy: object;
|
||||
|
||||
constructor(resolve: ValueResolver, callable: boolean) {
|
||||
this.#resolve = resolve;
|
||||
const target: object = callable ? () => undefined : Object.create(null);
|
||||
this.proxy = new Proxy(target, {
|
||||
apply: (_target, _thisArg, args) => this.#invoke(args),
|
||||
get: (_target, property) => this.#get(property),
|
||||
});
|
||||
}
|
||||
|
||||
#invoke(args: unknown[]): unknown {
|
||||
const { value, receiver } = this.#resolve();
|
||||
if (typeof value !== "function") throw new TypeError("Service member is not callable");
|
||||
return Reflect.apply(value, receiver, args);
|
||||
}
|
||||
|
||||
#get(property: PropertyKey): unknown {
|
||||
const resolve = (): ResolvedValue => {
|
||||
const parent = this.#resolve();
|
||||
if (!isObject(parent.value)) throw new TypeError("Service member does not have properties");
|
||||
return {
|
||||
value: Reflect.get(parent.value, property, parent.value),
|
||||
receiver: parent.value,
|
||||
};
|
||||
};
|
||||
const current = resolve().value;
|
||||
if (typeof current !== "function") return current;
|
||||
let child = this.#children.get(property);
|
||||
if (child === undefined) {
|
||||
child = new ValueView(resolve, true);
|
||||
this.#children.set(property, child);
|
||||
}
|
||||
return child.proxy;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === "object" && value !== null) || typeof value === "function";
|
||||
}
|
||||
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
import { BACKGROUND_CONTEXT, withCancel } from "../context/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface InstanceDirectoryEntry {
|
||||
readonly key: string;
|
||||
readonly generation: number;
|
||||
readonly service: object;
|
||||
deactivate(): void;
|
||||
}
|
||||
|
||||
interface Observer {
|
||||
readonly handler: (service: object, context: Context) => void | Promise<void>;
|
||||
readonly tasks: Map<InstanceDirectoryEntry, { readonly cancel: (reason?: unknown) => void }>;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Owns keyed instance lifetime and the cancellable tasks observing those instances. */
|
||||
export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
|
||||
readonly #entries = new Map<string, TEntry>();
|
||||
readonly #observers = new Set<Observer>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#ready: boolean;
|
||||
#disposed = false;
|
||||
|
||||
constructor(options: { readonly ready: boolean; readonly onError: (error: Error) => void }) {
|
||||
this.#ready = options.ready;
|
||||
this.#reportError = options.onError;
|
||||
}
|
||||
|
||||
get observerCount(): number {
|
||||
return this.#observers.size;
|
||||
}
|
||||
|
||||
get(key: string): TEntry | undefined {
|
||||
return this.#entries.get(key);
|
||||
}
|
||||
|
||||
insert(entry: TEntry): void {
|
||||
this.#assertActive();
|
||||
if (this.#entries.has(entry.key)) {
|
||||
throw new Error(`Keyed service already has a live instance with key ${entry.key}`);
|
||||
}
|
||||
this.#entries.set(entry.key, entry);
|
||||
if (this.#ready) this.#startAll(entry);
|
||||
}
|
||||
|
||||
replace(entry: TEntry): void {
|
||||
this.#assertActive();
|
||||
const previous = this.#entries.get(entry.key);
|
||||
if (previous !== undefined) {
|
||||
if (previous.generation === entry.generation) {
|
||||
throw new Error("Keyed service repeated a live generation");
|
||||
}
|
||||
this.#remove(previous);
|
||||
}
|
||||
this.#entries.set(entry.key, entry);
|
||||
if (this.#ready) this.#startAll(entry);
|
||||
}
|
||||
|
||||
remove(entry: TEntry): void {
|
||||
if (this.#entries.get(entry.key) !== entry) return;
|
||||
this.#remove(entry);
|
||||
}
|
||||
|
||||
ready(): void {
|
||||
this.#assertActive();
|
||||
if (this.#ready) return;
|
||||
this.#ready = true;
|
||||
for (const entry of this.#entries.values()) this.#startAll(entry);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#ready = false;
|
||||
for (const entry of [...this.#entries.values()]) this.#remove(entry);
|
||||
}
|
||||
|
||||
observe<T>(handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
this.#assertActive();
|
||||
const observer: Observer = {
|
||||
handler: handler as Observer["handler"],
|
||||
tasks: new Map(),
|
||||
closed: false,
|
||||
};
|
||||
this.#observers.add(observer);
|
||||
if (this.#ready) {
|
||||
for (const entry of this.#entries.values()) this.#start(observer, entry);
|
||||
}
|
||||
return () => {
|
||||
if (observer.closed) return;
|
||||
observer.closed = true;
|
||||
for (const task of observer.tasks.values()) task.cancel();
|
||||
observer.tasks.clear();
|
||||
this.#observers.delete(observer);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
for (const observer of this.#observers) {
|
||||
observer.closed = true;
|
||||
for (const task of observer.tasks.values()) task.cancel();
|
||||
observer.tasks.clear();
|
||||
}
|
||||
this.#observers.clear();
|
||||
for (const entry of [...this.#entries.values()]) {
|
||||
entry.deactivate();
|
||||
}
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
#remove(entry: TEntry): void {
|
||||
if (this.#entries.get(entry.key) !== entry) return;
|
||||
this.#entries.delete(entry.key);
|
||||
entry.deactivate();
|
||||
for (const observer of this.#observers) {
|
||||
observer.tasks.get(entry)?.cancel();
|
||||
observer.tasks.delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
#startAll(entry: TEntry): void {
|
||||
for (const observer of this.#observers) this.#start(observer, entry);
|
||||
}
|
||||
|
||||
#start(observer: Observer, entry: TEntry): void {
|
||||
if (observer.closed || observer.tasks.has(entry)) return;
|
||||
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
|
||||
observer.tasks.set(entry, { cancel });
|
||||
try {
|
||||
void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => {
|
||||
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
|
||||
});
|
||||
} catch (error) {
|
||||
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#assertActive(): void {
|
||||
if (this.#disposed) throw new Error("Keyed service directory is disposed");
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { RemoteServiceTransport } from "../types.ts";
|
||||
import type { RemoteServiceProvider } from "./provider.ts";
|
||||
|
||||
/** Connects a provider to a binding without changing remote service semantics. */
|
||||
export function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
|
||||
return {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
@@ -0,0 +1,586 @@
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceContract,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { serviceDeliveryContext } from "./state.ts";
|
||||
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "./state-internals.ts";
|
||||
import { decodeServiceControlCall } from "./wire.ts";
|
||||
|
||||
type RemoteMethod = (...args: unknown[]) => unknown;
|
||||
|
||||
type InstanceMember =
|
||||
| { readonly kind: "method"; readonly method: RemoteMethod }
|
||||
| { readonly kind: "state"; readonly state: ReplicatedStateInternals };
|
||||
|
||||
type ServiceMemberKind = InstanceMember["kind"];
|
||||
|
||||
interface ClassifiedRemoteServiceImplementation {
|
||||
readonly implementation: object;
|
||||
readonly members: Map<string, InstanceMember>;
|
||||
}
|
||||
|
||||
interface ProviderInstance {
|
||||
readonly address?: ServiceInstanceAddress;
|
||||
readonly implementation: object;
|
||||
readonly members: ReadonlyMap<string, InstanceMember>;
|
||||
readonly removeMemberListeners: readonly (() => void)[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ProviderSubscriber {
|
||||
readonly listener: (update: ServiceProviderUpdate, context: Context) => void;
|
||||
readonly buffer: { readonly update: ServiceProviderUpdate; readonly context: Context }[];
|
||||
active: boolean;
|
||||
terminated: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
interface ServiceProviderDefinition {
|
||||
readonly service: { readonly id: string; readonly local?: boolean };
|
||||
readonly mode: ServiceMode;
|
||||
}
|
||||
|
||||
interface ServiceRegistration {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
singleton?: ProviderInstance;
|
||||
singletonShape?: ReadonlyMap<string, ServiceMemberKind>;
|
||||
readonly instances: Map<string, ProviderInstance>;
|
||||
readonly generations: Map<string, number>;
|
||||
readonly subscribers: Set<ProviderSubscriber>;
|
||||
}
|
||||
|
||||
export type ServiceUpdatePublisher = (
|
||||
subscriptionId: string,
|
||||
update: ServiceProviderUpdate,
|
||||
context: Context,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** Hosts one provider for one remote consumer and owns that consumer's subscriptions. */
|
||||
export interface RemoteServiceEndpoint {
|
||||
invoke(call: ServiceCall, publish: ServiceUpdatePublisher, context: Context): Promise<JsonValue | undefined>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class RemoteServiceProvider {
|
||||
readonly #catalogue: readonly ServiceCatalogueEntry[];
|
||||
readonly #registrations = new Map<string, ServiceRegistration>();
|
||||
#disposed = false;
|
||||
|
||||
constructor(entries: readonly (ServiceProviderDefinition | { readonly id: string })[]) {
|
||||
const definitions = entries.map(
|
||||
(entry): ServiceProviderDefinition => ("service" in entry ? entry : { service: entry, mode: "singleton" }),
|
||||
);
|
||||
for (const { service } of definitions) {
|
||||
if ("local" in service && service.local === true) {
|
||||
throw new TypeError(`Local service ${service.id} cannot be published remotely`);
|
||||
}
|
||||
}
|
||||
const ids = definitions.map(({ service }) => service.id);
|
||||
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs");
|
||||
this.#catalogue = Object.freeze(
|
||||
definitions.map(({ service, mode }) => Object.freeze({ serviceId: service.id, mode })),
|
||||
);
|
||||
for (const { service, mode } of definitions) {
|
||||
this.#registrations.set(service.id, {
|
||||
serviceId: service.id,
|
||||
mode,
|
||||
instances: new Map(),
|
||||
generations: new Map(),
|
||||
subscribers: new Set(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get catalogue(): readonly ServiceCatalogueEntry[] {
|
||||
return this.#catalogue;
|
||||
}
|
||||
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
if (registration.singleton !== undefined) {
|
||||
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`);
|
||||
}
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const shape = serviceMemberShape(classified.members);
|
||||
this.#assertSingletonShape(registration, shape);
|
||||
registration.singleton = this.#createInstance(registration, classified, undefined);
|
||||
registration.singletonShape = shape;
|
||||
}
|
||||
|
||||
/** Disconnect one singleton while preserving active subscriptions and remote facades. */
|
||||
withdraw<T>(service: Service<T>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const previous = registration.singleton;
|
||||
if (previous === undefined) return;
|
||||
previous.active = false;
|
||||
for (const remove of previous.removeMemberListeners) remove();
|
||||
delete registration.singleton;
|
||||
this.#emit(registration, { type: "unavailable" });
|
||||
}
|
||||
|
||||
/** Check a singleton replacement without changing the active provider. */
|
||||
validateReplacement<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
this.#assertSingletonShape(registration, serviceMemberShape(classified.members));
|
||||
}
|
||||
|
||||
/** Replace one singleton without making its stable remote facade unavailable. */
|
||||
replace<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton");
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const shape = serviceMemberShape(classified.members);
|
||||
this.#assertSingletonShape(registration, shape);
|
||||
const replacement = this.#createInstance(registration, classified, undefined);
|
||||
const previous = registration.singleton;
|
||||
if (previous !== undefined) {
|
||||
previous.active = false;
|
||||
for (const remove of previous.removeMemberListeners) remove();
|
||||
}
|
||||
registration.singleton = replacement;
|
||||
registration.singletonShape = shape;
|
||||
this.#emit(registration, { type: "replaced", snapshot: this.#snapshotInstance(replacement) });
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registrations.get(service.id);
|
||||
if (registration?.mode !== "singleton" || registration.singleton === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Remote service ${service.id} has no local provider`);
|
||||
}
|
||||
return registration.singleton.implementation as T;
|
||||
}
|
||||
|
||||
spawn<T>(service: Service<T>, key: string, implementation: NoInfer<RemoteServiceContract<T>>): () => void {
|
||||
this.#assertActive();
|
||||
this.#assertRemotable(service);
|
||||
this.#assertAllowed(service.id);
|
||||
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
|
||||
const registration = this.#registration(service.id, "keyed");
|
||||
if (registration.instances.has(key)) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${service.id} already has a live instance with key ${key}`,
|
||||
);
|
||||
}
|
||||
const generation = (registration.generations.get(key) ?? 0) + 1;
|
||||
registration.generations.set(key, generation);
|
||||
const address = { key, generation } satisfies ServiceInstanceAddress;
|
||||
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
|
||||
const instance = this.#createInstance(registration, classified, address);
|
||||
registration.instances.set(key, instance);
|
||||
this.#emit(registration, { type: "spawned", instance: this.#snapshotInstance(instance) });
|
||||
let closed = false;
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (registration.instances.get(key) !== instance) return;
|
||||
instance.active = false;
|
||||
for (const remove of instance.removeMemberListeners) remove();
|
||||
registration.instances.delete(key);
|
||||
this.#emit(registration, { type: "closed", instance: address });
|
||||
};
|
||||
}
|
||||
|
||||
async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(call.serviceId);
|
||||
const registration = this.#registrations.get(call.serviceId);
|
||||
if (registration === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`);
|
||||
}
|
||||
const instance = this.#resolveInstance(registration, call.instance);
|
||||
const member = instance.members.get(call.member);
|
||||
if (member === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_not_found",
|
||||
`Unknown remote service member ${call.serviceId}.${call.member}`,
|
||||
);
|
||||
}
|
||||
if (member.kind !== "method") {
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service member ${call.serviceId}.${call.member} is not a method`,
|
||||
);
|
||||
}
|
||||
const result: unknown = await Reflect.apply(member.method, instance.implementation, [...call.args, context]);
|
||||
return result as JsonValue | undefined;
|
||||
}
|
||||
|
||||
subscribe(
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate, context: Context) => void,
|
||||
): ServiceSubscription {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(serviceId);
|
||||
const registration = this.#registration(serviceId, mode);
|
||||
if (registration.mode === "singleton" && registration.singleton === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`);
|
||||
}
|
||||
const subscriber: ProviderSubscriber = {
|
||||
listener,
|
||||
buffer: [],
|
||||
active: false,
|
||||
terminated: false,
|
||||
closed: false,
|
||||
};
|
||||
this.#publishPending(registration);
|
||||
registration.subscribers.add(subscriber);
|
||||
const snapshot = this.#snapshot(registration);
|
||||
return {
|
||||
snapshot,
|
||||
activate: () => {
|
||||
if (subscriber.closed || subscriber.active) return;
|
||||
subscriber.active = true;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
for (const entry of subscriber.buffer.splice(0)) {
|
||||
try {
|
||||
listener(entry.update, entry.context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (subscriber.terminated) subscriber.closed = true;
|
||||
}
|
||||
throwCollectedErrors(errors, "Failed to activate remote service subscription");
|
||||
},
|
||||
close: () => {
|
||||
if (subscriber.closed) return;
|
||||
subscriber.closed = true;
|
||||
subscriber.buffer.length = 0;
|
||||
registration.subscribers.delete(subscriber);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
const errors: unknown[] = [];
|
||||
for (const registration of this.#registrations.values()) {
|
||||
const singleton = registration.singleton;
|
||||
if (singleton !== undefined) {
|
||||
singleton.active = false;
|
||||
for (const remove of singleton.removeMemberListeners) remove();
|
||||
delete registration.singleton;
|
||||
try {
|
||||
this.#emit(registration, { type: "unavailable" });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
for (const [key, instance] of [...registration.instances]) {
|
||||
instance.active = false;
|
||||
for (const remove of instance.removeMemberListeners) remove();
|
||||
registration.instances.delete(key);
|
||||
try {
|
||||
this.#emit(registration, { type: "closed", instance: instance.address! });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.active) {
|
||||
subscriber.closed = true;
|
||||
subscriber.buffer.length = 0;
|
||||
} else {
|
||||
subscriber.terminated = true;
|
||||
}
|
||||
}
|
||||
registration.subscribers.clear();
|
||||
}
|
||||
this.#registrations.clear();
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose remote service provider");
|
||||
}
|
||||
|
||||
#registration(serviceId: string, mode: ServiceMode): ServiceRegistration {
|
||||
const registration = this.#registrations.get(serviceId);
|
||||
if (registration === undefined) {
|
||||
throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
|
||||
}
|
||||
if (registration.mode !== mode) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${serviceId} is ${registration.mode}, not ${mode}`,
|
||||
);
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
#createInstance(
|
||||
registration: ServiceRegistration,
|
||||
classified: ClassifiedRemoteServiceImplementation,
|
||||
address: ServiceInstanceAddress | undefined,
|
||||
): ProviderInstance {
|
||||
const removeMemberListeners: (() => void)[] = [];
|
||||
const instance: ProviderInstance = {
|
||||
...(address === undefined ? {} : { address }),
|
||||
implementation: classified.implementation,
|
||||
members: classified.members,
|
||||
removeMemberListeners,
|
||||
active: true,
|
||||
};
|
||||
for (const [name, member] of classified.members) {
|
||||
if (member.kind !== "state") continue;
|
||||
removeMemberListeners.push(
|
||||
member.state.subscribe((ops, sequence, context) => {
|
||||
if (!instance.active) return;
|
||||
this.#emit(
|
||||
registration,
|
||||
{
|
||||
type: "state",
|
||||
...(address === undefined ? {} : { instance: address }),
|
||||
member: name,
|
||||
sequence,
|
||||
ops,
|
||||
},
|
||||
context,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
#assertSingletonShape(registration: ServiceRegistration, replacement: ReadonlyMap<string, ServiceMemberKind>): void {
|
||||
const current = registration.singletonShape;
|
||||
if (current === undefined || sameServiceMemberShape(current, replacement)) return;
|
||||
throw new RemoteServiceError(
|
||||
"service_member_mismatch",
|
||||
`Remote service ${registration.serviceId} replacement must preserve its member shape`,
|
||||
);
|
||||
}
|
||||
|
||||
#resolveInstance(registration: ServiceRegistration, address: ServiceInstanceAddress | undefined): ProviderInstance {
|
||||
if (registration.mode === "singleton") {
|
||||
if (address !== undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_mode_mismatch",
|
||||
`Remote service ${registration.serviceId} is singleton`,
|
||||
);
|
||||
}
|
||||
if (registration.singleton === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_not_found",
|
||||
`Remote service ${registration.serviceId} has no provider`,
|
||||
);
|
||||
}
|
||||
return registration.singleton;
|
||||
}
|
||||
if (address === undefined) {
|
||||
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${registration.serviceId} is keyed`);
|
||||
}
|
||||
const instance = registration.instances.get(address.key);
|
||||
if (instance === undefined) {
|
||||
throw new RemoteServiceError(
|
||||
"service_instance_not_found",
|
||||
`Remote service ${registration.serviceId} has no instance ${address.key}`,
|
||||
);
|
||||
}
|
||||
if (instance.address?.generation !== address.generation) {
|
||||
throw new RemoteServiceError(
|
||||
"service_stale_instance",
|
||||
`Remote service ${registration.serviceId} instance ${address.key} is stale`,
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
#publishPending(registration: ServiceRegistration): void {
|
||||
const context = serviceDeliveryContext();
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
? registration.singleton === undefined
|
||||
? []
|
||||
: [registration.singleton]
|
||||
: registration.instances.values();
|
||||
for (const instance of instances) {
|
||||
for (const member of instance.members.values()) {
|
||||
if (member.kind === "state") member.state.publish(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#snapshot(registration: ServiceRegistration): ServiceSubscriptionSnapshot {
|
||||
const instances =
|
||||
registration.mode === "singleton"
|
||||
? registration.singleton
|
||||
? [this.#snapshotInstance(registration.singleton)]
|
||||
: []
|
||||
: [...registration.instances.values()]
|
||||
.sort((left, right) => left.address!.key.localeCompare(right.address!.key))
|
||||
.map((instance) => this.#snapshotInstance(instance));
|
||||
return { serviceId: registration.serviceId, mode: registration.mode, instances };
|
||||
}
|
||||
|
||||
#snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot {
|
||||
const members: ServiceMemberSnapshot[] = [];
|
||||
for (const [name, member] of instance.members) {
|
||||
if (member.kind === "method") {
|
||||
members.push({ name, kind: "method" });
|
||||
} else {
|
||||
members.push({
|
||||
name,
|
||||
kind: "state",
|
||||
sequence: member.state.sequence,
|
||||
ops: [["r", member.state.value as JsonValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(instance.address === undefined ? {} : { instance: instance.address }),
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
|
||||
if (registration.subscribers.size === 0) return;
|
||||
const deliveryContext = context ?? serviceDeliveryContext();
|
||||
const errors: unknown[] = [];
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.closed) continue;
|
||||
const entry = { update, context: deliveryContext };
|
||||
if (!subscriber.active) {
|
||||
subscriber.buffer.push(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
subscriber.listener(entry.update, entry.context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
throwCollectedErrors(errors, `Failed to publish remote service ${registration.serviceId} update`);
|
||||
}
|
||||
|
||||
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
|
||||
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
|
||||
}
|
||||
|
||||
#assertAllowed(serviceId: string): void {
|
||||
if (!this.#registrations.has(serviceId)) {
|
||||
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
|
||||
}
|
||||
}
|
||||
|
||||
#assertActive(): void {
|
||||
if (this.#disposed) throw new Error("Remote service provider is disposed");
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteServiceEndpoint(provider: RemoteServiceProvider): RemoteServiceEndpoint {
|
||||
const subscriptions = new Map<string, ServiceSubscription>();
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
async invoke(call, publish, context) {
|
||||
if (disposed) throw new Error("Remote service endpoint is disposed");
|
||||
const control = decodeServiceControlCall(call);
|
||||
if (control?.type === "catalogue") return provider.catalogue as unknown as JsonValue;
|
||||
if (control?.type === "subscribe") {
|
||||
if (subscriptions.has(control.subscriptionId)) {
|
||||
throw new Error("Service subscription ID is already active");
|
||||
}
|
||||
const subscription = provider.subscribe(control.serviceId, control.mode, (update, updateContext) => {
|
||||
void Promise.resolve(publish(control.subscriptionId, update, updateContext)).catch(() => {});
|
||||
});
|
||||
subscriptions.set(control.subscriptionId, subscription);
|
||||
subscription.activate();
|
||||
return subscription.snapshot as unknown as JsonValue;
|
||||
}
|
||||
if (control?.type === "unsubscribe") {
|
||||
const subscription = subscriptions.get(control.subscriptionId);
|
||||
if (subscription === undefined) throw new Error("Service subscription was not found");
|
||||
subscription.close();
|
||||
subscriptions.delete(control.subscriptionId);
|
||||
return undefined;
|
||||
}
|
||||
return provider.invoke(call, context);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const subscription of subscriptions.values()) subscription.close();
|
||||
subscriptions.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRemoteServiceImplementation(serviceId: string, implementation: unknown): void {
|
||||
void classifyRemoteServiceImplementation(serviceId, implementation);
|
||||
}
|
||||
|
||||
function classifyRemoteServiceImplementation(
|
||||
serviceId: string,
|
||||
implementation: unknown,
|
||||
): ClassifiedRemoteServiceImplementation {
|
||||
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
|
||||
throw new TypeError(`Remote service ${serviceId} implementation must be an object`);
|
||||
}
|
||||
const members = new Map<string, InstanceMember>();
|
||||
for (const name of Object.keys(implementation).sort()) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(implementation, name);
|
||||
if (descriptor === undefined || !("value" in descriptor)) {
|
||||
throw new TypeError(`Remote service member ${serviceId}.${name} must be a data property`);
|
||||
}
|
||||
if (typeof descriptor.value === "function") {
|
||||
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod });
|
||||
continue;
|
||||
}
|
||||
const state = getReplicatedStateInternals(descriptor.value);
|
||||
if (state !== undefined) {
|
||||
members.set(name, { kind: "state", state });
|
||||
continue;
|
||||
}
|
||||
throw new TypeError(`Remote service member ${serviceId}.${name} is not remotely exposable`);
|
||||
}
|
||||
if (members.size === 0) throw new TypeError(`Remote service ${serviceId} has no members`);
|
||||
return { implementation, members };
|
||||
}
|
||||
|
||||
function throwCollectedErrors(errors: readonly unknown[], message: string): void {
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, message);
|
||||
}
|
||||
|
||||
function serviceMemberShape(members: ReadonlyMap<string, InstanceMember>): ReadonlyMap<string, ServiceMemberKind> {
|
||||
return new Map([...members].map(([name, member]) => [name, member.kind]));
|
||||
}
|
||||
|
||||
function sameServiceMemberShape(
|
||||
left: ReadonlyMap<string, ServiceMemberKind>,
|
||||
right: ReadonlyMap<string, ServiceMemberKind>,
|
||||
): boolean {
|
||||
return left.size === right.size && [...left].every(([name, kind]) => right.get(name) === kind);
|
||||
}
|
||||
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import { type Decoder, decoder, type Encoder, encoder } from "../delta/index.ts";
|
||||
import type {
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
import type { WireServiceProviderUpdate, WireServiceSubscriptionSnapshot } from "./wire.ts";
|
||||
|
||||
/** Stateful operation encoders for every replicated state in one service subscription. */
|
||||
export interface ServiceStateEncoder {
|
||||
encodeSnapshot(snapshot: ServiceSubscriptionSnapshot): WireServiceSubscriptionSnapshot;
|
||||
encodeUpdate(update: ServiceProviderUpdate): WireServiceProviderUpdate;
|
||||
}
|
||||
|
||||
/** Stateful operation decoders for every replicated state in one service subscription. */
|
||||
export interface ServiceStateDecoder {
|
||||
decodeSnapshot(snapshot: WireServiceSubscriptionSnapshot): ServiceSubscriptionSnapshot;
|
||||
decodeUpdate(update: WireServiceProviderUpdate): ServiceProviderUpdate;
|
||||
}
|
||||
|
||||
interface CodecEntry<C> {
|
||||
readonly instance: ServiceInstanceAddress | undefined;
|
||||
readonly codec: C;
|
||||
}
|
||||
|
||||
class StateCodecRegistry<C> {
|
||||
readonly #create: () => C;
|
||||
readonly #entries = new Map<string, CodecEntry<C>>();
|
||||
|
||||
constructor(create: () => C) {
|
||||
this.#create = create;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
add(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const key = stateKey(instance, member);
|
||||
if (this.#entries.has(key)) throw new Error(`Duplicate service state ${describeState(instance, member)}`);
|
||||
const codec = this.#create();
|
||||
this.#entries.set(key, { instance, codec });
|
||||
return codec;
|
||||
}
|
||||
|
||||
get(instance: ServiceInstanceAddress | undefined, member: string): C {
|
||||
const codec = this.#entries.get(stateKey(instance, member))?.codec;
|
||||
if (codec === undefined) throw new Error(`Unknown service state ${describeState(instance, member)}`);
|
||||
return codec;
|
||||
}
|
||||
|
||||
removeInstance(instance: ServiceInstanceAddress): void {
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (sameAddress(entry.instance, instance)) this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createServiceStateEncoder(): ServiceStateEncoder {
|
||||
const codecs = new StateCodecRegistry<Encoder>(encoder);
|
||||
return {
|
||||
encodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => encodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
encodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).encode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: encodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: encodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServiceStateDecoder(): ServiceStateDecoder {
|
||||
const codecs = new StateCodecRegistry<Decoder>(decoder);
|
||||
return {
|
||||
decodeSnapshot(snapshot) {
|
||||
codecs.reset();
|
||||
return {
|
||||
...snapshot,
|
||||
instances: snapshot.instances.map((instance) => decodeInstance(instance, codecs)),
|
||||
};
|
||||
},
|
||||
decodeUpdate(update) {
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
return { ...update, ops: codecs.get(update.instance, update.member).decode(update.ops) };
|
||||
case "replaced":
|
||||
codecs.reset();
|
||||
return { ...update, snapshot: decodeInstance(update.snapshot, codecs) };
|
||||
case "spawned":
|
||||
return { ...update, instance: decodeInstance(update.instance, codecs) };
|
||||
case "unavailable":
|
||||
codecs.reset();
|
||||
return update;
|
||||
case "closed":
|
||||
codecs.removeInstance(update.instance);
|
||||
return update;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeInstance(
|
||||
instance: ServiceInstanceSnapshot,
|
||||
codecs: StateCodecRegistry<Encoder>,
|
||||
): WireServiceSubscriptionSnapshot["instances"][number] {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).encode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeInstance(
|
||||
instance: WireServiceSubscriptionSnapshot["instances"][number],
|
||||
codecs: StateCodecRegistry<Decoder>,
|
||||
): ServiceInstanceSnapshot {
|
||||
return {
|
||||
...instance,
|
||||
members: instance.members.map((member) =>
|
||||
member.kind === "state"
|
||||
? { ...member, ops: codecs.add(instance.instance, member.name).decode(member.ops) }
|
||||
: member,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stateKey(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return JSON.stringify([instance?.key ?? null, instance?.generation ?? null, member]);
|
||||
}
|
||||
|
||||
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress): boolean {
|
||||
return left?.key === right.key && left.generation === right.generation;
|
||||
}
|
||||
|
||||
function describeState(instance: ServiceInstanceAddress | undefined, member: string): string {
|
||||
return instance === undefined ? member : `${instance.key}@${instance.generation}.${member}`;
|
||||
}
|
||||
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface ReplicatedStateInternals {
|
||||
readonly sequence: number;
|
||||
readonly value: unknown;
|
||||
publish(context: Context): void;
|
||||
subscribe(listener: (ops: readonly Op[], sequence: number, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
const sources = new WeakMap<object, ReplicatedStateInternals>();
|
||||
|
||||
export function registerReplicatedStateInternals(value: object, internals: ReplicatedStateInternals): void {
|
||||
sources.set(value, internals);
|
||||
}
|
||||
|
||||
export function getReplicatedStateInternals(value: unknown): ReplicatedStateInternals | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
return sources.get(value);
|
||||
}
|
||||
139
third_party/pi/packages/chord/src/services/state.ts
vendored
Normal file
139
third_party/pi/packages/chord/src/services/state.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { applyImmutable, isBase, type Op, type Tracker, track } from "../delta/index.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts";
|
||||
import { registerReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
export class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>();
|
||||
readonly #tracker: Tracker<T>;
|
||||
#publishedValue: T;
|
||||
#sequence = 0;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#tracker = track(initial);
|
||||
this.#publishedValue = applyImmutable(undefined, this.#tracker.flush()) as unknown as T;
|
||||
const thisSource = this;
|
||||
registerReplicatedStateInternals(this, {
|
||||
get sequence() {
|
||||
return thisSource.#sequence;
|
||||
},
|
||||
get value() {
|
||||
return thisSource.#publishedValue;
|
||||
},
|
||||
publish: (context) => thisSource.publish(context),
|
||||
subscribe: (listener) => {
|
||||
thisSource.#sourceListeners.add(listener);
|
||||
return () => thisSource.#sourceListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#publishedValue;
|
||||
}
|
||||
|
||||
get state(): T {
|
||||
return this.#tracker.state;
|
||||
}
|
||||
|
||||
publish(context: Context): void {
|
||||
const ops = this.#tracker.flush();
|
||||
if (ops.length === 0) return;
|
||||
this.#sequence += 1;
|
||||
this.#publishedValue = applyImmutable(this.#publishedValue as unknown as JsonValue, ops) as unknown as T;
|
||||
for (const listener of [...this.#sourceListeners]) listener(ops, this.#sequence, context);
|
||||
const delivery = { kind: "update", sequence: this.#sequence } as const;
|
||||
for (const listener of [...this.#listeners]) listener(this.#publishedValue, context, delivery);
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
const context = serviceDeliveryContext();
|
||||
this.publish(context);
|
||||
this.#listeners.add(listener);
|
||||
listener(this.#publishedValue, context, { kind: "hydrate", sequence: this.#sequence });
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/** A cold read-only state used by service consumers until a complete snapshot arrives. */
|
||||
export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements ReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#value: T | undefined;
|
||||
#sequence: number | undefined;
|
||||
|
||||
constructor(reportError: (error: Error) => void) {
|
||||
this.#reportError = reportError;
|
||||
}
|
||||
|
||||
get value(): T | undefined {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
if (this.#value !== undefined) {
|
||||
this.#deliver(listener, this.#value, serviceDeliveryContext(), {
|
||||
kind: "hydrate",
|
||||
sequence: this.#sequence!,
|
||||
});
|
||||
}
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch");
|
||||
const value = applyImmutable<T>(undefined, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context, { kind: "hydrate", sequence });
|
||||
}
|
||||
|
||||
update(sequence: number, ops: readonly Op[], context: Context): void {
|
||||
if (this.#sequence === undefined || this.#value === undefined) {
|
||||
throw new Error("Replicated state received an update before hydration");
|
||||
}
|
||||
if (sequence !== this.#sequence + 1) {
|
||||
this.clear();
|
||||
throw new Error("Replicated state update sequence has a gap");
|
||||
}
|
||||
const value = applyImmutable(this.#value, ops);
|
||||
this.#sequence = sequence;
|
||||
this.#value = value;
|
||||
this.#deliverAll(context, { kind: "update", sequence });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#value = undefined;
|
||||
this.#sequence = undefined;
|
||||
}
|
||||
|
||||
#deliverAll(context: Context, delivery: ReplicatedStateDelivery): void {
|
||||
if (this.#value === undefined) return;
|
||||
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context, delivery);
|
||||
}
|
||||
|
||||
#deliver(
|
||||
listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void,
|
||||
value: T,
|
||||
context: Context,
|
||||
delivery: ReplicatedStateDelivery,
|
||||
): void {
|
||||
try {
|
||||
listener(value, context, delivery);
|
||||
} catch (error) {
|
||||
this.#reportError(toError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Context for synthetic service deliveries without a caller. */
|
||||
export function serviceDeliveryContext(): Context {
|
||||
// TODO: Add delivery-scoped cancellation or metadata if deliveries gain an owned lifecycle.
|
||||
return BACKGROUND_CONTEXT;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
Normal file
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
import { assertValidOp, assertValidWireOp, type WireOp } from "../delta/index.ts";
|
||||
import type {
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
|
||||
export type WireServiceMemberSnapshot =
|
||||
| { readonly name: string; readonly kind: "method" }
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly WireOp[] };
|
||||
|
||||
export type WireServiceInstanceSnapshot = {
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly members: readonly WireServiceMemberSnapshot[];
|
||||
};
|
||||
|
||||
export type WireServiceSubscriptionSnapshot = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
readonly instances: readonly WireServiceInstanceSnapshot[];
|
||||
};
|
||||
|
||||
export type WireServiceProviderUpdate =
|
||||
| {
|
||||
readonly type: "state";
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
readonly sequence: number;
|
||||
readonly ops: readonly WireOp[];
|
||||
}
|
||||
| { readonly type: "unavailable" }
|
||||
| { readonly type: "replaced"; readonly snapshot: WireServiceInstanceSnapshot }
|
||||
| { readonly type: "spawned"; readonly instance: WireServiceInstanceSnapshot }
|
||||
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
|
||||
|
||||
const SERVICE_CONTROL_ID = "$chord.service";
|
||||
const SERVICE_CATALOGUE_MEMBER = "catalogue";
|
||||
const SERVICE_SUBSCRIBE_MEMBER = "subscribe";
|
||||
const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe";
|
||||
|
||||
export type ServiceControlCall =
|
||||
| { readonly type: "catalogue" }
|
||||
| {
|
||||
readonly type: "subscribe";
|
||||
readonly subscriptionId: string;
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
}
|
||||
| { readonly type: "unsubscribe"; readonly subscriptionId: string };
|
||||
|
||||
export function createServiceCatalogueCall(): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_CATALOGUE_MEMBER, args: [] };
|
||||
}
|
||||
|
||||
export function createServiceSubscribeCall(subscriptionId: string, serviceId: string, mode: ServiceMode): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_SUBSCRIBE_MEMBER, args: [subscriptionId, serviceId, mode] };
|
||||
}
|
||||
|
||||
export function createServiceUnsubscribeCall(subscriptionId: string): ServiceCall {
|
||||
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_UNSUBSCRIBE_MEMBER, args: [subscriptionId] };
|
||||
}
|
||||
|
||||
export function decodeServiceControlCall(call: ServiceCall): ServiceControlCall | undefined {
|
||||
if (call.serviceId !== SERVICE_CONTROL_ID || call.instance !== undefined) return undefined;
|
||||
if (call.member === SERVICE_CATALOGUE_MEMBER && call.args.length === 0) return { type: "catalogue" };
|
||||
if (
|
||||
call.member === SERVICE_SUBSCRIBE_MEMBER &&
|
||||
call.args.length === 3 &&
|
||||
isId(call.args[0]) &&
|
||||
isId(call.args[1]) &&
|
||||
(call.args[2] === "singleton" || call.args[2] === "keyed")
|
||||
) {
|
||||
return {
|
||||
type: "subscribe",
|
||||
subscriptionId: call.args[0],
|
||||
serviceId: call.args[1],
|
||||
mode: call.args[2],
|
||||
};
|
||||
}
|
||||
if (call.member === SERVICE_UNSUBSCRIBE_MEMBER && call.args.length === 1 && isId(call.args[0])) {
|
||||
return { type: "unsubscribe", subscriptionId: call.args[0] };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseServiceCall(value: unknown): ServiceCall {
|
||||
const call = record(value, "service call");
|
||||
assertKeys(call, ["serviceId", "member", "args"], ["instance"], "service call");
|
||||
if (!isId(call.serviceId) || !isId(call.member) || !Array.isArray(call.args)) {
|
||||
throw new TypeError("Invalid service call");
|
||||
}
|
||||
if (call.instance !== undefined) assertAddress(call.instance);
|
||||
return value as ServiceCall;
|
||||
}
|
||||
|
||||
export function parseServiceCatalogue(value: unknown): readonly ServiceCatalogueEntry[] {
|
||||
if (!Array.isArray(value)) throw new TypeError("Invalid service catalogue");
|
||||
const ids = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
const entry = record(candidate, "service catalogue entry");
|
||||
assertKeys(entry, ["serviceId", "mode"], [], "service catalogue entry");
|
||||
if (!isId(entry.serviceId) || !isMode(entry.mode) || ids.has(entry.serviceId)) {
|
||||
throw new TypeError("Invalid service catalogue");
|
||||
}
|
||||
ids.add(entry.serviceId);
|
||||
}
|
||||
return value as unknown as readonly ServiceCatalogueEntry[];
|
||||
}
|
||||
|
||||
export function parseServiceSubscriptionSnapshot(value: unknown): ServiceSubscriptionSnapshot {
|
||||
assertSubscriptionSnapshot(value, assertValidOp);
|
||||
return value as ServiceSubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export function parseWireServiceSubscriptionSnapshot(value: unknown): WireServiceSubscriptionSnapshot {
|
||||
assertSubscriptionSnapshot(value, assertValidWireOp);
|
||||
return value as WireServiceSubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export function parseServiceProviderUpdate(value: unknown): ServiceProviderUpdate {
|
||||
assertProviderUpdate(value, assertValidOp);
|
||||
return value as ServiceProviderUpdate;
|
||||
}
|
||||
|
||||
export function parseWireServiceProviderUpdate(value: unknown): WireServiceProviderUpdate {
|
||||
assertProviderUpdate(value, assertValidWireOp);
|
||||
return value as WireServiceProviderUpdate;
|
||||
}
|
||||
|
||||
function assertSubscriptionSnapshot(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const snapshot = record(value, "service subscription snapshot");
|
||||
assertKeys(snapshot, ["serviceId", "mode", "instances"], [], "service subscription snapshot");
|
||||
if (!isId(snapshot.serviceId) || !isMode(snapshot.mode) || !Array.isArray(snapshot.instances)) {
|
||||
throw new TypeError("Invalid service subscription snapshot");
|
||||
}
|
||||
for (const instance of snapshot.instances) assertInstance(instance, assertOp);
|
||||
}
|
||||
|
||||
function assertProviderUpdate(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const update = record(value, "service provider update");
|
||||
switch (update.type) {
|
||||
case "state":
|
||||
assertKeys(update, ["type", "member", "sequence", "ops"], ["instance"], "state update");
|
||||
if (!isId(update.member) || !isInteger(update.sequence, 1) || !Array.isArray(update.ops)) {
|
||||
throw new TypeError("Invalid service state update");
|
||||
}
|
||||
if (update.instance !== undefined) assertAddress(update.instance);
|
||||
for (const op of update.ops) assertOp(op);
|
||||
return;
|
||||
case "unavailable":
|
||||
assertKeys(update, ["type"], [], "unavailable update");
|
||||
return;
|
||||
case "replaced":
|
||||
assertKeys(update, ["type", "snapshot"], [], "replacement update");
|
||||
assertInstance(update.snapshot, assertOp);
|
||||
return;
|
||||
case "spawned":
|
||||
assertKeys(update, ["type", "instance"], [], "spawn update");
|
||||
assertInstance(update.instance, assertOp);
|
||||
return;
|
||||
case "closed":
|
||||
assertKeys(update, ["type", "instance"], [], "close update");
|
||||
assertAddress(update.instance);
|
||||
return;
|
||||
default:
|
||||
throw new TypeError("Invalid service provider update");
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstance(value: unknown, assertOp: (value: unknown) => void): void {
|
||||
const instance = record(value, "service instance snapshot");
|
||||
assertKeys(instance, ["members"], ["instance"], "service instance snapshot");
|
||||
if (instance.instance !== undefined) assertAddress(instance.instance);
|
||||
if (!Array.isArray(instance.members)) throw new TypeError("Invalid service instance snapshot");
|
||||
for (const candidate of instance.members) {
|
||||
const member = record(candidate, "service member snapshot");
|
||||
if (member.kind === "method") {
|
||||
assertKeys(member, ["name", "kind"], [], "service method snapshot");
|
||||
if (!isId(member.name)) throw new TypeError("Invalid service method snapshot");
|
||||
continue;
|
||||
}
|
||||
if (member.kind === "state") {
|
||||
assertKeys(member, ["name", "kind", "sequence", "ops"], [], "service state snapshot");
|
||||
if (!isId(member.name) || !isInteger(member.sequence, 0) || !Array.isArray(member.ops)) {
|
||||
throw new TypeError("Invalid service state snapshot");
|
||||
}
|
||||
for (const op of member.ops) assertOp(op);
|
||||
continue;
|
||||
}
|
||||
throw new TypeError("Invalid service member snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAddress(value: unknown): asserts value is ServiceInstanceAddress {
|
||||
const address = record(value, "service instance address");
|
||||
assertKeys(address, ["key", "generation"], [], "service instance address");
|
||||
if (!isId(address.key) || !isInteger(address.generation, 1)) {
|
||||
throw new TypeError("Invalid service instance address");
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, description: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`Invalid ${description}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
description: string,
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new TypeError(`Invalid ${description}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isId(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isMode(value: unknown): value is ServiceMode {
|
||||
return value === "singleton" || value === "keyed";
|
||||
}
|
||||
|
||||
function isInteger(value: unknown, minimum: number): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= minimum;
|
||||
}
|
||||
Reference in New Issue
Block a user