@mastra/core
Version:
672 lines (671 loc) • 21.1 kB
JavaScript
const require_rolldown_runtime = require("./rolldown-runtime-uwYp4b74.cjs");
let events = require("events");
events = require_rolldown_runtime.__toESM(events, 1);
//#region src/events/pubsub.ts
var PubSub = class {
/**
* Delete all retained state for a topic (cached history, persistent stream
* entries, consumer groups) once no more events will be published to it.
*
* Called by run lifecycles (durable agents, the evented workflow engine)
* when a run reaches a terminal state, so per-run topics don't accumulate
* forever on transports that retain messages (e.g. Redis Streams).
*
* Default implementation is a no-op: transports that don't retain anything
* per topic (e.g. plain EventEmitter delivery) have nothing to clear.
*
* Best-effort contract: implementations should not throw — callers invoke
* this fire-and-forget at cleanup boundaries, so failures should be logged
* by the implementation rather than rejected.
*
* @param topic - The topic whose retained state should be deleted
*/
clearTopic(_topic) {
return Promise.resolve();
}
/**
* 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);
}
};
/**
* Duck-typed check for whether a value implements {@link LeaseProvider}.
*
* Uses structural detection rather than `instanceof` so it works across
* package boundaries (e.g. a separately-published pubsub backend resolving
* a different copy of `@mastra/core`).
*/
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";
}
/**
* Always-win / no-op {@link LeaseProvider}. Used by the signals runtime
* when the configured pubsub does not implement `LeaseProvider` — this
* preserves single-process behavior where every caller "wins" its own
* lease race and release/renew are inert.
*/
const 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);
}
};
//#endregion
//#region src/events/event-emitter/batch-policy.ts
const defaultDeps = {
now: () => Date.now(),
setTimeout: (cb, ms) => setTimeout(cb, ms),
clearTimeout: (handle) => clearTimeout(handle)
};
/**
* Internal to `EventEmitterPubSub`. Embedded by `AckHandleBuffer` to decide
* when a batched subscription should flush (size, time, coalesce, overflow).
*
* Not part of the public API — users configure batching via
* `SubscribeBatchOptions` on `subscribe`.
*/
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 ?? 256;
this.overflow = opts.overflow ?? "coalesce-or-drop-oldest";
}
/** 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);
working = coalesced.every((e) => inputRefs.has(e)) ? 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, true);
kept = splitFromEnd.kept;
droppedFromOverflow = splitFromEnd.dropped;
break;
}
default: {
const splitFromStart = this.takeWithoutDropping(working, overBy, isImmediate, 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) 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;
}
return {
kept: result.filter((e) => e !== void 0),
dropped
};
}
};
//#endregion
//#region src/events/event-emitter/ack-handle-buffer.ts
/**
* In-process queue used by `EventEmitterPubSub` to turn its
* one-event-per-emit stream into batched callback invocations.
* Owns a `BatchPolicy` that decides when to flush (size, time,
* quiet-period) and holds (event, ack, nack) triples in publish
* order until that decision fires.
*
* Extracted from `EventEmitterPubSub` only so the batching state
* machine can be tested in isolation. Not a public extension point.
* State is per-process; the queue dies with the process.
*/
var AckHandleBuffer = class {
cb;
onError;
policy;
queue = [];
flushing = false;
reflush = false;
disposed = false;
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" });
}));
}
/**
* 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
});
if (this.policy.onEnqueue(event) === "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();
}
};
//#endregion
//#region src/events/event-emitter/index.ts
const NOOP_ACK = async () => {};
var EventEmitterPubSub = class extends PubSub {
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;
groups = /* @__PURE__ */ new Map();
groupCounters = /* @__PURE__ */ new Map();
groupListeners = /* @__PURE__ */ new Map();
pendingNacks = /* @__PURE__ */ new Set();
deliveryAttempts = /* @__PURE__ */ new Map();
fanoutWrappers = /* @__PURE__ */ new Map();
batchBuffers = /* @__PURE__ */ new Map();
logger;
constructor(existingEmitter, options = {}) {
super();
this.emitter = existingEmitter ?? new events.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) => {
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) buffer.push(eventWithAttempt, ack, nack).catch((err) => {
this.logBufferError(topic, err, { phase: "cb" });
});
else member(eventWithAttempt, ack, nack);
}
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);
}
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);
}
};
//#endregion
Object.defineProperty(exports, "EventEmitterPubSub", {
enumerable: true,
get: function() {
return EventEmitterPubSub;
}
});
Object.defineProperty(exports, "NoopLeaseProvider", {
enumerable: true,
get: function() {
return NoopLeaseProvider;
}
});
Object.defineProperty(exports, "PubSub", {
enumerable: true,
get: function() {
return PubSub;
}
});
Object.defineProperty(exports, "isLeaseProvider", {
enumerable: true,
get: function() {
return isLeaseProvider;
}
});
//# sourceMappingURL=event-emitter-80LrFIBS.cjs.map