@mastra/core
Version:
1 lines • 16.1 kB
Source Map (JSON)
{"version":3,"file":"caching-pubsub-DBB7kAgu.cjs","names":["PubSub","isLeaseProvider"],"sources":["../src/events/caching-pubsub.ts"],"sourcesContent":["import type { MastraServerCache } from '../cache/base';\nimport type { IMastraLogger } from '../logger';\nimport { isLeaseProvider, PubSub } from './pubsub';\nimport type { LeaseProvider } from './pubsub';\nimport type { Event, EventCallback, SubscribeOptions } from './types';\n\n/**\n * Options for CachingPubSub\n */\nexport interface CachingPubSubOptions {\n /**\n * Optional prefix for cache keys to namespace events.\n * Defaults to 'pubsub:'.\n */\n keyPrefix?: string;\n /**\n * Optional logger for structured logging.\n * Falls back to console.error if not provided.\n */\n logger?: IMastraLogger;\n}\n\n/**\n * A PubSub decorator that adds event caching and replay capabilities.\n *\n * Wraps any PubSub implementation and uses MastraServerCache to:\n * - Cache all published events per topic\n * - Enable replay of cached events for late subscribers\n *\n * This enables resumable streams - clients can disconnect and reconnect\n * without missing events.\n *\n * ## Batching\n *\n * `CachingPubSub` is transparent to `options.batch`: `subscribe()` forwards\n * the option to the inner PubSub, and `supportsNativeBatching` mirrors the\n * inner's value. Wrapping a non-native inner with `{ batch: {...} }` results\n * in unbatched delivery — use an inner that returns\n * `supportsNativeBatching === true` (e.g. `EventEmitterPubSub`) if you need\n * batched delivery.\n *\n * @example\n * ```typescript\n * import { EventEmitterPubSub, CachingPubSub } from '@mastra/core/events';\n * import { InMemoryServerCache } from '@mastra/core/cache';\n *\n * const cache = new InMemoryServerCache();\n * const pubsub = new CachingPubSub(new EventEmitterPubSub(), cache);\n *\n * // Subscribe with replay - receives cached events first, then live\n * await pubsub.subscribeWithReplay('my-topic', (event) => {\n * console.log(event);\n * });\n * ```\n */\nexport class CachingPubSub extends PubSub {\n private readonly keyPrefix: string;\n private readonly logger?: IMastraLogger;\n /** Maps original callbacks to their wrapped versions for proper unsubscribe */\n private callbackMap = new Map<EventCallback, EventCallback>();\n\n constructor(\n private readonly inner: PubSub,\n private readonly cache: MastraServerCache,\n options: CachingPubSubOptions = {},\n ) {\n super();\n this.keyPrefix = options.keyPrefix ?? 'pubsub:';\n this.logger = options.logger;\n }\n\n get supportsNativeBatching(): boolean {\n return this.inner.supportsNativeBatching;\n }\n\n /**\n * Log an error message using the configured logger or console.error.\n */\n private logError(message: string, error: unknown): void {\n if (this.logger) {\n this.logger.error(message, error);\n } else {\n console.error(message, error);\n }\n }\n\n /**\n * Stable key used to deduplicate an event across the cache-replay and\n * live-delivery paths.\n *\n * We cannot dedup on `event.id`: `CachingPubSub.publish` assigns the id and\n * caches the event with it, but inner PubSub implementations\n * (EventEmitterPubSub, UnixSocketPubSub, …) regenerate `id` inside their own\n * `publish`, so the cached copy and the live copy of the SAME publish carry\n * different ids. The sequential `index` is assigned here and is preserved by\n * every inner implementation, so it matches across both paths. Events without\n * an index are never cached (see `publish`), so they can't be replay/live\n * duplicated — falling back to `id` for them is safe.\n */\n private dedupKey(event: Event): string {\n return event.index !== undefined ? `i:${event.index}` : `id:${event.id}`;\n }\n\n /**\n * Get the cache key for a topic's event list\n */\n private getCacheKey(topic: string): string {\n return `${this.keyPrefix}${topic}`;\n }\n\n /**\n * Get the cache key for a topic's index counter\n */\n private getCounterKey(topic: string): string {\n return `${this.keyPrefix}${topic}:counter`;\n }\n\n /**\n * Publish an event to a topic.\n * The event is cached with a sequential index before being published to the inner PubSub.\n *\n * Uses atomic increment for index assignment to prevent race conditions\n * when multiple events are published concurrently.\n */\n async publish(\n topic: string,\n event: Omit<Event, 'id' | 'createdAt' | 'index'>,\n options?: { localOnly?: boolean },\n ): Promise<void> {\n const cacheKey = this.getCacheKey(topic);\n const counterKey = this.getCounterKey(topic);\n\n let index: number | undefined;\n let indexFailed = false;\n try {\n // Atomically get next index (increment returns value after incrementing, so subtract 1 for 0-based index)\n index = (await this.cache.increment(counterKey)) - 1;\n } catch (error) {\n this.logError(`[CachingPubSub] Failed to increment counter for ${topic}`, error);\n indexFailed = true;\n }\n\n // On counter failure leave `index` undefined rather than defaulting to 0:\n // downstream consumers that key off `index` (e.g. replay-from-offset)\n // would otherwise see colliding indices across failed publishes.\n const fullEvent: Event = {\n ...event,\n id: crypto.randomUUID(),\n createdAt: new Date(),\n ...(index !== undefined ? { index } : {}),\n };\n\n if (!indexFailed) {\n try {\n // Cache BEFORE live publish so late-joining observers never miss events\n await this.cache.listPush(cacheKey, fullEvent);\n } catch (error) {\n this.logError(`[CachingPubSub] Failed to cache event for ${topic}`, error);\n }\n }\n\n // Always publish to inner PubSub — cache failure must not block live delivery\n await this.inner.publish(topic, fullEvent, options);\n }\n\n /**\n * Subscribe to live events on a topic (no replay).\n */\n async subscribe(topic: string, cb: EventCallback, options?: SubscribeOptions): Promise<void> {\n await this.inner.subscribe(topic, cb, options);\n }\n\n /**\n * Subscribe to a topic with automatic replay of cached events.\n * Delegates to {@link subscribeFromOffset} with offset 0.\n */\n async subscribeWithReplay(topic: string, cb: EventCallback): Promise<void> {\n return this.subscribeFromOffset(topic, 0, cb);\n }\n\n /**\n * Subscribe to a topic with replay starting from a specific index.\n * More efficient than full replay when the client knows their last position.\n *\n * Order of operations:\n * 1. Subscribe to live events FIRST — buffer deliveries during bootstrap\n * 2. Fetch and deliver cached history in order\n * 3. Drain the buffer, skipping events already delivered via history\n * 4. Switch to passthrough with an index watermark for steady-state dedup\n *\n * @param topic - The topic to subscribe to\n * @param offset - Start replaying from this index (0-based)\n * @param cb - Callback invoked for each event\n */\n async subscribeFromOffset(topic: string, offset: number, cb: EventCallback): Promise<void> {\n // --- Phase 1: subscribe live, buffer everything during bootstrap ---\n let bootstrapping = true;\n const buffer: Array<{\n event: Event;\n ack?: Parameters<EventCallback>[1];\n nack?: Parameters<EventCallback>[2];\n }> = [];\n let lastDelivered = -1;\n\n const wrappedCb: EventCallback = (event, ack, nack) => {\n // Drop events strictly before the requested offset on the live path.\n if (typeof event.index === 'number' && event.index < offset) {\n return;\n }\n\n if (bootstrapping) {\n buffer.push({ event, ack, nack });\n return;\n }\n\n // Steady-state: skip events we already delivered via history or buffer drain.\n // Allow nack-redelivered messages through — they carry the same index but\n // deliveryAttempt > 1, and the consumer must see them to retry processing.\n const isRetry = typeof event.deliveryAttempt === 'number' && event.deliveryAttempt > 1;\n if (typeof event.index === 'number' && event.index <= lastDelivered && !isRetry) {\n return;\n }\n\n if (typeof event.index === 'number' && event.index > lastDelivered) {\n lastDelivered = event.index;\n }\n cb(event, ack, nack);\n };\n\n this.callbackMap.set(cb, wrappedCb);\n await this.inner.subscribe(topic, wrappedCb);\n\n try {\n // --- Phase 2: fetch and deliver cached history ---\n const seen = new Set<string>();\n const history = await this.getHistory(topic, offset);\n for (const event of history) {\n const key = this.dedupKey(event);\n seen.add(key);\n if (typeof event.index === 'number') {\n lastDelivered = event.index;\n }\n cb(event);\n }\n\n // --- Phase 3: drain buffer, suppressing duplicates history already covered ---\n for (const { event, ack, nack } of buffer) {\n const key = this.dedupKey(event);\n if (seen.has(key)) {\n continue;\n }\n seen.add(key);\n if (typeof event.index === 'number') {\n lastDelivered = event.index;\n }\n cb(event, ack, nack);\n }\n\n // --- Phase 4: flip to passthrough ---\n bootstrapping = false;\n buffer.length = 0;\n } catch (error) {\n // Rollback: unsubscribe wrappedCb so it doesn't strand in bootstrap mode\n this.callbackMap.delete(cb);\n await this.inner.unsubscribe(topic, wrappedCb).catch(() => {});\n throw error;\n }\n }\n\n /**\n * Unsubscribe from a topic.\n */\n async unsubscribe(topic: string, cb: EventCallback): Promise<void> {\n const wrappedCb = this.callbackMap.get(cb) ?? cb;\n this.callbackMap.delete(cb);\n await this.inner.unsubscribe(topic, wrappedCb);\n }\n\n /**\n * Get historical events for a topic from cache.\n */\n async getHistory(topic: string, offset: number = 0): Promise<Event[]> {\n const cacheKey = this.getCacheKey(topic);\n const events = await this.cache.listFromTo(cacheKey, offset);\n return events as Event[];\n }\n\n /**\n * Flush any pending operations on the inner PubSub.\n */\n async flush(): Promise<void> {\n await this.inner.flush();\n }\n\n /**\n * Expose the inner's {@link LeaseProvider} when it has one, otherwise\n * `undefined`. Leasing is a capability of the underlying backend\n * (e.g. Redis), not of the caching decorator itself — so rather than\n * unconditionally declaring lease methods (which would make\n * {@link isLeaseProvider} report `true` even when the inner can't\n * coordinate a lock), we surface the inner's capability directly. The\n * signals runtime unwraps this so wrapping with caching preserves real\n * distributed lease semantics without faking them.\n */\n getLeaseProvider(): LeaseProvider | undefined {\n return isLeaseProvider(this.inner) ? this.inner : undefined;\n }\n\n /**\n * Clear cached events for a specific topic (and the index counter), and\n * forward the clear to the inner transport.\n *\n * Call this when a stream completes to free memory. The forward matters for\n * persistent inner transports (e.g. Redis Streams): without it, wrapping a\n * pubsub in `CachingPubSub` silently turns `clearTopic` into a cache-only\n * no-op and the inner stream leaks forever.\n */\n override async clearTopic(topic: string): Promise<void> {\n const cacheKey = this.getCacheKey(topic);\n const counterKey = this.getCounterKey(topic);\n try {\n await Promise.all([this.cache.delete(cacheKey), this.cache.delete(counterKey), this.inner.clearTopic(topic)]);\n } catch (error) {\n // Honor the base-class contract: clearTopic is best-effort and callers\n // invoke it fire-and-forget, so a cache failure must not become an\n // unhandled rejection. A failed delete means retained state may leak\n // until the transport-level TTL backstop, so make it visible.\n this.logError(`[CachingPubSub] Failed to clear topic ${topic}`, error);\n }\n }\n\n /**\n * Get the inner PubSub instance.\n * Useful for accessing implementation-specific methods like close().\n */\n getInner(): PubSub {\n return this.inner;\n }\n}\n\n/**\n * Factory function to wrap a PubSub with caching capabilities.\n *\n * @example\n * ```typescript\n * import { withCaching, EventEmitterPubSub } from '@mastra/core/events';\n * import { InMemoryServerCache } from '@mastra/core/cache';\n *\n * const cache = new InMemoryServerCache();\n * const pubsub = withCaching(new EventEmitterPubSub(), cache);\n * ```\n */\nexport function withCaching(pubsub: PubSub, cache: MastraServerCache, options?: CachingPubSubOptions): CachingPubSub {\n return new CachingPubSub(pubsub, cache, options);\n}\n"],"mappings":"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"}