blob: e063b6fa4086535d3aafa6348755ad24862ceb47 [file]
import {
encodeProtocolFrame,
type SessionContinuitySnapshot,
type SubscriptionFrame,
type SubscriptionOpenResult,
} from '../protocol/index.js';
const MAX_CLIENT_QUEUED_FRAMES = 32;
const MAX_CLIENT_QUEUED_BYTES = 256 * 1024;
export type RuntimeHostSubscriptionFailureReason =
| 'sequence_gap'
| 'host_epoch_changed'
| 'correlation_changed'
| 'projection_revision_invalid'
| 'slow_consumer'
| 'connection_closed';
export class RuntimeHostSubscriptionError extends Error {
constructor(
readonly reason: RuntimeHostSubscriptionFailureReason,
message: string,
) {
super(message);
this.name = 'RuntimeHostSubscriptionError';
}
}
export interface RuntimeHostSessionSubscription extends AsyncIterable<SubscriptionFrame> {
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
close(): Promise<void>;
}
interface QueuedFrame {
frame: SubscriptionFrame;
encodedBytes: number;
}
export class ClientSessionSubscription
implements RuntimeHostSessionSubscription, AsyncIterator<SubscriptionFrame>
{
readonly hostEpoch: string;
readonly subscriptionId: string;
readonly snapshot: SessionContinuitySnapshot;
readonly #requestClose: () => Promise<void>;
readonly #expectedSessionId: string;
readonly #queue: QueuedFrame[] = [];
#queuedBytes = 0;
#expectedSequence: number;
#latestProjectionRevision: number;
#waiting:
| {
resolve(value: IteratorResult<SubscriptionFrame>): void;
reject(error: Error): void;
}
| undefined;
#terminalError: Error | undefined;
#done = false;
#doneAfterQueue = false;
#closeTask: Promise<void> | undefined;
constructor(result: SubscriptionOpenResult, requestClose: () => Promise<void>) {
this.hostEpoch = result.hostEpoch;
this.subscriptionId = result.subscriptionId;
this.snapshot = result.snapshot;
this.#expectedSessionId = result.snapshot.session.sessionId;
this.#expectedSequence = result.nextSequence;
this.#latestProjectionRevision = result.snapshot.projectionRevision;
this.#requestClose = requestClose;
}
[Symbol.asyncIterator](): AsyncIterator<SubscriptionFrame> {
return this;
}
next(): Promise<IteratorResult<SubscriptionFrame>> {
const queued = this.#queue.shift();
if (queued) {
this.#queuedBytes -= queued.encodedBytes;
if (this.#queue.length === 0 && this.#doneAfterQueue) this.#done = true;
return Promise.resolve({ done: false, value: queued.frame });
}
if (this.#terminalError) return Promise.reject(this.#terminalError);
if (this.#done || this.#doneAfterQueue) {
this.#done = true;
return Promise.resolve({ done: true, value: undefined });
}
if (this.#waiting) {
return Promise.reject(new Error('Session subscription already has a pending iterator read'));
}
return new Promise((resolve, reject) => {
this.#waiting = { resolve, reject };
});
}
async return(): Promise<IteratorResult<SubscriptionFrame>> {
await this.close();
return { done: true, value: undefined };
}
close(): Promise<void> {
if (this.#done || this.#terminalError) return Promise.resolve();
if (!this.#closeTask) this.#closeTask = this.#requestClose();
return this.#closeTask;
}
accept(frame: SubscriptionFrame): void {
if (this.#done || this.#terminalError) return;
if (this.#doneAfterQueue) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription received a frame after closure',
);
}
if (frame.hostEpoch !== this.hostEpoch) {
throw new RuntimeHostSubscriptionError(
'host_epoch_changed',
'Session subscription Host Epoch changed',
);
}
if (frame.subscriptionId !== this.subscriptionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription correlation changed',
);
}
if (frame.sequence !== this.#expectedSequence) {
throw new RuntimeHostSubscriptionError(
'sequence_gap',
`Session subscription expected sequence ${this.#expectedSequence} but received ${frame.sequence}`,
);
}
this.#expectedSequence += 1;
if (frame.kind === 'subscription.session_projection') {
if (frame.snapshot.session.sessionId !== this.#expectedSessionId) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription projection identity changed',
);
}
if (frame.snapshot.projectionRevision <= this.#latestProjectionRevision) {
throw new RuntimeHostSubscriptionError(
'projection_revision_invalid',
'Session projection revision did not advance',
);
}
this.#latestProjectionRevision = frame.snapshot.projectionRevision;
} else if (
(frame.kind === 'subscription.session_delta' ||
frame.kind === 'subscription.session_event') &&
frame.sessionId !== this.#expectedSessionId
) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
'Session subscription frame identity changed',
);
}
this.#offer(frame);
if (frame.kind === 'subscription.closed') this.#doneAfterQueue = true;
}
finish(): void {
if (this.#done || this.#terminalError) return;
this.#doneAfterQueue = true;
if (this.#queue.length === 0) {
this.#done = true;
this.#waiting?.resolve({ done: true, value: undefined });
this.#waiting = undefined;
}
}
fail(error: Error): void {
if (this.#done || this.#terminalError) return;
this.#terminalError = error;
this.#queue.length = 0;
this.#queuedBytes = 0;
this.#waiting?.reject(error);
this.#waiting = undefined;
}
#offer(frame: SubscriptionFrame): void {
if (this.#waiting) {
const waiting = this.#waiting;
this.#waiting = undefined;
waiting.resolve({ done: false, value: frame });
return;
}
const encodedBytes = encodeProtocolFrame(frame).byteLength;
if (
this.#queue.length >= MAX_CLIENT_QUEUED_FRAMES ||
this.#queuedBytes + encodedBytes > MAX_CLIENT_QUEUED_BYTES
) {
throw new RuntimeHostSubscriptionError(
'slow_consumer',
'Session subscription consumer exceeded its local queue bound',
);
}
this.#queue.push({ frame, encodedBytes });
this.#queuedBytes += encodedBytes;
}
}