@mastra/core
Version:
676 lines (668 loc) • 22.4 kB
JavaScript
'use strict';
var EventEmitter = require('events');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
// 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__default.default();
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);
}
};
exports.EventEmitterPubSub = EventEmitterPubSub;
exports.NoopLeaseProvider = NoopLeaseProvider;
exports.PubSub = PubSub;
exports.isLeaseProvider = isLeaseProvider;
//# sourceMappingURL=chunk-MB3TIFKT.cjs.map
//# sourceMappingURL=chunk-MB3TIFKT.cjs.map