@ydbjs/topic
Version:
YDB Topics client for publish-subscribe messaging. Provides at-least-once delivery, exactly-once publishing, FIFO guarantees, and scalable message processing for unstructured data.
141 lines • 5.47 kB
JavaScript
import { createActor } from 'xstate';
import { abortable } from '@ydbjs/abortable';
import { WriterMachine } from './machine.js';
import { SeqNoManager } from './seqno-manager.js';
export class TopicWriter {
#actor;
#promise = null;
#subscription;
#seqNoManager;
constructor(driver, options) {
this.#seqNoManager = new SeqNoManager();
this.#actor = createActor(WriterMachine, { input: { driver, options } });
// Subscribe to state changes for flush completions
this.#subscription = this.#actor.subscribe((snapshot) => {
// When all messages are processed (buffer and inflight empty),
// resolve current flush promise if it exists
if (snapshot.context.bufferLength === 0 && snapshot.context.inflightLength === 0) {
this.#promise?.resolve(this.#seqNoManager.getState().lastSeqNo);
this.#promise = null;
}
});
// Subscribe to emitted events for seqNo management
this.#actor.on('writer.session', (event) => {
this.#seqNoManager.initialize(event.lastSeqNo);
});
// Subscribe to error events
this.#actor.on('writer.error', (event) => {
// Reject any pending flush promise
this.#promise?.reject(event.error);
this.#promise = null;
});
// Note: We don't update lastSeqNo from MESSAGES_ACKNOWLEDGED
// ACKs are just confirmations for messages we already sent
// lastSeqNo is managed internally when write() is called
this.#actor.start();
}
/**
* Write a message to the topic
* @param data Message payload
* @param extra Optional message metadata
* @returns Sequence number of the message
*/
write(data, extra) {
// Get seqNo from SeqNoManager (handles auto/manual modes)
let seqNo = this.#seqNoManager.getNext(extra?.seqNo);
this.#actor.send({
type: 'writer.write',
message: {
data,
seqNo,
...(extra?.createdAt && { createdAt: extra.createdAt }),
...(extra?.metadataItems && { metadataItems: extra.metadataItems })
}
});
return seqNo;
}
/**
* Flush all buffered messages and wait for acknowledgment
* @param signal Optional AbortSignal to cancel the flush operation
* @returns Promise that resolves with the last acknowledged sequence number
*/
async flush(signal) {
// If there's already a flush in progress, return the same promise
if (this.#promise) {
// If signal is provided, wrap existing promise with abortable
if (signal) {
return abortable(signal, this.#promise.promise);
}
return this.#promise.promise;
}
// Check if already flushed
let snapshot = this.#actor.getSnapshot();
if (snapshot.context.bufferLength === 0 && snapshot.context.inflightLength === 0) {
// Already flushed, return immediately
return Promise.resolve(this.#seqNoManager.getState().lastSeqNo);
}
// Create new flush promise using Promise.withResolvers()
this.#promise = Promise.withResolvers();
// Send flush request to state machine
this.#actor.send({ type: 'writer.flush' });
// If signal is provided, wrap with abortable
if (signal) {
return abortable(signal, this.#promise.promise);
}
return this.#promise.promise;
}
/**
* Get current writer statistics
*/ get stats() {
let snapshot = this.#actor.getSnapshot();
let seqNoState = this.#seqNoManager.getState();
return {
state: snapshot.value,
lastSeqNo: seqNoState.lastSeqNo,
nextSeqNo: seqNoState.nextSeqNo,
seqNoMode: seqNoState.mode,
bufferSize: snapshot.context.bufferSize,
bufferLength: snapshot.context.bufferLength,
inflightSize: snapshot.context.inflightSize,
inflightLength: snapshot.context.inflightLength,
};
}
/**
* Close the writer gracefully, waiting for all messages to be sent
*/
async close(signal) {
let { promise, resolve } = Promise.withResolvers();
let subscription = this.#actor.subscribe((snapshot) => {
if (snapshot.value === 'closed') {
resolve();
}
});
this.#actor.send({ type: 'writer.close' });
return (signal ? abortable(signal, promise) : promise)
.finally(() => {
subscription.unsubscribe();
});
}
/**
* Destroy the writer immediately without waiting
*/
destroy(reason) {
// Reject any pending flush
this.#promise?.reject(new Error('Writer was destroyed'));
this.#promise = null;
// Send destroy event (optional - for cleanup logic)
this.#actor.send({ type: 'writer.destroy', ...(reason && { reason }) });
// Immediately stop the actor
this.#actor.stop();
// Clean up subscription
this.#subscription.unsubscribe();
}
/**
* AsyncDisposable implementation - graceful close with resource cleanup
*/
async [Symbol.asyncDispose]() {
await this.close();
this.destroy();
}
}
//# sourceMappingURL=writer.js.map