@mastra/core
Version:
232 lines (230 loc) • 7.7 kB
JavaScript
import { PubSub, isLeaseProvider } from './chunk-2S76KRB5.js';
// src/events/caching-pubsub.ts
var CachingPubSub = class extends PubSub {
constructor(inner, cache, options = {}) {
super();
this.inner = inner;
this.cache = cache;
this.keyPrefix = options.keyPrefix ?? "pubsub:";
this.logger = options.logger;
}
inner;
cache;
keyPrefix;
logger;
/** Maps original callbacks to their wrapped versions for proper unsubscribe */
callbackMap = /* @__PURE__ */ new Map();
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);
const events = await this.cache.listFromTo(cacheKey, offset);
return events;
}
/**
* 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 isLeaseProvider(this.inner) ? this.inner : void 0;
}
/**
* Clear cached events for a specific topic.
* Call this when a stream completes to free memory.
* Also clears the index counter.
*/
async clearTopic(topic) {
const cacheKey = this.getCacheKey(topic);
const counterKey = this.getCounterKey(topic);
await Promise.all([this.cache.delete(cacheKey), this.cache.delete(counterKey)]);
}
/**
* Get the inner PubSub instance.
* Useful for accessing implementation-specific methods like close().
*/
getInner() {
return this.inner;
}
};
function withCaching(pubsub, cache, options) {
return new CachingPubSub(pubsub, cache, options);
}
export { CachingPubSub, withCaching };
//# sourceMappingURL=chunk-BOHPE4XX.js.map
//# sourceMappingURL=chunk-BOHPE4XX.js.map