upstreamReadCache.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. // @vitest-environment node
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { CookieJar, UpstreamClient, type UpstreamResponse } from '../../src/server/upstream.js'
  4. import { readPolicy, UpstreamReadCache } from '../../src/server/upstreamReadCache.js'
  5. function value(n: number, status = 200): UpstreamResponse {
  6. return { status, headers: new Headers({ 'content-type': 'application/json' }), body: Buffer.from(JSON.stringify({ n })) }
  7. }
  8. function gate<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>(r => { resolve = r }); return { promise, resolve } }
  9. const policy = { tags: ['sessions', 'session:one'], freshMs: 1000, staleMs: 0 }
  10. describe('bounded upstream read cache', () => {
  11. it('coalesces cold reads, copies responses and expires fresh entries', async () => {
  12. let now = 0
  13. const cache = new UpstreamReadCache(() => now), load = vi.fn(async () => value(1))
  14. const [a, b] = await Promise.all([cache.read('one', policy, load), cache.read('one', policy, load)])
  15. expect(load).toHaveBeenCalledTimes(1)
  16. a.body.fill(0); a.headers.set('private', 'changed')
  17. expect(b.body.toString()).toBe('{"n":1}')
  18. expect((await cache.read('one', policy, load)).headers.has('private')).toBe(false)
  19. now = 1001
  20. await cache.read('one', policy, load)
  21. expect(load).toHaveBeenCalledTimes(2)
  22. expect(cache.stats()).toMatchObject({ hits: 1, coalesced: 1 })
  23. })
  24. it('refreshes stale metadata once in the background, without extending failed freshness', async () => {
  25. let now = 0
  26. const cache = new UpstreamReadCache(() => now), p = { ...policy, staleMs: 1000 }
  27. await cache.read('one', p, async () => value(1))
  28. now = 1001
  29. const pending = gate<UpstreamResponse>(), load = vi.fn(() => pending.promise)
  30. expect((await cache.read('one', p, load)).body.toString()).toContain('1')
  31. await cache.read('one', p, load)
  32. expect(load).toHaveBeenCalledTimes(1)
  33. pending.resolve(value(2))
  34. await vi.waitFor(() => expect(cache.stats().inFlight).toBe(0))
  35. expect((await cache.read('one', p, load)).body.toString()).toContain('2')
  36. now = 4000
  37. await expect(cache.read('one', p, async () => { throw new Error('offline') })).rejects.toThrow('offline')
  38. })
  39. it('does not refill from a read that overlapped invalidation', async () => {
  40. const cache = new UpstreamReadCache(), pending = gate<UpstreamResponse>()
  41. const old = cache.read('one', policy, () => pending.promise)
  42. cache.invalidate(['session:one'])
  43. await cache.read('one', policy, async () => value(2))
  44. pending.resolve(value(1)); await old
  45. const fetched = await cache.read('one', policy, async () => value(3))
  46. expect(fetched.body.toString()).toContain('2')
  47. })
  48. it('enforces both byte and entry limits and never caches errors or no-store', async () => {
  49. const cache = new UpstreamReadCache(Date.now, 2000, 2, 1024)
  50. for (let i = 0; i < 10; i++) await cache.read(String(i), policy, async () => value(i))
  51. expect(cache.stats().entries).toBeLessThanOrEqual(2)
  52. expect(cache.stats().bytes).toBeLessThanOrEqual(2000)
  53. const error = vi.fn(async () => value(4, 401))
  54. await cache.read('error', policy, error); await cache.read('error', policy, error)
  55. expect(error).toHaveBeenCalledTimes(2)
  56. const noStore = vi.fn(async () => { const v = value(4); v.headers.set('cache-control', 'no-store'); return v })
  57. await cache.read('private', policy, noStore); await cache.read('private', policy, noStore)
  58. expect(noStore).toHaveBeenCalledTimes(2)
  59. })
  60. it('does not cache critical or cursor-dependent endpoints', () => {
  61. for (const path of ['/api/status', '/api/auth/me', '/api/auth/providers', '/api/config', '/api/sessions/search', '/api/session-unread', '/api/plugins/yaoyao/v1/capabilities']) {
  62. expect(readPolicy(path)).toBeUndefined()
  63. }
  64. expect(readPolicy('/api/plugins/yaoyao/v1/rooms/room/messages', new URLSearchParams('afterSeq=5'))).toBeUndefined()
  65. })
  66. })
  67. describe('scoped upstream read-through cache', () => {
  68. function fixture() {
  69. const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json({ value: fetch.mock.calls.length }))
  70. const client = new UpstreamClient(new URL('http://localhost:9119'), fetch)
  71. const jar = new CookieJar('service=one')
  72. const read = (scope = 'alice', profile = 'a', options = {}) => client.withReadScope(scope, false,
  73. () => client.request('/api/sessions/same/messages', jar, { search: new URLSearchParams({ profile }), ...options }))
  74. return { client, jar, fetch, read }
  75. }
  76. it('isolates user/profile/cookie/headers while merging identical reads', async () => {
  77. const f = fixture()
  78. await Promise.all([f.read(), f.read(), f.read()]); expect(f.fetch).toHaveBeenCalledTimes(1)
  79. await f.read('bob'); await f.read('alice', 'b')
  80. expect(f.fetch).toHaveBeenCalledTimes(3)
  81. f.jar.replace(new CookieJar('service=two')); await f.read()
  82. expect(f.fetch).toHaveBeenCalledTimes(4)
  83. await f.read('alice', 'a', { headers: { 'x-custom-scope': 'other' } })
  84. expect(f.fetch).toHaveBeenCalledTimes(5)
  85. })
  86. it('bypasses background, explicitly fresh and range reads', async () => {
  87. const f = fixture()
  88. await f.read(); await f.read()
  89. expect(f.fetch).toHaveBeenCalledTimes(1)
  90. await f.client.request('/api/sessions/same/messages', f.jar)
  91. expect(f.fetch).toHaveBeenCalledTimes(2)
  92. await f.client.request('/api/sessions/same/messages', f.jar)
  93. expect(f.fetch).toHaveBeenCalledTimes(3)
  94. await f.read('alice', 'a', { cache: 'reload' })
  95. expect(f.fetch).toHaveBeenCalledTimes(4)
  96. expect(new Headers({ range: 'bytes=0-4' }).has('range')).toBe(true)
  97. await f.read('alice', 'a', { headers: { range: 'bytes=0-4' } })
  98. expect(f.fetch).toHaveBeenCalledTimes(5)
  99. })
  100. it('invalidates reads on writes and broker events without invalidating unrelated sessions', async () => {
  101. const f = fixture()
  102. await f.read()
  103. await f.client.withReadScope('alice', false, () => f.client.request('/api/sessions/other/messages', f.jar))
  104. f.client.observeRealtime({ kind: 'event', name: 'message.delta', sessionId: 'same' })
  105. await f.read()
  106. await f.client.withReadScope('alice', false, () => f.client.request('/api/sessions/other/messages', f.jar))
  107. expect(f.fetch).toHaveBeenCalledTimes(3)
  108. await f.client.request('/api/sessions/same', f.jar, { method: 'PATCH', body: { title: 'new' } })
  109. await f.read()
  110. expect(f.fetch).toHaveBeenCalledTimes(5)
  111. })
  112. it('invalidates earlier group snapshots after a fresh journal anchor', async () => {
  113. const f = fixture()
  114. const rooms = () => f.client.withReadScope('alice', false, () => f.client.request('/api/plugins/yaoyao/v1/rooms', f.jar))
  115. await rooms(); await rooms()
  116. expect(f.fetch).toHaveBeenCalledTimes(1)
  117. await f.client.request('/api/plugins/yaoyao/v1/capabilities', f.jar)
  118. await rooms()
  119. expect(f.fetch).toHaveBeenCalledTimes(3)
  120. })
  121. })