UNPKG

@mastra/core

Version:
667 lines (662 loc) 22.2 kB
import EventEmitter from 'events'; // src/events/pubsub.ts var PubSub = class { /** * Delivery modes this PubSub implementation supports. * * Defaults to `['pull']` for backward compatibility — third-party * implementations that don't override this property are treated as * pull-mode, which preserves today's behavior. * * Implementations that deliver events without an active read loop (e.g. * EventEmitter, GCP Pub/Sub push subscriptions) should declare `'push'`. * Implementations that support both modes should declare both. */ get supportedModes() { return ["pull"]; } /** * Whether this implementation honors `options.batch` on `subscribe()` * natively. Defaults to `false`. * * Implementations that integrate batching internally (e.g. against their * own broker retention or via an `AckHandleBuffer`) override this getter * and return `true`. */ get supportsNativeBatching() { return false; } /** * Get historical events for a topic. * Default implementation returns empty array (no history support). * Override in implementations that support event caching. * * @param topic - The topic to get history for * @param offset - Starting index (0-based), defaults to 0 * @returns Array of events from the specified index */ getHistory(_topic, _offset) { return Promise.resolve([]); } /** * Subscribe to a topic with automatic replay of cached events. * First replays any cached history, then subscribes to live events. * Default implementation falls back to regular subscribe (no replay). * Override in implementations that support event caching. * * @param topic - The topic to subscribe to * @param cb - Callback invoked for each event (both cached and live) */ subscribeWithReplay(topic, cb) { return this.subscribe(topic, cb); } /** * Subscribe to a topic with replay starting from a specific index. * This is more efficient than full replay when the client knows their last position. * Default implementation falls back to subscribeWithReplay (full replay). * Override in implementations that support indexed event caching. * * @param topic - The topic to subscribe to * @param offset - Start replaying from this index (0-based) * @param cb - Callback invoked for each event */ subscribeFromOffset(topic, _offset, cb) { return this.subscribeWithReplay(topic, cb); } }; function isLeaseProvider(value) { if (!value || typeof value !== "object" && typeof value !== "function") return false; const candidate = value; return typeof candidate.acquireLease === "function" && typeof candidate.getLeaseOwner === "function" && typeof candidate.releaseLease === "function" && typeof candidate.renewLease === "function" && typeof candidate.transferLease === "function"; } var NoopLeaseProvider = { acquireLease(_key, owner, _ttlMs) { return Promise.resolve({ acquired: true, owner }); }, getLeaseOwner(_key) { return Promise.resolve(void 0); }, releaseLease(_key, _owner) { return Promise.resolve(); }, renewLease(_key, _owner, _ttlMs) { return Promise.resolve(true); }, transferLease(_key, _fromOwner, _toOwner, _ttlMs) { return Promise.resolve(true); } }; // src/events/event-emitter/batch-policy.ts var defaultDeps = { now: () => Date.now(), setTimeout: (cb, ms) => setTimeout(cb, ms), clearTimeout: (handle) => clearTimeout(handle) }; var DEFAULT_MAX_BUFFER_SIZE = 256; var DEFAULT_OVERFLOW = "coalesce-or-drop-oldest"; var BatchPolicy = class { opts; deps; maxBufferSize; overflow; firstQueuedAt = null; lastDeliveredAt = -Infinity; size = 0; timer = null; flushHandler = null; constructor(opts, deps = defaultDeps) { this.opts = opts; this.deps = deps; this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; this.overflow = opts.overflow ?? DEFAULT_OVERFLOW; } /** Bind the function invoked when the deadline timer fires. */ bindFlushHandler(fn) { this.flushHandler = fn; } /** * Called by the integrator each time an event is enqueued. * Returns whether the integrator should flush immediately. */ onEnqueue(event) { this.size += 1; if (this.firstQueuedAt === null) { this.firstQueuedAt = this.deps.now(); } const now = this.deps.now(); const intervalFloor = this.lastDeliveredAt + (this.opts.minIntervalMs ?? 0); if (this.opts.isImmediate?.(event)) { if (now >= intervalFloor) { return "flush-now"; } this.scheduleAt(intervalFloor); return "wait"; } if (this.size >= this.maxBufferSize) { return "flush-now"; } if (this.opts.maxSize !== void 0 && this.size >= this.opts.maxSize) { if (now >= intervalFloor) { return "flush-now"; } this.scheduleAt(intervalFloor); return "wait"; } this.scheduleDeadline(); return "wait"; } /** * Called by the integrator after a successful flush has delivered * `deliveredCount` events. Resets timer + firstQueuedAt. */ onFlushed(deliveredCount) { this.lastDeliveredAt = this.deps.now(); this.size = Math.max(0, this.size - deliveredCount); this.firstQueuedAt = null; this.cancelTimer(); } /** * Pure helper. Given the caller-owned queue contents, applies `coalesce` * and `overflow` to decide what to deliver and what to drop. * Order-preserving for kept events. */ prepareBatch(events) { let working = events; if (this.opts.coalesce) { const coalesced = this.opts.coalesce(working); const inputRefs = new Set(working); const allInInput = coalesced.every((e) => inputRefs.has(e)); working = allInInput ? coalesced : []; } const keptRefs = new Set(working); const computeDropped = () => events.filter((e) => !keptRefs.has(e)); if (working.length <= this.maxBufferSize) { return { delivered: working, dropped: computeDropped() }; } const overBy = working.length - this.maxBufferSize; const isImmediate = this.opts.isImmediate; let kept; let droppedFromOverflow; switch (this.overflow) { case "drop-newest": { const splitFromEnd = this.takeWithoutDropping( working, overBy, isImmediate, /* fromEnd */ true ); kept = splitFromEnd.kept; droppedFromOverflow = splitFromEnd.dropped; break; } case "drop-oldest": case "coalesce-or-drop-oldest": default: { const splitFromStart = this.takeWithoutDropping( working, overBy, isImmediate, /* fromEnd */ false ); kept = splitFromStart.kept; droppedFromOverflow = splitFromStart.dropped; break; } } return { delivered: kept, dropped: [...computeDropped(), ...droppedFromOverflow] }; } /** Stop the timer and clear policy state. */ dispose() { this.cancelTimer(); this.flushHandler = null; this.firstQueuedAt = null; this.size = 0; } scheduleDeadline() { if (this.opts.maxWaitMs === void 0 && this.opts.minIntervalMs === void 0) { return; } const firstQueuedAt = this.firstQueuedAt ?? this.deps.now(); const deadline = this.opts.maxWaitMs !== void 0 ? firstQueuedAt + this.opts.maxWaitMs : Number.POSITIVE_INFINITY; const floor = this.lastDeliveredAt + (this.opts.minIntervalMs ?? 0); const at = Math.max(deadline, floor); this.scheduleAt(at); } scheduleAt(at) { if (!isFinite(at)) { return; } this.cancelTimer(); const delay = Math.max(0, at - this.deps.now()); this.timer = this.deps.setTimeout(() => { this.timer = null; const handler = this.flushHandler; if (handler) { void handler(); } }, delay); } cancelTimer() { if (this.timer !== null) { this.deps.clearTimeout(this.timer); this.timer = null; } } /** * Drop `count` non-immediate items from the start (or end) of `items`. * Immediate items are never dropped — if every candidate is immediate, * fewer than `count` items are dropped. */ takeWithoutDropping(items, count, isImmediate, fromEnd) { const dropped = []; const result = [...items]; let remaining = count; const order = fromEnd ? [...result.keys()].reverse() : [...result.keys()]; for (const idx of order) { if (remaining === 0) break; const ev = result[idx]; if (isImmediate?.(ev)) continue; dropped.push(ev); result[idx] = void 0; remaining -= 1; } const kept = result.filter((e) => e !== void 0); return { kept, dropped }; } }; // src/events/event-emitter/ack-handle-buffer.ts var AckHandleBuffer = class { constructor(cb, opts, deps, onError) { this.cb = cb; this.onError = onError; this.policy = new BatchPolicy(opts, deps); this.policy.bindFlushHandler( () => this.flush().catch((err) => { this.onError?.(err, { phase: "cb" }); }) ); } cb; onError; policy; queue = []; flushing = false; reflush = false; disposed = false; /** * Called by the adapter for each event arriving from the underlying transport. */ async push(event, ack, nack) { if (this.disposed) return; this.queue.push({ event, ack, nack }); const decision = this.policy.onEnqueue(event); if (decision === "flush-now") { await this.flush(); } } /** * Drain the current queue regardless of policy state. Safe to call from * adapter `flush()` or external code that wants to force delivery. */ async flush() { if (this.flushing) { this.reflush = true; return; } if (this.queue.length === 0) return; this.flushing = true; try { do { this.reflush = false; if (this.queue.length === 0) break; const snapshot = this.queue; this.queue = []; const events = snapshot.map((e) => e.event); const byEvent = /* @__PURE__ */ new Map(); for (const e of snapshot) byEvent.set(e.event, e); const { delivered, dropped } = this.policy.prepareBatch(events); for (const ev of dropped) { const entry = byEvent.get(ev); if (entry?.ack) { try { await entry.ack(); } catch (err) { this.onError?.(err, { phase: "ack-dropped" }); } } } for (const ev of delivered) { if (this.disposed) break; const entry = byEvent.get(ev); try { await this.cb(ev, entry?.ack, entry?.nack); } catch (err) { this.onError?.(err, { phase: "cb" }); } } this.policy.onFlushed(delivered.length + dropped.length); } while (this.reflush && !this.disposed); } finally { this.flushing = false; } } dispose() { this.disposed = true; this.queue = []; this.policy.dispose(); } }; // src/events/event-emitter/index.ts var NOOP_ACK = async () => { }; var EventEmitterPubSub = class extends PubSub { // EventEmitter dispatches synchronously to listeners, so it can serve both // a push consumer (no worker) and a pull-style worker that simply calls // `subscribe()` to register a listener. Both modes are advertised so the // default in-process setup keeps using OrchestrationWorker, while // genuinely push-only transports (GCP Pub/Sub push, SNS, EventBridge) // declare `['push']` only and skip the worker. get supportedModes() { return ["pull", "push"]; } /** * `EventEmitterPubSub` is strictly in-process, so the `AckHandleBuffer` * queue it uses for batching shares the same lifetime as everything * else here. Nothing more durable is promised, and nothing less is * needed. */ get supportsNativeBatching() { return true; } emitter; // group → topic → callbacks[] groups = /* @__PURE__ */ new Map(); // "topic:group" → round-robin counter groupCounters = /* @__PURE__ */ new Map(); // "topic:group" → the single listener registered on the emitter for this group groupListeners = /* @__PURE__ */ new Map(); // Track pending nack redeliveries so flush() can wait and close() can cancel them pendingNacks = /* @__PURE__ */ new Set(); // Track delivery attempts per message id deliveryAttempts = /* @__PURE__ */ new Map(); // topic → (original callback → wrapped listener) for fan-out (non-group) subscribers. // Nested keying so the same callback registered on multiple topics keeps // a distinct wrapper per topic. fanoutWrappers = /* @__PURE__ */ new Map(); // topic → (original callback → buffer). Present only for subscribers that // opt into batching via `options.batch`. The buffer is the destination of // the emitter listener; it invokes the user cb according to its policy. batchBuffers = /* @__PURE__ */ new Map(); logger; constructor(existingEmitter, options = {}) { super(); this.emitter = existingEmitter ?? new EventEmitter(); this.logger = options.logger; } /** * Debug-hostile silent failures are the default for emitter listeners. * Surface buffer-side errors on a single channel so they're at least visible. */ logBufferError(topic, err, ctx) { const message = `[EventEmitterPubSub] batched ${ctx.phase} failed for ${topic}`; if (this.logger) { this.logger.error(message, err); } else { console.error(message, err); } } async publish(topic, event, _options) { const id = crypto.randomUUID(); const createdAt = /* @__PURE__ */ new Date(); this.emitter.emit(topic, { ...event, id, createdAt, deliveryAttempt: 1 }); } async subscribe(topic, cb, options) { if (options?.batch) { const buffer = new AckHandleBuffer(cb, options.batch, void 0, (err, ctx) => { this.logBufferError(topic, err, ctx); }); let byCb = this.batchBuffers.get(topic); if (!byCb) { byCb = /* @__PURE__ */ new Map(); this.batchBuffers.set(topic, byCb); } byCb.set(cb, buffer); if (options.group) { this.subscribeWithGroup(topic, cb, options.group); } else { const wrapper = (event) => { void buffer.push(event, NOOP_ACK, NOOP_ACK).catch((err) => { this.logBufferError(topic, err, { phase: "cb" }); }); }; let byCbFanout = this.fanoutWrappers.get(topic); if (!byCbFanout) { byCbFanout = /* @__PURE__ */ new Map(); this.fanoutWrappers.set(topic, byCbFanout); } byCbFanout.set(cb, wrapper); this.emitter.on(topic, wrapper); } return; } if (options?.group) { this.subscribeWithGroup(topic, cb, options.group); } else { const wrapper = (event) => { cb(event, NOOP_ACK, NOOP_ACK); }; let byCb = this.fanoutWrappers.get(topic); if (!byCb) { byCb = /* @__PURE__ */ new Map(); this.fanoutWrappers.set(topic, byCb); } byCb.set(cb, wrapper); this.emitter.on(topic, wrapper); } } async unsubscribe(topic, cb) { const byCbBuffers = this.batchBuffers.get(topic); const buffer = byCbBuffers?.get(cb); if (buffer && byCbBuffers) { buffer.dispose(); byCbBuffers.delete(cb); if (byCbBuffers.size === 0) this.batchBuffers.delete(topic); } for (const [group, topicMap] of this.groups) { const members = topicMap.get(topic); if (members) { const idx = members.indexOf(cb); if (idx !== -1) { members.splice(idx, 1); if (members.length === 0) { topicMap.delete(topic); const listenerKey = `${topic}:${group}`; const listener = this.groupListeners.get(listenerKey); if (listener) { this.emitter.off(topic, listener); this.groupListeners.delete(listenerKey); this.groupCounters.delete(listenerKey); } } if (topicMap.size === 0) { this.groups.delete(group); } return; } } } const byCb = this.fanoutWrappers.get(topic); const wrapper = byCb?.get(cb); if (wrapper && byCb) { this.emitter.off(topic, wrapper); byCb.delete(cb); if (byCb.size === 0) this.fanoutWrappers.delete(topic); } else { this.emitter.off(topic, cb); } } async flush() { while (true) { const drains = []; for (const [topic, byCb] of this.batchBuffers.entries()) { for (const buffer of byCb.values()) { drains.push({ topic, promise: buffer.flush() }); } } if (drains.length > 0) { const results = await Promise.allSettled(drains.map((d) => d.promise)); for (let i = 0; i < results.length; i++) { const result = results[i]; if (result.status === "rejected") { this.logBufferError(drains[i].topic, result.reason, { phase: "cb" }); } } } if (this.pendingNacks.size === 0) { return; } await new Promise((resolve) => { const check = () => { if (this.pendingNacks.size === 0) { resolve(); } else { setTimeout(check, 10); } }; check(); }); } } /** * Clean up all listeners during graceful shutdown. */ async close() { for (const handle of this.pendingNacks) { clearTimeout(handle); } this.pendingNacks.clear(); this.deliveryAttempts.clear(); for (const byCb of this.batchBuffers.values()) { for (const buffer of byCb.values()) { buffer.dispose(); } } this.batchBuffers.clear(); this.emitter.removeAllListeners(); this.groups.clear(); this.groupCounters.clear(); this.groupListeners.clear(); this.fanoutWrappers.clear(); } subscribeWithGroup(topic, cb, group) { let topicMap = this.groups.get(group); if (!topicMap) { topicMap = /* @__PURE__ */ new Map(); this.groups.set(group, topicMap); } let members = topicMap.get(topic); if (!members) { members = []; topicMap.set(topic, members); } members.push(cb); const listenerKey = `${topic}:${group}`; if (!this.groupListeners.has(listenerKey)) { const listener = (event) => { this.deliverToGroup(topic, group, listenerKey, event); }; this.groupListeners.set(listenerKey, listener); this.emitter.on(topic, listener); } } deliverToGroup(topic, group, listenerKey, event) { const currentMembers = this.groups.get(group)?.get(topic); if (!currentMembers || currentMembers.length === 0) return; const counter = this.groupCounters.get(listenerKey) ?? 0; const idx = counter % currentMembers.length; this.groupCounters.set(listenerKey, counter + 1); const attemptKey = `${listenerKey}:${event.id}`; const attempt = this.deliveryAttempts.get(attemptKey) ?? 1; const eventWithAttempt = { ...event, deliveryAttempt: attempt }; const ack = async () => { this.deliveryAttempts.delete(attemptKey); }; const nack = async () => { this.deliveryAttempts.set(attemptKey, attempt + 1); const handle = setTimeout(() => { this.pendingNacks.delete(handle); this.deliverToGroup(topic, group, listenerKey, event); }, 0); this.pendingNacks.add(handle); }; const member = currentMembers[idx]; const buffer = this.batchBuffers.get(topic)?.get(member); if (buffer) { void buffer.push(eventWithAttempt, ack, nack).catch((err) => { this.logBufferError(topic, err, { phase: "cb" }); }); } else { member(eventWithAttempt, ack, nack); } } // key → { owner, expiresAt }. In-process so a single Map is enough; // there is no other process to race against. The same owner can renew // their own lease; expired entries are reclaimed lazily on the next // acquireLease call. leases = /* @__PURE__ */ new Map(); acquireLease(key, owner, ttlMs) { const now = Date.now(); const existing = this.leases.get(key); if (existing && existing.expiresAt > now && existing.owner !== owner) { return Promise.resolve({ acquired: false, owner: existing.owner }); } this.leases.set(key, { owner, expiresAt: now + ttlMs }); return Promise.resolve({ acquired: true, owner }); } getLeaseOwner(key) { const existing = this.leases.get(key); if (!existing) return Promise.resolve(void 0); if (existing.expiresAt <= Date.now()) { this.leases.delete(key); return Promise.resolve(void 0); } return Promise.resolve(existing.owner); } releaseLease(key, owner) { const existing = this.leases.get(key); if (existing && existing.owner === owner) { this.leases.delete(key); } return Promise.resolve(); } renewLease(key, owner, ttlMs) { const existing = this.leases.get(key); if (!existing || existing.owner !== owner || existing.expiresAt <= Date.now()) { return Promise.resolve(false); } existing.expiresAt = Date.now() + ttlMs; return Promise.resolve(true); } // Atomic owner-guarded handoff: only the current owner can transfer, and the // key is never empty during the swap (mirrors the Redis GET==from -> SET to // Lua). Lets a finishing run hand the lease to its drain run without a // release/acquire gap, even on the in-process backend. transferLease(key, fromOwner, toOwner, ttlMs) { const existing = this.leases.get(key); if (!existing || existing.owner !== fromOwner || existing.expiresAt <= Date.now()) { return Promise.resolve(false); } this.leases.set(key, { owner: toOwner, expiresAt: Date.now() + ttlMs }); return Promise.resolve(true); } }; export { EventEmitterPubSub, NoopLeaseProvider, PubSub, isLeaseProvider }; //# sourceMappingURL=chunk-2S76KRB5.js.map //# sourceMappingURL=chunk-2S76KRB5.js.map