Skip to content

Commit 1265e80

Browse files
committed
fix(engine): serialize blob GC unlinks with publish and persist resources after the blob row
1 parent d383dd2 commit 1265e80

9 files changed

Lines changed: 188 additions & 69 deletions

File tree

‎packages/host/engine/src/__tests__/attachment-gc.test.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
22
import { mkdtemp, rm, stat } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
5-
import type { AttachmentId, BlobId, UploadLease } from '@linkcode/schema';
5+
import type { BlobId, UploadLease } from '@linkcode/schema';
66
import {
77
AttachmentIdSchema,
88
ConversationOperationSchema,
@@ -183,6 +183,27 @@ describe('AttachmentGc', () => {
183183
await expect(stat(join(f.blobs.pathOf(kept), '..', '..', '..', 'tmp'))).rejects.toMatchObject({
184184
code: 'ENOENT',
185185
});
186-
expect(f.store.getAttachment('att-kept' as AttachmentId)).resolves.toBeDefined();
186+
await expect(
187+
f.store.getAttachment(AttachmentIdSchema.parse('att-kept')),
188+
).resolves.toBeDefined();
189+
});
190+
191+
it('does not unlink a blob that is re-committed after the reaper transaction', async () => {
192+
const f = await fixture();
193+
const blobId = await f.commit('att-old', 'shared bytes');
194+
f.clock.now += ATTACHMENT_GC_GRACE_MS + 1;
195+
196+
const originalSweep = f.store.sweep.bind(f.store);
197+
f.store.sweep = async (window) => {
198+
const doomed = await originalSweep(window);
199+
await f.commit('att-new', 'shared bytes');
200+
return doomed;
201+
};
202+
203+
expect(await f.gc.sweep()).toEqual({ removedBlobs: [] });
204+
expect(await f.blobs.stat(blobId)).toBeDefined();
205+
expect(await f.store.getAttachment(AttachmentIdSchema.parse('att-new'))).toMatchObject({
206+
blobId,
207+
});
187208
});
188209
});

‎packages/host/engine/src/__tests__/blob-store.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,13 @@ describe('mime sniff', () => {
110110
expect(sniffImageMimeType(new Uint8Array(0))).toBeUndefined();
111111
});
112112

