UNPKG

@mastra/core

Version:
235 lines (232 loc) 7.78 kB
'use strict'; var chunkMB3TIFKT_cjs = require('./chunk-MB3TIFKT.cjs'); // src/events/caching-pubsub.ts var CachingPubSub = class extends chunkMB3TIFKT_cjs.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 chunkMB3TIFKT_cjs.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); } exports.CachingPubSub = CachingPubSub; exports.withCaching = withCaching; //# sourceMappingURL=chunk-OXETR7NG.cjs.map //# sourceMappingURL=chunk-OXETR7NG.cjs.map