@mastra/core
Version:
284 lines (283 loc) • 9.3 kB
JavaScript
const require_event_emitter = require("./event-emitter-80LrFIBS.cjs");
//#region src/events/caching-pubsub.ts
/**
* A PubSub decorator that adds event caching and replay capabilities.
*
* Wraps any PubSub implementation and uses MastraServerCache to:
* - Cache all published events per topic
* - Enable replay of cached events for late subscribers
*
* This enables resumable streams - clients can disconnect and reconnect
* without missing events.
*
* ## Batching
*
* `CachingPubSub` is transparent to `options.batch`: `subscribe()` forwards
* the option to the inner PubSub, and `supportsNativeBatching` mirrors the
* inner's value. Wrapping a non-native inner with `{ batch: {...} }` results
* in unbatched delivery — use an inner that returns
* `supportsNativeBatching === true` (e.g. `EventEmitterPubSub`) if you need
* batched delivery.
*
* @example
* ```typescript
* import { EventEmitterPubSub, CachingPubSub } from '@mastra/core/events';
* import { InMemoryServerCache } from '@mastra/core/cache';
*
* const cache = new InMemoryServerCache();
* const pubsub = new CachingPubSub(new EventEmitterPubSub(), cache);
*
* // Subscribe with replay - receives cached events first, then live
* await pubsub.subscribeWithReplay('my-topic', (event) => {
* console.log(event);
* });
* ```
*/
var CachingPubSub = class extends require_event_emitter.PubSub {
inner;
cache;
keyPrefix;
logger;
/** Maps original callbacks to their wrapped versions for proper unsubscribe */
callbackMap = /* @__PURE__ */ new Map();
constructor(inner, cache, options = {}) {
super();
this.inner = inner;
this.cache = cache;
this.keyPrefix = options.keyPrefix ?? "pubsub:";
this.logger = options.logger;
}
get supportsNativeBatching() {
return this.inner.supportsNativeBatching;
}
/**
* Log an error message using the configured logger or console.error.
*/
logError(message, error) {
if (this.logger) this.logger.error(message, error);
else console.error(message, error);
}
/**
* Stable key used to deduplicate an event across the cache-replay and
* live-delivery paths.
*
* We cannot dedup on `event.id`: `CachingPubSub.publish` assigns the id and
* caches the event with it, but inner PubSub implementations
* (EventEmitterPubSub, UnixSocketPubSub, …) regenerate `id` inside their own
* `publish`, so the cached copy and the live copy of the SAME publish carry
* different ids. The sequential `index` is assigned here and is preserved by
* every inner implementation, so it matches across both paths. Events without
* an index are never cached (see `publish`), so they can't be replay/live
* duplicated — falling back to `id` for them is safe.
*/
dedupKey(event) {
return event.index !== void 0 ? `i:${event.index}` : `id:${event.id}`;
}
/**
* Get the cache key for a topic's event list
*/
getCacheKey(topic) {
return `${this.keyPrefix}${topic}`;
}
/**
* Get the cache key for a topic's index counter
*/
getCounterKey(topic) {
return `${this.keyPrefix}${topic}:counter`;
}
/**
* Publish an event to a topic.
* The event is cached with a sequential index before being published to the inner PubSub.
*
* Uses atomic increment for index assignment to prevent race conditions
* when multiple events are published concurrently.
*/
async publish(topic, event, options) {
const cacheKey = this.getCacheKey(topic);
const counterKey = this.getCounterKey(topic);
let index;
let indexFailed = false;
try {
index = await this.cache.increment(counterKey) - 1;
} catch (error) {
this.logError(`[CachingPubSub] Failed to increment counter for ${topic}`, error);
indexFailed = true;
}
const fullEvent = {
...event,
id: crypto.randomUUID(),
createdAt: /* @__PURE__ */ new Date(),
...index !== void 0 ? { index } : {}
};
if (!indexFailed) try {
await this.cache.listPush(cacheKey, fullEvent);
} catch (error) {
this.logError(`[CachingPubSub] Failed to cache event for ${topic}`, error);
}
await this.inner.publish(topic, fullEvent, options);
}
/**
* Subscribe to live events on a topic (no replay).
*/
async subscribe(topic, cb, options) {
await this.inner.subscribe(topic, cb, options);
}
/**
* Subscribe to a topic with automatic replay of cached events.
* Delegates to {@link subscribeFromOffset} with offset 0.
*/
async subscribeWithReplay(topic, cb) {
return this.subscribeFromOffset(topic, 0, cb);
}
/**
* Subscribe to a topic with replay starting from a specific index.
* More efficient than full replay when the client knows their last position.
*
* Order of operations:
* 1. Subscribe to live events FIRST — buffer deliveries during bootstrap
* 2. Fetch and deliver cached history in order
* 3. Drain the buffer, skipping events already delivered via history
* 4. Switch to passthrough with an index watermark for steady-state dedup
*
* @param topic - The topic to subscribe to
* @param offset - Start replaying from this index (0-based)
* @param cb - Callback invoked for each event
*/
async subscribeFromOffset(topic, offset, cb) {
let bootstrapping = true;
const buffer = [];
let lastDelivered = -1;
const wrappedCb = (event, ack, nack) => {
if (typeof event.index === "number" && event.index < offset) return;
if (bootstrapping) {
buffer.push({
event,
ack,
nack
});
return;
}
const isRetry = typeof event.deliveryAttempt === "number" && event.deliveryAttempt > 1;
if (typeof event.index === "number" && event.index <= lastDelivered && !isRetry) return;
if (typeof event.index === "number" && event.index > lastDelivered) lastDelivered = event.index;
cb(event, ack, nack);
};
this.callbackMap.set(cb, wrappedCb);
await this.inner.subscribe(topic, wrappedCb);
try {
const seen = /* @__PURE__ */ new Set();
const history = await this.getHistory(topic, offset);
for (const event of history) {
const key = this.dedupKey(event);
seen.add(key);
if (typeof event.index === "number") lastDelivered = event.index;
cb(event);
}
for (const { event, ack, nack } of buffer) {
const key = this.dedupKey(event);
if (seen.has(key)) continue;
seen.add(key);
if (typeof event.index === "number") lastDelivered = event.index;
cb(event, ack, nack);
}
bootstrapping = false;
buffer.length = 0;
} catch (error) {
this.callbackMap.delete(cb);
await this.inner.unsubscribe(topic, wrappedCb).catch(() => {});
throw error;
}
}
/**
* Unsubscribe from a topic.
*/
async unsubscribe(topic, cb) {
const wrappedCb = this.callbackMap.get(cb) ?? cb;
this.callbackMap.delete(cb);
await this.inner.unsubscribe(topic, wrappedCb);
}
/**
* Get historical events for a topic from cache.
*/
async getHistory(topic, offset = 0) {
const cacheKey = this.getCacheKey(topic);
return await this.cache.listFromTo(cacheKey, offset);
}
/**
* Flush any pending operations on the inner PubSub.
*/
async flush() {
await this.inner.flush();
}
/**
* Expose the inner's {@link LeaseProvider} when it has one, otherwise
* `undefined`. Leasing is a capability of the underlying backend
* (e.g. Redis), not of the caching decorator itself — so rather than
* unconditionally declaring lease methods (which would make
* {@link isLeaseProvider} report `true` even when the inner can't
* coordinate a lock), we surface the inner's capability directly. The
* signals runtime unwraps this so wrapping with caching preserves real
* distributed lease semantics without faking them.
*/
getLeaseProvider() {
return require_event_emitter.isLeaseProvider(this.inner) ? this.inner : void 0;
}
/**
* Clear cached events for a specific topic (and the index counter), and
* forward the clear to the inner transport.
*
* Call this when a stream completes to free memory. The forward matters for
* persistent inner transports (e.g. Redis Streams): without it, wrapping a
* pubsub in `CachingPubSub` silently turns `clearTopic` into a cache-only
* no-op and the inner stream leaks forever.
*/
async clearTopic(topic) {
const cacheKey = this.getCacheKey(topic);
const counterKey = this.getCounterKey(topic);
try {
await Promise.all([
this.cache.delete(cacheKey),
this.cache.delete(counterKey),
this.inner.clearTopic(topic)
]);
} catch (error) {
this.logError(`[CachingPubSub] Failed to clear topic ${topic}`, error);
}
}
/**
* Get the inner PubSub instance.
* Useful for accessing implementation-specific methods like close().
*/
getInner() {
return this.inner;
}
};
/**
* Factory function to wrap a PubSub with caching capabilities.
*
* @example
* ```typescript
* import { withCaching, EventEmitterPubSub } from '@mastra/core/events';
* import { InMemoryServerCache } from '@mastra/core/cache';
*
* const cache = new InMemoryServerCache();
* const pubsub = withCaching(new EventEmitterPubSub(), cache);
* ```
*/
function withCaching(pubsub, cache, options) {
return new CachingPubSub(pubsub, cache, options);
}
//#endregion
Object.defineProperty(exports, "CachingPubSub", {
enumerable: true,
get: function() {
return CachingPubSub;
}
});
Object.defineProperty(exports, "withCaching", {
enumerable: true,
get: function() {
return withCaching;
}
});
//# sourceMappingURL=caching-pubsub-DBB7kAgu.cjs.map