From bd7e70edfd68ba256c79ee3d1da842d03bcefd9b Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sat, 10 Oct 2026 21:21:59 -0400 Subject: [PATCH] fix(workers): tear down a deleted source's worker after its FK nulls root_dir_id --- .../workers/WorkerDriver.cloudflare.test.ts | 31 +++++++++++++++++++ src/backend/drivers/workers/WorkerDriver.ts | 17 +++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts index 329cf7748..5502a0549 100644 --- a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts +++ b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts @@ -719,6 +719,37 @@ describe('WorkerDriver hot reload', () => { expect(path).toContain(user.username); }); + it('tears the worker down when the delete nulls its root_dir_id', async () => { + // MySQL and Postgres null `subdomains.root_dir_id` when its fsentry row + // is deleted (FK ON DELETE SET NULL). SQLite's schema has no such FK, + // so a trigger stands in for it. + await server.clients.db.write( + 'CREATE TRIGGER test_subdomain_root_set_null AFTER DELETE ON fsentries ' + + 'BEGIN UPDATE subdomains SET root_dir_id = NULL WHERE root_dir_id = OLD.id; END', + ); + try { + const { user, actor, path, name } = await deployWorker(); + fetchSpy.mockClear(); + const entry = await server.stores.fsEntry.getEntryByPath(path); + + await inCtx(actor, () => + server.services.fs.remove(user.id, { entry: entry! }), + ); + + await waitFor( + () => deleteCalls().length > 0, + 'edge delete after source delete', + ); + expect(deleteCalls().map((call) => call[0])).toContain( + `${SCRIPTS_BASE}/${name}/`, + ); + } finally { + await server.clients.db.write( + 'DROP TRIGGER IF EXISTS test_subdomain_root_set_null', + ); + } + }); + it('tears the worker down when its source file is moved to Trash', async () => { const { user, actor, entry, name } = await deployWorker(); fetchSpy.mockClear(); diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index b4846e7c1..06ed32342 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -1207,7 +1207,9 @@ export class WorkerDriver extends PuterDriver { const entry = this.#extractFsEntryFromEvent(data); if (!entry || entry.isDir) return; - const matched = await this.#listWorkerRowsForEntry(entry); + const matched = await this.#listWorkerRowsForEntry(entry, { + deleted: true, + }); for (const row of matched) { await this.#deleteWorkerForSourceRow(row, entry.userId); } @@ -1259,9 +1261,13 @@ export class WorkerDriver extends PuterDriver { * The entry already names the worker rows pointing at it, so a file no * worker is bound to (nearly every write) costs no query. The rows * themselves come from the primary: one deleted by cascade can outlive its - * by-name cache entry. + * by-name cache entry. Once the entry is deleted, its FK has already set + * those rows' `root_dir_id` to NULL. */ - async #listWorkerRowsForEntry(entry: FSEntry): Promise { + async #listWorkerRowsForEntry( + entry: FSEntry, + { deleted = false }: { deleted?: boolean } = {}, + ): Promise { const names = (entry.workers ?? []).map((worker) => worker.subdomain); if (names.length === 0) return []; const rowsByName = await this.stores.subdomain.getBySubdomains(names, { @@ -1269,8 +1275,9 @@ export class WorkerDriver extends PuterDriver { }); return [...rowsByName.values()].filter( (row) => - Number(row.root_dir_id) === entry.id && - Number(row.user_id) === entry.userId, + Number(row.user_id) === entry.userId && + (Number(row.root_dir_id) === entry.id || + (deleted && row.root_dir_id == null)), ); }