113-
it('holds image declarations to their bytes and trusts the rest', () => {
113+
it('holds sniffable image declarations to their bytes and trusts the rest', () => {
114114
expect(declaredMimeTypeMatches('image/png', png)).toBe(true);
115115
expect(declaredMimeTypeMatches('image/jpeg', png)).toBe(false);
116-
expect(declaredMimeTypeMatches('image/svg+xml', Buffer.from('<svg/>'))).toBe(false);
116+
expect(declaredMimeTypeMatches('image/jpeg', Buffer.from('<svg/>'))).toBe(false);
117+
expect(declaredMimeTypeMatches('image/svg+xml', Buffer.from('<svg/>'))).toBe(true);
118+
expect(declaredMimeTypeMatches('image/svg+xml', png)).toBe(false);
119+
expect(declaredMimeTypeMatches('image/heic', Buffer.from('ftypheic'))).toBe(true);
117120
expect(declaredMimeTypeMatches('application/pdf', Buffer.from('%PDF-1.7'))).toBe(true);
118121
expect(declaredMimeTypeMatches('text/plain', png)).toBe(true);
119122
});

‎packages/host/engine/src/__tests__/engine-resources.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ describe('engine session resources', () => {
140140
await h.inject({ kind: 'resource.list', clientReqId: 'list-after-mismatch', sessionId });
141141
expect(listedResources(h.sent, 'list-after-mismatch')).toEqual([]);
142142

143+
await h.inject({
144+
kind: 'resource.source.upload',
145+
clientReqId: 'upload-svg',
146+
sessionId,
147+
name: 'icon.svg',
148+
mimeType: 'image/svg+xml',
149+
data: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64'),
150+
});
151+
await vi.waitFor(() => {
152+
expect(h.sent).toContainEqual(
153+
expect.objectContaining({ kind: 'resource.uploaded', replyTo: 'upload-svg' }),
154+
);
155+
});
156+
await h.inject({ kind: 'resource.list', clientReqId: 'list-svg', sessionId });
157+
expect(listedResources(h.sent, 'list-svg')).toEqual([
158+
expect.objectContaining({
159+
name: 'icon.svg',
160+
status: 'ready',
161+
mimeType: 'image/svg+xml',
162+
}),
163+
]);
164+
await h.inject({
165+
kind: 'resource.remove',
166+
clientReqId: 'remove-svg',
167+
resourceId: listedResources(h.sent, 'list-svg')[0].resourceId,
168+
});
169+
await vi.waitFor(() => {
170+
expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'remove-svg' });
171+
});
172+
143173
await h.inject({
144174
kind: 'resource.source.upload',
145175
clientReqId: 'upload-for-delete',

‎packages/host/engine/src/attachment/blob-store.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,16 @@ export class FsBlobStore implements BlobStore {
149149
await rename(stagingPath, dest);
150150
} catch (error) {
151151
if ((await this.stat(blobId)) === undefined) throw error;
152+
// Dest existing is not proof of identical bytes: Windows rename does not replace a
153+
// read-only dest, so a truncated or bitrot file would otherwise count as success.
154+
const existing = await sha256OfFile(dest);
155+
const expected = blobId.slice(BLOB_ID_PREFIX.length);
152156
await rm(stagingPath, { force: true });
157+
if (existing !== expected) {
158+
throw new BlobIntegrityError('Existing blob bytes do not match the declared SHA-256', {
159+
cause: error,
160+
});
161+
}
153162
}
154163
}
155164
}

‎packages/host/engine/src/attachment/gc.ts‎

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { BlobId } from '@linkcode/schema';
22
import { Effect, Schedule } from 'effect';
33
import type { AttachmentStore } from './attachment-store';
44
import type { BlobStore } from './blob-store';
5+
import { AttachmentIoMutex } from './io-mutex';
56

67
/** A draft's upload outlives any composer session, not a forgotten one. */
78
export const UPLOAD_LEASE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -19,41 +20,53 @@ export class AttachmentGc {
1920
private readonly store: AttachmentStore,
2021
private readonly blobs: BlobStore,
2122
private readonly clock: () => number = Date.now,
23+
private readonly io: AttachmentIoMutex = new AttachmentIoMutex(),
2224
) {}
2325

2426
/** A failed unlink leaves an orphan file the next boot sweep removes. */
2527
async sweep(): Promise<AttachmentGcReport> {
28+
return this.io.run(() => this.sweepBody());
29+
}
30+
31+
private async sweepBody(): Promise<AttachmentGcReport> {
2632
const now = this.clock();
2733
const removedBlobs = await this.store.sweep({
2834
now,
2935
graceBefore: now - ATTACHMENT_GC_GRACE_MS,
3036
});
37+
const unlinked: BlobId[] = [];
3138
for (let i = 0, len = removedBlobs.length; i < len; i++) {
39+
const blobId = removedBlobs[i];
3240
// eslint-disable-next-line no-await-in-loop -- sequential unlinks of a short list
33-
await this.blobs.delete(removedBlobs[i]);
41+
if (await this.store.getBlob(blobId)) continue;
42+
// eslint-disable-next-line no-await-in-loop -- same
43+
await this.blobs.delete(blobId);
44+
unlinked.push(blobId);
3445
}
35-
return { removedBlobs };
46+
return { removedBlobs: unlinked };
3647
}
3748

3849
/**
3950
* Run before requests are accepted: no upload survives a restart, so staging goes wholesale;
4051
* then a sweep; then bytes with no blob row (a crash between publish and the row insert).
41-
* Safe only while no commit can be publishing — that is what the boot ordering guarantees.
52+
* File unlinks share the publish mutex: they are only safe while no commit can be publishing.
4253
*/
4354
async bootSweep(): Promise<AttachmentGcReport> {
44-
await this.blobs.purgeStaging();
45-
const report = await this.sweep();
46-
const onDisk = await this.blobs.list();
47-
const orphans: BlobId[] = [];
48-
for (let i = 0, len = onDisk.length; i < len; i++) {
49-
const blobId = onDisk[i];
50-
// eslint-disable-next-line no-await-in-loop -- one row lookup per file on disk
51-
if (await this.store.getBlob(blobId)) continue;
52-
// eslint-disable-next-line no-await-in-loop -- same
53-
await this.blobs.delete(blobId);
54-
orphans.push(blobId);
55-
}
56-
return { removedBlobs: [...report.removedBlobs, ...orphans] };
55+
return this.io.run(async () => {
56+
await this.blobs.purgeStaging();
57+
const report = await this.sweepBody();
58+
const onDisk = await this.blobs.list();
59+
const orphans: BlobId[] = [];
60+
for (let i = 0, len = onDisk.length; i < len; i++) {
61+
const blobId = onDisk[i];
62+
// eslint-disable-next-line no-await-in-loop -- one row lookup per file on disk
63+
if (await this.store.getBlob(blobId)) continue;
64+
// eslint-disable-next-line no-await-in-loop -- same
65+
await this.blobs.delete(blobId);
66+
orphans.push(blobId);
67+
}
68+
return { removedBlobs: [...report.removedBlobs, ...orphans] };
69+
});
5770
}
5871

5972
/** One sweep per interval until interrupted; a failed sweep is logged and the cadence goes on. */
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { noop } from 'foxts/noop';
2+
3+
/**
4+
* Serializes blob publish + row insert with GC unlinks. A doomed id can grow a new row between
5+
* the reaper transaction and the unlink; those two must not overlap.
6+
*/
7+
export class AttachmentIoMutex {
8+
private tail: Promise<void> = Promise.resolve();
9+
10+
run<T>(work: () => Promise<T>): Promise<T> {
11+
const previous = this.tail;
12+
let release: () => void = noop;
13+
this.tail = new Promise<void>((resolve) => {
14+
release = resolve;
15+
});
16+
return previous.catch(noop).then(work).finally(release);
17+
}
18+
}

‎packages/host/engine/src/attachment/mime-sniff.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,19 @@ export function sniffImageMimeType(head: Uint8Array): SupportedAttachmentImageMi
2323
return undefined;
2424
}
2525

26-
/** A declared `image/*` type must match its bytes — model APIs refuse the mismatch later and
27-
* less legibly. Other declarations have no reliable sniff and are trusted. */
26+
const SNIFFABLE_IMAGE_TYPES = new Set<string>([
27+
'image/jpeg',
28+
'image/png',
29+
'image/gif',
30+
'image/webp',
31+
]);
32+
33+
/** A declared sniffable `image/*` type must match its bytes — model APIs refuse the mismatch
34+
* later and less legibly. Other declarations (svg, heic, pdf, …) have no reliable sniff here
35+
* and are trusted. */
2836
export function declaredMimeTypeMatches(declared: string, head: Uint8Array): boolean {
2937
if (!declared.startsWith('image/')) return true;
30-
return sniffImageMimeType(head) === declared;
38+
const sniffed = sniffImageMimeType(head);
39+
if (sniffed !== undefined) return sniffed === declared;
40+
return !SNIFFABLE_IMAGE_TYPES.has(declared);
3141
}

‎packages/host/engine/src/engine.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ManagedAssetService } from './asset/service';
1919
import { InMemoryAttachmentStore } from './attachment/attachment-store';
2020
import { FsBlobStore } from './attachment/blob-store';
2121
import { AttachmentGc } from './attachment/gc';
22+
import { AttachmentIoMutex } from './attachment/io-mutex';
2223
import {
2324
InMemoryLoopStore,
2425
InMemoryScheduleStore,
@@ -128,7 +129,8 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* (
128129
? resourceStore.referencedAttachmentIds()
129130
: []),
130131
]);
131-
const attachmentGc = new AttachmentGc(attachmentStore, blobStore);
132+
const attachmentIo = new AttachmentIoMutex();
133+
const attachmentGc = new AttachmentGc(attachmentStore, blobStore, Date.now, attachmentIo);
132134
const resources = new ResourceService(
133135
transport,
134136
resourceStore,
@@ -137,6 +139,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* (
137139
fileHost,
138140
blobStore,
139141
attachmentStore,
142+
attachmentIo,
140143
);
141144
const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter);
142145
const translator = deps.translator;

