ReviewOS

also looking at this

pantry-pm/pantry

fix: hydrate durable registry snapshots before serving writes

#244
Merged chris wants to merge codex/wildloop-registry-rollout into main
11 files +202 -35
packages/registry/src/build-status-hydration.test.tsadded+58-0
Changes to packages/registry/src/build-status-hydration.test.ts
@@ -0,0 +1,58 @@
1import type { S3Client } from './storage/aws-client'
2import { expect, it } from 'bun:test'
3import { S3RequestError } from './storage/aws-client'
4import { BuildStatusStore } from './storage/build-status'
5
6for (const failedKey of ['build-status/status.json', 'build-status/coverage.json']) {
7 it(`preserves both snapshots when ${failedKey} cannot hydrate, then permits a healthy retry`, async () => {
8 const objects = new Map<string, Buffer>()
9 let unavailable = false
10 let writes = 0
11 const client = {
12 async getObjectBuffer(_bucket: string, key: string) {
13 if (unavailable && key === failedKey)
14 throw new S3RequestError('GET', 503, 'ServiceUnavailable')
15 const value = objects.get(key)
16 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
17 return value
18 },
19 async putObject(input: { key: string, body: string | Buffer }) {
20 writes++
21 objects.set(input.key, Buffer.from(input.body))
22 },
23 async list() {
24 return [{ Key: 'binaries/bun.sh/1.2.3/linux-arm64/bun.sh-1.2.3.tar.gz', LastModified: '2026-10-18T00:00:00Z' }]
25 },
26 } as unknown as S3Client
27 const original = new BuildStatusStore(client, 'registry')
28 await original.load()
29 await original.refreshCoverage(true)
30 original.record({ domain: 'bun.sh', version: '1.2.3', platform: 'linux-arm64', state: 'built' })
31 await original.flush()
32 const status = Buffer.from(objects.get('build-status/status.json')!)
33 const coverage = Buffer.from(objects.get('build-status/coverage.json')!)
34 const initialWrites = writes
35
36 unavailable = true
37 const restarted = new BuildStatusStore(client, 'registry')
38 await expect(restarted.load()).rejects.toThrow('503')
39 if (failedKey.endsWith('coverage.json')) {
40 restarted.record({ domain: 'git-scm.org', version: '2.53.0', platform: 'linux-arm64', state: 'built' })
41 await expect(restarted.flush()).rejects.toThrow()
42 }
43 else {
44 await expect(restarted.refreshCoverage(true)).rejects.toThrow()
45 }
46 expect(writes).toBe(initialWrites)
47 expect(objects.get('build-status/status.json')).toEqual(status)
48 expect(objects.get('build-status/coverage.json')).toEqual(coverage)
49
50 unavailable = false
51 await restarted.load()
52 restarted.record({ domain: 'curl.se', version: '8.22.0', platform: 'linux-arm64', state: 'built' })
53 await restarted.flush()
54 const recovered = JSON.parse(objects.get('build-status/status.json')!.toString())
55 expect(recovered.recent.map((event: { domain: string }) => event.domain)).toContain('bun.sh')
56 expect(recovered.recent.map((event: { domain: string }) => event.domain)).toContain('curl.se')
57 })
58}
packages/registry/src/build-status.test.tsmodified+5-4
Changes to packages/registry/src/build-status.test.ts
@@ -1,3 +1,4 @@
1import { S3RequestError } from './storage/aws-client'
12import { describe, expect, it } from 'bun:test'
23import { BuildStatusStore } from './storage/build-status'
34
@@ -7,7 +8,7 @@ function mockBucket() {
78 const client = {
89 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
910 const value = objects.get(key)
10 if (!value) throw new Error('S3 GET failed: 404 Not Found')
11 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
1112 return value
1213 },
1314 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -74,7 +75,7 @@ describe('BuildStatusStore partial coverage', () => {
7475 const client = {
7576 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
7677 const value = objects.get(key)
77 if (!value) throw new Error('S3 GET failed: 404 Not Found')
78 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
7879 return value
7980 },
8081 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -135,7 +136,7 @@ describe('BuildStatusStore partial coverage', () => {
135136 const client = {
136137 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
137138 const value = objects.get(key)
138 if (!value) throw new Error('S3 GET failed: 404 Not Found')
139 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
139140 return value
140141 },
141142 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -196,7 +197,7 @@ describe('BuildStatusStore retired platforms', () => {
196197 const client = {
197198 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
198199 const value = objects.get(key)
199 if (!value) throw new Error('S3 GET failed: 404 Not Found')
200 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
200201 return value
201202 },
202203 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-analytics.test.tsmodified+2-1
Changes to packages/registry/src/object-analytics.test.ts
@@ -8,6 +8,7 @@
88 * starts empty.
99 */
1010
11import { S3RequestError } from './storage/aws-client'
1112import { describe, expect, it } from 'bun:test'
1213import { ObjectAnalytics } from './storage/object-analytics'
1314
@@ -17,7 +18,7 @@ function mockBucket() {
1718 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
1819 const v = store.get(key)
1920 if (!v)
20 throw new Error('S3 GET failed: 404 Not Found')
21 throw new S3RequestError('GET', 404, 'NoSuchKey')
2122 return v
2223 },
2324 async putObject(o: { bucket: string, key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-metadata.test.tsmodified+2-1
Changes to packages/registry/src/object-metadata.test.ts
@@ -7,6 +7,7 @@
77 * used to hold), and that a missing snapshot starts empty rather than throwing.
88 */
99
10import { S3RequestError } from './storage/aws-client'
1011import type { CommitPublish, PackageMetadata } from './types'
1112import { describe, expect, it } from 'bun:test'
1213import { ObjectMetadataStorage } from './storage/object-metadata'
@@ -18,7 +19,7 @@ function mockBucket() {
1819 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
1920 const v = store.get(key)
2021 if (!v)
21 throw new Error('S3 GET failed: 404 Not Found')
22 throw new S3RequestError('GET', 404, 'NoSuchKey')
2223 return v
2324 },
2425 async putObject(o: { bucket: string, key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-package-storage.test.tsmodified+2-1
Changes to packages/registry/src/object-package-storage.test.ts
@@ -1,3 +1,4 @@
1import { S3RequestError } from './storage/aws-client'
12import { describe, expect, test } from 'bun:test'
23import { ObjectPhpStorage } from './php'
34import { ObjectZigStorage } from './zig'
@@ -14,7 +15,7 @@ class MockS3 {
1415 async getObjectBuffer(bucket: string, key: string): Promise<Buffer> {
1516 const v = this.store.get(`${bucket}/${key}`)
1617 if (!v)
17 throw new Error('404 Not Found')
18 throw new S3RequestError('GET', 404, 'NoSuchKey')
1819 return v
1920 }
2021
packages/registry/src/object-snapshot.test.tsadded+83-0
Changes to packages/registry/src/object-snapshot.test.ts
@@ -0,0 +1,83 @@
1import type { S3Client } from './storage/aws-client'
2import { describe, expect, it } from 'bun:test'
3import { S3RequestError } from './storage/aws-client'
4import { ObjectSnapshot } from './storage/object-snapshot'
5import { ObjectMetadataStorage } from './storage/object-metadata'
6import { ObjectAnalytics } from './storage/object-analytics'
7
8function bucket(body: string, failure?: Error) {
9 let reads = 0
10 let writes = 0
11 const client = {
12 async getObjectBuffer() {
13 reads++
14 if (failure) throw failure
15 return Buffer.from(body)
16 },
17 async putObject() { writes++ },
18 } as unknown as S3Client
19 return { client, reads: () => reads, writes: () => writes }
20}
21
22describe('snapshot hydration safety', () => {
23 for (const failure of [
24 new S3RequestError('GET', 503, 'ServiceUnavailable'),
25 new S3RequestError('GET', 403, 'AccessDenied'),
26 new S3RequestError('GET', 404, 'NoSuchBucket'),
27 new Error('transport unavailable'),
28 new Error('adapter failed: 404 Not Found'),
29 ]) {
30 it(`rejects ${failure.message} without overwriting remote state`, async () => {
31 const remote = bucket('{"existing":true}', failure)
32 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
33 await expect(snapshot.load()).rejects.toBe(failure)
34 await expect(snapshot.flush()).rejects.toThrow()
35 expect(remote.writes()).toBe(0)
36 })
37 }
38
39 it('cannot flush before the initial read completes', async () => {
40 const remote = bucket('{}')
41 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
42 await expect(snapshot.flush()).rejects.toThrow()
43 expect(remote.reads()).toBe(0)
44 expect(remote.writes()).toBe(0)
45 await snapshot.load()
46 await snapshot.flush()
47 expect(remote.writes()).toBe(1)
48 })
49
50 it('permits a first save only after confirmed object absence', async () => {
51 const remote = bucket('', new S3RequestError('GET', 404, 'NoSuchKey'))
52 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
53 expect(await snapshot.load()).toBeNull()
54 await snapshot.flush()
55 expect(remote.writes()).toBe(1)
56 })
57
58 it('rejects corrupt snapshots without replacing them', async () => {
59 for (const body of ['', '{broken']) {
60 const remote = bucket(body)
61 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
62 await expect(snapshot.load()).rejects.toThrow()
63 await expect(snapshot.flush()).rejects.toThrow()
64 expect(remote.writes()).toBe(0)
65 }
66 })
67
68 it('metadata readiness rejects an outage and flush cannot overwrite the index', async () => {
69 const remote = bucket('{}', new S3RequestError('GET', 503, 'ServiceUnavailable'))
70 const storage = new ObjectMetadataStorage(remote.client, 'b')
71 await expect(storage.ready()).rejects.toThrow('503')
72 await expect(storage.flush()).rejects.toThrow()
73 expect(remote.writes()).toBe(0)
74 })
75
76 it('analytics readiness rejects an outage and flush cannot overwrite counters', async () => {
77 const remote = bucket('{}', new S3RequestError('GET', 503, 'ServiceUnavailable'))
78 const storage = new ObjectAnalytics(remote.client, 'b')
79 await expect(storage.ready()).rejects.toThrow('503')
80 await expect(storage.flush()).rejects.toThrow()
81 expect(remote.writes()).toBe(0)
82 })
83})
packages/registry/src/server.tsmodified+10-1
Changes to packages/registry/src/server.ts
@@ -6846,7 +6846,16 @@ if (import.meta.main) {
68466846 // Ensure auth storage uses the same DynamoDB table as the registry
68476847 const authStorage = createAuthStorage(dynamoTable, awsRegion)
68486848
6849 const { start } = createServer(registry, port, analytics, undefined, undefined, undefined, authStorage)
6849 const zigStorage = createZigStorage()
6850 const phpStorage = createPhpStorage()
6851 // Hydrate durable state before accepting any request that can mutate it.
6852 // Only a confirmed missing object can initialize an empty registry.
6853 await Promise.all([registry.metadata, analytics, authStorage, zigStorage, phpStorage].map(async (store) => {
6854 if ('ready' in store && typeof store.ready === 'function')
6855 await store.ready()
6856 }))
6857
6858 const { start } = createServer(registry, port, analytics, zigStorage, undefined, phpStorage, authStorage)
68506859 start()
68516860
68526861 console.log('\nEnvironment:')
packages/registry/src/storage/build-status.tsmodified+15-2
Changes to packages/registry/src/storage/build-status.ts
@@ -179,6 +179,7 @@ export class BuildStatusStore {
179179 private unavailable = new Map<string, UnavailableVersion>()
180180 private snapshot: ObjectSnapshot
181181 private coverageSnapshot: ObjectSnapshot
182 private hydrated = false
182183
183184 // Derived coverage cache (domain -> version -> set of platforms) + freshness.
184185 private coverage = new Map<string, Map<string, Set<string>>>()
@@ -203,8 +204,18 @@ export class BuildStatusStore {
203204 private logs = new Map<string, BuildLogLine[]>()
204205
205206 constructor(private s3: S3Client, private bucket: string) {
206 this.snapshot = new ObjectSnapshot(s3, bucket, 'build-status/status.json', () => this.captureState())
207 this.coverageSnapshot = new ObjectSnapshot(s3, bucket, 'build-status/coverage.json', () => this.captureCoverage())
207 this.snapshot = new ObjectSnapshot(s3, bucket, 'build-status/status.json', () => {
208 this.assertHydrated()
209 return this.captureState()
210 })
211 this.coverageSnapshot = new ObjectSnapshot(s3, bucket, 'build-status/coverage.json', () => {
212 this.assertHydrated()
213 return this.captureCoverage()
214 })
215 }
216
217 private assertHydrated(): void {
218 if (!this.hydrated) throw new Error('Build status snapshots have not been applied')
208219 }
209220
210221 /**
@@ -312,6 +323,7 @@ export class BuildStatusStore {
312323 }
313324
314325 async load(): Promise<void> {
326 this.hydrated = false
315327 const [state, coverage] = await Promise.all([
316328 this.snapshot.load() as Promise<PersistedState | null>,
317329 this.coverageSnapshot.load() as Promise<PersistedCoverage | null>,
@@ -344,6 +356,7 @@ export class BuildStatusStore {
344356 if (event.state === 'built' && event.version)
345357 this.mergeCoverage(event.domain, event.version, event.platform, new Date(event.ts).toISOString())
346358 }
359 this.hydrated = true
347360 }
348361
349362 private key(e: { domain: string, version: string, platform: string }): string {
packages/registry/src/storage/object-analytics.tsmodified+8-7
Changes to packages/registry/src/storage/object-analytics.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23import { InMemoryAnalytics } from '../analytics'
34
45/**
@@ -16,6 +17,7 @@ import { InMemoryAnalytics } from '../analytics'
1617 * are debounced (counters accumulate in memory between flushes).
1718 */
1819export class ObjectAnalytics extends InMemoryAnalytics {
20 private hydrated = false
1921 private s3: S3Client
2022 private bucket: string
2123 private key: string
@@ -39,16 +41,12 @@ export class ObjectAnalytics extends InMemoryAnalytics {
3941 private async load(): Promise<void> {
4042 try {
4143 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
42 if (!buf || buf.byteLength === 0)
43 return
4444 this.applyAnalyticsState(JSON.parse(buf.toString('utf-8')))
45 this.hydrated = true
4546 }
4647 catch (err) {
47 const msg = (err as Error).message || ''
48 // A missing object on first run is expected (404/403); start empty otherwise.
49 if (!/\b40[34]\b/.test(msg)) {
50 console.error(`ObjectAnalytics: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
51 }
48 if (!isS3ObjectNotFound(err)) throw err
49 this.hydrated = true
5250 }
5351 }
5452
@@ -66,6 +64,8 @@ export class ObjectAnalytics extends InMemoryAnalytics {
6664 }
6765
6866 private async save(): Promise<void> {
67 if (!this.hydrated)
68 throw new Error(`Snapshot not hydrated: ${this.key}`)
6969 while (this.savePromise) await this.savePromise
7070 this.savePromise = this.doSave()
7171 try {
@@ -88,6 +88,7 @@ export class ObjectAnalytics extends InMemoryAnalytics {
8888
8989 /** Flush any pending save immediately (e.g. before shutdown). */
9090 async flush(): Promise<void> {
91 await this.loaded
9192 if (this.saveTimeout) {
9293 clearTimeout(this.saveTimeout)
9394 this.saveTimeout = null
packages/registry/src/storage/object-metadata.tsmodified+8-9
Changes to packages/registry/src/storage/object-metadata.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23import { InMemoryMetadataStorage } from './metadata'
34
45/**
@@ -15,6 +16,7 @@ import { InMemoryMetadataStorage } from './metadata'
1516 * snapshot from the bucket.
1617 */
1718export class ObjectMetadataStorage extends InMemoryMetadataStorage {
19 private hydrated = false
1820 private s3: S3Client
1921 private bucket: string
2022 private key: string
@@ -38,19 +40,13 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
3840 private async load(): Promise<void> {
3941 try {
4042 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
41 if (!buf || buf.byteLength === 0)
42 return
4343 const data = JSON.parse(buf.toString('utf-8'))
4444 this.applyState(data)
45 this.hydrated = true
4546 }
4647 catch (err) {
47 // A missing object on first run is expected (404/403). Anything else we
48 // log loudly but still start empty rather than block the server boot —
49 // and we do NOT overwrite the remote object until the next real save.
50 const msg = (err as Error).message || ''
51 if (!/\b40[34]\b/.test(msg)) {
52 console.error(`ObjectMetadataStorage: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
53 }
48 if (!isS3ObjectNotFound(err)) throw err
49 this.hydrated = true
5450 }
5551 }
5652
@@ -65,6 +61,8 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
6561 }
6662
6763 private async save(): Promise<void> {
64 if (!this.hydrated)
65 throw new Error(`Snapshot not hydrated: ${this.key}`)
6866 // Serialize saves so a slow upload can't be overtaken by a newer one.
6967 while (this.savePromise) await this.savePromise
7068 this.savePromise = this._doSave()
@@ -88,6 +86,7 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
8886
8987 /** Flush any pending save immediately (e.g. before shutdown). */
9088 async flush(): Promise<void> {
89 await this.loaded
9190 if (this.saveTimeout) {
9291 clearTimeout(this.saveTimeout)
9392 this.saveTimeout = null
packages/registry/src/storage/object-snapshot.tsmodified+9-9
Changes to packages/registry/src/storage/object-snapshot.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23
34/**
45 * Reusable debounced JSON-snapshot persistence to an S3-compatible bucket.
@@ -13,6 +14,7 @@ import type { S3Client } from './aws-client'
1314 * the bucket under their own keys by the owning store.
1415 */
1516export class ObjectSnapshot {
17 private hydrated = false
1618 private saveTimeout: ReturnType<typeof setTimeout> | null = null
1719 private savePromise: Promise<void> | null = null
1820
@@ -27,17 +29,13 @@ export class ObjectSnapshot {
2729 async load(): Promise<unknown | null> {
2830 try {
2931 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
30 if (!buf || buf.byteLength === 0)
31 return null
32 return JSON.parse(buf.toString('utf-8'))
32 const state = JSON.parse(buf.toString('utf-8'))
33 this.hydrated = true
34 return state
3335 }
3436 catch (err) {
35 // A missing object on first run is expected (404/403). Anything else we
36 // surface but still start empty rather than block boot — and we do NOT
37 // overwrite the remote object until the next real save.
38 const msg = (err as Error).message || ''
39 if (!/\b40[34]\b/.test(msg))
40 console.error(`ObjectSnapshot: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
37 if (!isS3ObjectNotFound(err)) throw err
38 this.hydrated = true
4139 return null
4240 }
4341 }
@@ -53,6 +51,8 @@ export class ObjectSnapshot {
5351 }
5452
5553 private async save(): Promise<void> {
54 if (!this.hydrated)
55 throw new Error(`Snapshot not hydrated: ${this.key}`)
5656 // Serialize saves so a slow upload can't be overtaken by a newer one.
5757 while (this.savePromise) await this.savePromise
5858 this.savePromise = this.s3.putObject({