data-source-cache.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import type {
  2. ContextLoadContext,
  3. DataSource,
  4. DataSourceLoadAdapter,
  5. } from "@/lib/novel/context-data-source"
  6. import { getDataSourceKinds } from "./source-paths"
  7. import { sha256Text } from "./fingerprint"
  8. import {
  9. CONTEXT_CACHE_SCHEMA_VERSION,
  10. type CachedArtifact,
  11. type ContextCacheScope,
  12. type ContextCacheItemTrace,
  13. type ContextSourceKind,
  14. type ContextSourceTraceStatus,
  15. type DependencyStamp,
  16. } from "./types"
  17. interface DataSourceCacheRegistry {
  18. refresh(): Promise<unknown>
  19. getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
  20. getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
  21. }
  22. interface DataSourceCacheStorage {
  23. readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
  24. writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
  25. }
  26. export interface DataSourceCacheAdapterOptions {
  27. registry: DataSourceCacheRegistry
  28. storage: DataSourceCacheStorage
  29. forceRefresh?: boolean
  30. }
  31. export interface DataSourceCacheStats {
  32. cacheHits: number
  33. reloaded: number
  34. empty: number
  35. fallbackUsed: number
  36. readFailed: number
  37. writeFailed: number
  38. }
  39. const STATIC_SOURCES = new Set([
  40. "canonRules",
  41. "writingStyle",
  42. "soulDoc",
  43. "storyFrameworkBinding",
  44. "relatedSettings",
  45. ])
  46. const CHAPTER_SCOPED_SOURCES = new Set([
  47. "outline",
  48. "chapterOutline",
  49. "volumeContext",
  50. "snapshots",
  51. "recentChapterContents",
  52. "fallbackRecentSummaries",
  53. "fallbackPreviousEnding",
  54. "fallbackCharacterStates",
  55. "fallbackForeshadowingStates",
  56. "fallbackTimeline",
  57. "revisionFeedback",
  58. "cognitionText",
  59. "retrieval",
  60. ])
  61. // Bump only the affected data source when its extraction semantics change.
  62. // This prevents a previously cached wrong-chapter outline from surviving the fix.
  63. const SOURCE_CACHE_VERSIONS: Partial<Record<string, number>> = {
  64. outline: 2,
  65. chapterOutline: 3,
  66. volumeContext: 2,
  67. sectionBriefing: 2,
  68. }
  69. function canonicalize(value: unknown): unknown {
  70. if (Array.isArray(value)) return value.map(canonicalize)
  71. if (!value || typeof value !== "object") return value
  72. return Object.fromEntries(
  73. Object.entries(value as Record<string, unknown>)
  74. .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
  75. .map(([key, child]) => [key, canonicalize(child)]),
  76. )
  77. }
  78. async function sourceRequestKey(sourceName: string, context: ContextLoadContext): Promise<string> {
  79. const scope = STATIC_SOURCES.has(sourceName)
  80. ? {}
  81. : CHAPTER_SCOPED_SOURCES.has(sourceName)
  82. ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
  83. : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
  84. const version = SOURCE_CACHE_VERSIONS[sourceName]
  85. const versionSuffix = version ? `:v${version}` : ""
  86. return `data-source:${sourceName}${versionSuffix}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
  87. }
  88. function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {
  89. return cached.fingerprint === current.fingerprint
  90. }
  91. function cacheScopeFor(sourceName: string): ContextCacheScope {
  92. if (STATIC_SOURCES.has(sourceName)) return "static"
  93. if (CHAPTER_SCOPED_SOURCES.has(sourceName)) return "chapter"
  94. return "task"
  95. }
  96. export function hasCacheableValue(value: unknown): boolean {
  97. if (typeof value === "string") return value.trim().length > 0
  98. if (Array.isArray(value)) return value.length > 0
  99. if (value && typeof value === "object") return Object.keys(value).length > 0
  100. return value !== null && value !== undefined
  101. }
  102. export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
  103. private readonly pending = new Map<string, Promise<unknown>>()
  104. private readonly stats: DataSourceCacheStats = {
  105. cacheHits: 0,
  106. reloaded: 0,
  107. empty: 0,
  108. fallbackUsed: 0,
  109. readFailed: 0,
  110. writeFailed: 0,
  111. }
  112. private readonly traceItems: ContextCacheItemTrace[] = []
  113. constructor(private readonly options: DataSourceCacheAdapterOptions) {}
  114. async load<T>(
  115. source: DataSource<T>,
  116. context: ContextLoadContext,
  117. directLoad: () => Promise<T>,
  118. ): Promise<T> {
  119. await this.options.registry.refresh()
  120. const kinds = getDataSourceKinds(source.name)
  121. const dependencyStamp = await this.options.registry.getDependencyStamp(kinds)
  122. const dependencyPaths = this.options.registry.getDependencyPreview(kinds, 20)
  123. const key = await sourceRequestKey(source.name, context)
  124. const pending = this.pending.get(key)
  125. if (pending) return pending as Promise<T>
  126. const operation = this.loadInternal(
  127. key,
  128. source.name,
  129. dependencyStamp,
  130. dependencyPaths,
  131. directLoad,
  132. )
  133. .finally(() => this.pending.delete(key))
  134. this.pending.set(key, operation)
  135. return operation
  136. }
  137. /**
  138. * Registry calls this when source.load throws. If fallback later succeeds,
  139. * recordFallbackUsed replaces the primary list status with fallback_used while
  140. * keeping both counters.
  141. */
  142. recordReadFailed(sourceName: string, dependencyStamp?: DependencyStamp): void {
  143. this.upsertSyntheticTrace(sourceName, "read_failed", dependencyStamp)
  144. this.stats.readFailed += 1
  145. }
  146. recordFallbackUsed(sourceName: string, dependencyStamp?: DependencyStamp): void {
  147. this.upsertSyntheticTrace(sourceName, "fallback_used", dependencyStamp)
  148. this.stats.fallbackUsed += 1
  149. }
  150. getStats(): DataSourceCacheStats {
  151. return { ...this.stats }
  152. }
  153. getTraceItems(): ContextCacheItemTrace[] {
  154. return this.traceItems.map((item) => ({
  155. ...item,
  156. dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
  157. dependencyPaths: [...item.dependencyPaths],
  158. }))
  159. }
  160. private upsertTrace(item: ContextCacheItemTrace): void {
  161. const existingIndex = this.traceItems.findIndex((entry) => entry.key === item.key)
  162. if (existingIndex >= 0) {
  163. this.traceItems[existingIndex] = item
  164. return
  165. }
  166. this.traceItems.push(item)
  167. }
  168. private upsertSyntheticTrace(
  169. sourceName: string,
  170. status: ContextSourceTraceStatus,
  171. dependencyStamp?: DependencyStamp,
  172. ): void {
  173. const kinds = getDataSourceKinds(sourceName)
  174. const stamp = dependencyStamp ?? { fingerprint: "", sourceCount: 0, kinds }
  175. const paths = this.options.registry.getDependencyPreview(kinds, 20)
  176. this.upsertTrace({
  177. key: `data-source:${sourceName}:outcome`,
  178. sourceName,
  179. status,
  180. dependencyStamp: stamp,
  181. dependencyPaths: paths,
  182. dependencyPathsTruncated: stamp.sourceCount > paths.length,
  183. })
  184. }
  185. private async loadInternal<T>(
  186. key: string,
  187. sourceName: string,
  188. dependencyStamp: DependencyStamp,
  189. dependencyPaths: string[],
  190. directLoad: () => Promise<T>,
  191. ): Promise<T> {
  192. const makeTrace = (status: ContextSourceTraceStatus): ContextCacheItemTrace => ({
  193. key,
  194. sourceName,
  195. status,
  196. dependencyStamp,
  197. dependencyPaths,
  198. dependencyPathsTruncated: dependencyStamp.sourceCount > dependencyPaths.length,
  199. })
  200. if (!this.options.forceRefresh) {
  201. try {
  202. const cached = await this.options.storage.readArtifact<T>(key)
  203. if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
  204. this.stats.cacheHits += 1
  205. this.upsertTrace(makeTrace("cache_hit"))
  206. return cached.value
  207. }
  208. } catch {
  209. // Corrupted/missing cache artifact: rebuild from source without counting as source failure.
  210. }
  211. }
  212. const value = await directLoad()
  213. if (!hasCacheableValue(value)) {
  214. this.stats.empty += 1
  215. this.upsertTrace(makeTrace("empty"))
  216. return value
  217. }
  218. this.stats.reloaded += 1
  219. try {
  220. await this.options.storage.writeArtifact(key, {
  221. schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
  222. key,
  223. sourceName,
  224. scope: cacheScopeFor(sourceName),
  225. value,
  226. dependencyStamp,
  227. createdAt: Date.now(),
  228. })
  229. this.upsertTrace(makeTrace("reloaded"))
  230. } catch {
  231. this.stats.writeFailed += 1
  232. // One primary list item: write_failed (reload still counted in reloaded).
  233. this.upsertTrace(makeTrace("write_failed"))
  234. }
  235. return value
  236. }
  237. }