‎packages/host/engine/src/resource/service.ts‎

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Effect } from 'effect';
1616
import { noop } from 'foxts/noop';
1717
import type { AttachmentStore } from '../attachment/attachment-store';
1818
import type { BlobStore } from '../attachment/blob-store';
19+
import { AttachmentIoMutex } from '../attachment/io-mutex';
1920
import { declaredMimeTypeMatches } from '../attachment/mime-sniff';
2021
import { OperationError, RequestError } from '../failure';
2122
import type { FileHostService } from '../preview/file-host-service';
@@ -67,6 +68,7 @@ export class ResourceService {
6768
private readonly fileHost: FileHostService,
6869
private readonly blobs: BlobStore,
6970
private readonly attachments: AttachmentStore,
71+
private readonly io: AttachmentIoMutex = new AttachmentIoMutex(),
7072
) {}
7173

7274
list(sessionId: SessionId): Effect.Effect<SessionResource[], OperationError> {
@@ -79,7 +81,7 @@ export class ResourceService {
7981
mimeType: string | undefined,
8082
data: string,
8183
): Effect.Effect<SessionResource, OperationError | RequestError> {
82-
const { attachments, blobs, records, transport } = this;
84+
const { attachments, blobs, io, records, transport } = this;
8385
return Effect.gen({ self: this }, function* () {
8486
if (!records.has(sessionId)) {
8587
return yield* new RequestError({ code: 'not_found', message: 'Session not found' });
@@ -102,59 +104,69 @@ export class ResourceService {
102104
const sha256 = createHash('sha256').update(bytes).digest('hex');
103105
const blobId = blobIdFromSha256(sha256);
104106
const now = Date.now();
105-
// The harness reads the blob's own path: immutable, shared, mode 0444.
106-
let resource: SessionResource = {
107-
resourceId,
108-
sessionId,
109-
direction: 'source',
110-
name,
111-
kind: classify(name, mimeType),
112-
status: 'processing',
113-
locator: { type: 'managed-file', path: blobs.pathOf(blobId) },
114-
attachmentId,
115-
mimeType,
116-
sizeBytes: bytes.byteLength,
117-
createdAt: now,
118-
updatedAt: now,
119-
};
120-
yield* this.run('save', () => this.store.save(resource));
121-
transport.send(createWireMessage({ kind: 'resource.changed', resource }));
107+
const kind = classify(name, mimeType);
108+
const locator = { type: 'managed-file' as const, path: blobs.pathOf(blobId) };
122109
const written = yield* Effect.tryPromise({
123110
async try() {
124-
const stage = await blobs.stage(resourceId);
125-
try {
126-
await stage.write(0, bytes);
127-
await stage.commit({ sha256, sizeBytes: bytes.byteLength });
128-
} catch (error) {
129-
await stage.abort().catch(noop);
130-
throw error;
131-
}
132-
await attachments.commitAttachment({
133-
blob: { blobId, sizeBytes: bytes.byteLength, createdAt: now },
134-
attachment: {
135-
attachmentId,
136-
kind: resource.kind,
137-
name,
138-
mimeType: mimeType ?? 'application/octet-stream',
139-
sizeBytes: bytes.byteLength,
140-
metadata: {},
141-
createdAt: now,
142-
},
111+
await io.run(async () => {
112+
const stage = await blobs.stage(resourceId);
113+
try {
114+
await stage.write(0, bytes);
115+
await stage.commit({ sha256, sizeBytes: bytes.byteLength });
116+
await attachments.commitAttachment({
117+
blob: { blobId, sizeBytes: bytes.byteLength, createdAt: now },
118+
attachment: {
119+
attachmentId,
120+
kind,
121+
name,
122+
mimeType: mimeType ?? 'application/octet-stream',
123+
sizeBytes: bytes.byteLength,
124+
metadata: {},
125+
createdAt: now,
126+
},
127+
});
128+
} catch (error) {
129+
await stage.abort().catch(noop);
130+
await blobs.delete(blobId);
131+
throw error;
132+
}
143133
});
144134
},
145135
catch: (cause) => cause,
146136
}).pipe(
147137
Effect.as(true),
148138
Effect.catch(() => Effect.succeed(false)),
149139
);
150-
resource = written
151-
? { ...resource, status: 'ready', updatedAt: Date.now() }
152-
: {
153-
...resource,
154-
status: 'failed',
155-
error: 'Failed to persist uploaded resource',
156-
updatedAt: Date.now(),
157-
};
140+
if (!written) {
141+
return {
142+
resourceId,
143+
sessionId,
144+
direction: 'source',
145+
name,
146+
kind,
147+
status: 'failed',
148+
locator,
149+
error: 'Failed to persist uploaded resource',
150+
mimeType,
151+
sizeBytes: bytes.byteLength,
152+
createdAt: now,
153+
updatedAt: Date.now(),
154+
};
155+
}
156+
const resource: SessionResource = {
157+
resourceId,
158+
sessionId,
159+
direction: 'source',
160+
name,
161+
kind,
162+
status: 'ready',
163+
locator,
164+
attachmentId,
165+
mimeType,
166+
sizeBytes: bytes.byteLength,
167+
createdAt: now,
168+
updatedAt: Date.now(),
169+
};
158170
yield* this.run('save', () => this.store.save(resource));
159171
transport.send(createWireMessage({ kind: 'resource.changed', resource }));
160172
return resource;

0 commit comments

Comments
 (0)