-
Notifications
You must be signed in to change notification settings - Fork 12
Prevent stale 404s for files written by another realm-server instance #5923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bdd13a8
65b7292
a522e1f
1dae480
4ded68a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,14 +13,21 @@ const log = logger('realm-server:file-changes-listener'); | |
| // runtime-common/realm.ts), every listener subscribed on this channel looks | ||
| // up the URL in its lookup function. If the realm is mounted locally, | ||
| // `realm.invalidateCache(path)` clears the matching #sourceCache / | ||
| // #transpiledModuleCache entries. If it's not mounted, the notification is dropped — | ||
| // this instance has no stale state to clear. | ||
| // #transpiledModuleCache entries, and `realm.refreshDirectoryView(path)` | ||
| // re-lists the path's parent directory so the kernel's cached view of the | ||
| // shared filesystem (which can still say the file is absent) is refreshed | ||
| // too. If it's not mounted, the notification is dropped — this instance has | ||
| // no stale state to clear. | ||
| // | ||
| // Bulk variant: when the path is the wildcard sentinel `*` (CS-11156), | ||
| // `realm.clearLocalSourceCaches()` drops every cached path for that realm. Emitted | ||
| // by the publish-realm / unpublish-realm / delete-realm handlers after the | ||
| // FS swap or removal so peers (whose file-watcher events do NOT cross | ||
| // replicas) bypass their pre-swap cached bytes on the next read. | ||
| // replicas) bypass their pre-swap cached bytes on the next read. Note the | ||
| // wildcard branch clears only the in-memory byte caches: the kernel's view of | ||
| // the swapped directory tree is NOT refreshed here (that would take a | ||
| // recursive walk of the realm), so a peer can still serve stale | ||
| // file-not-found for files a republish added, until the kernel cache expires. | ||
| // | ||
| // The LISTEN is backed by `PgAdapter.subscribe` (shared multiplexed | ||
| // notification client). There is no periodic work to run between | ||
|
|
@@ -102,6 +109,13 @@ export class RealmFileChangesListener { | |
| realm.clearLocalSourceCaches(); | ||
| } else { | ||
| realm.invalidateCache(parsed.path); | ||
| // Fire-and-forget: the NOTIFY handler stays synchronous, and a failed | ||
| // directory listing only means the kernel cache expires on its own. | ||
| realm.refreshDirectoryView(parsed.path).catch((err: unknown) => { | ||
|
Comment on lines
+112
to
+114
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] (Written by Claude on Matic's behalf.) Fixed both parts: |
||
| log.warn( | ||
| `refreshDirectoryView failed for ${parsed.url} ${parsed.path}: ${String(err)}`, | ||
| ); | ||
| }); | ||
| } | ||
| } catch (err: unknown) { | ||
| const op = isWildcard ? 'clearLocalSourceCaches' : 'invalidateCache'; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import QUnit from 'qunit'; | ||
| const { module, test } = QUnit; | ||
| import { basename } from 'path'; | ||
| import { | ||
| DirectoryViewRefresher, | ||
| ancestorDirectories, | ||
| } from '@cardstack/runtime-common'; | ||
|
|
||
| module(basename(import.meta.filename), function () { | ||
| test('ancestorDirectories lists the realm root first, then each directory down to the parent', function (assert) { | ||
| assert.deepEqual(ancestorDirectories('c.json'), ['']); | ||
| assert.deepEqual(ancestorDirectories('a/c.json'), ['', 'a']); | ||
| assert.deepEqual(ancestorDirectories('a/b/c.json'), ['', 'a', 'a/b']); | ||
| }); | ||
|
|
||
| test('refresh lists every ancestor, root first', async function (assert) { | ||
| let listed: string[] = []; | ||
| let refresher = new DirectoryViewRefresher(async (dir) => { | ||
| listed.push(dir); | ||
| }); | ||
|
|
||
| await refresher.refresh('PersonCard/nested/bob.json'); | ||
|
|
||
| assert.deepEqual(listed, ['', 'PersonCard', 'PersonCard/nested']); | ||
| }); | ||
|
|
||
| test('concurrent refreshes of one directory never overlap listings and coalesce into fewer listings than refreshes', async function (assert) { | ||
| let calls: string[] = []; | ||
| let inFlight = new Map<string, number>(); | ||
| let maxInFlight = new Map<string, number>(); | ||
| let refresher = new DirectoryViewRefresher(async (dir) => { | ||
| calls.push(dir); | ||
| let now = (inFlight.get(dir) ?? 0) + 1; | ||
| inFlight.set(dir, now); | ||
| maxInFlight.set(dir, Math.max(maxInFlight.get(dir) ?? 0, now)); | ||
| // A listing takes a round trip; every refresh below arrives during it. | ||
| await new Promise((r) => setTimeout(r, 5)); | ||
| inFlight.set(dir, now - 1); | ||
| }); | ||
|
|
||
| // Five notifications for files in the same directory arrive at once (a | ||
| // batch write emits one per file). | ||
| let names = ['a.json', 'b.json', 'c.json', 'd.json', 'e.json']; | ||
| await Promise.all( | ||
| names.map((name) => refresher.refresh(`PersonCard/${name}`)), | ||
| ); | ||
|
|
||
| let count = (dir: string) => calls.filter((c) => c === dir).length; | ||
| assert.strictEqual(maxInFlight.get(''), 1, 'root listings never overlap'); | ||
| assert.strictEqual( | ||
| maxInFlight.get('PersonCard'), | ||
| 1, | ||
| 'PersonCard listings never overlap', | ||
| ); | ||
| // The five refresh() calls reach their first `await` synchronously, so | ||
| // the first starts the root listing and the other four share one pending | ||
| // follow-up: the root count is structurally exactly 2. The PersonCard | ||
| // count depends on how its first listing interleaves with the root | ||
| // follow-up, so a bound is the honest assertion there. | ||
| assert.strictEqual( | ||
| count(''), | ||
| 2, | ||
| 'root listed once, then one follow-up for the four that arrived during it', | ||
| ); | ||
| let dirBounded = count('PersonCard') <= 3; | ||
| assert.true( | ||
| dirBounded, | ||
| `PersonCard listings stayed bounded (${count('PersonCard')})`, | ||
| ); | ||
|
|
||
| calls.length = 0; | ||
| await refresher.refresh('PersonCard/f.json'); | ||
| assert.deepEqual( | ||
| calls, | ||
| ['', 'PersonCard'], | ||
| 'after everything settles a new refresh lists again', | ||
| ); | ||
| }); | ||
|
|
||
| test('a request that arrives while the follow-up listing is running queues another one', async function (assert) { | ||
| let resolvers: Array<() => void> = []; | ||
| let refresher = new DirectoryViewRefresher( | ||
| (_dir) => | ||
| new Promise<void>((resolve) => { | ||
| resolvers.push(resolve); | ||
| }), | ||
| ); | ||
| let started = () => resolvers.length; | ||
| let untilStarted = async (n: number) => { | ||
| while (started() < n) { | ||
| await new Promise((r) => setTimeout(r, 0)); | ||
| } | ||
| }; | ||
|
|
||
| let first = refresher.refresh('a.json'); // starts listing 1 | ||
| let second = refresher.refresh('b.json'); // queues listing 2 | ||
| assert.strictEqual(started(), 1, 'only listing 1 has started'); | ||
|
|
||
| resolvers[0](); | ||
| await untilStarted(2); // listing 2 is now running | ||
|
|
||
| // This request arrives while listing 2 runs; listing 2 started before the | ||
| // write behind this request, so it must get listing 3, not share 2. | ||
| let third = refresher.refresh('c.json'); | ||
| resolvers[1](); | ||
| await untilStarted(3); | ||
| resolvers[2](); | ||
|
|
||
| await Promise.all([first, second, third]); | ||
| assert.strictEqual(started(), 3, 'the late request got its own listing'); | ||
| }); | ||
|
|
||
| test('one ancestor failing does not stop the rest of the chain', async function (assert) { | ||
| let listed: string[] = []; | ||
| let refresher = new DirectoryViewRefresher(async (dir) => { | ||
| listed.push(dir); | ||
| if (dir === '') { | ||
| throw new Error('root EIO'); | ||
| } | ||
| }); | ||
|
|
||
| await assert.rejects(refresher.refresh('a/b/c.json'), /root EIO/); | ||
| assert.deepEqual( | ||
| listed, | ||
| ['', 'a', 'a/b'], | ||
| 'the parent directories were still listed after the root failed', | ||
| ); | ||
| }); | ||
|
|
||
| test('a listing that never settles is timed out and stops blocking later refreshes', async function (assert) { | ||
| let calls = 0; | ||
| let refresher = new DirectoryViewRefresher( | ||
| (_dir) => | ||
| new Promise<void>((resolve) => { | ||
| calls++; | ||
| if (calls > 1) { | ||
| resolve(); | ||
| } | ||
| // the first listing never settles | ||
| }), | ||
| { timeoutMs: 20 }, | ||
| ); | ||
|
|
||
| await assert.rejects(refresher.refresh('a.json'), /timed out listing ""/); | ||
| await refresher.refresh('b.json'); | ||
| assert.strictEqual(calls, 2, 'the second refresh got a fresh listing'); | ||
| }); | ||
|
|
||
| test('a failed listing rejects that refresh and does not poison later ones', async function (assert) { | ||
| let attempt = 0; | ||
| let refresher = new DirectoryViewRefresher(async () => { | ||
| attempt++; | ||
| if (attempt === 1) { | ||
| throw new Error('EIO'); | ||
| } | ||
| }); | ||
|
|
||
| await assert.rejects(refresher.refresh('x.json'), /EIO/); | ||
| await refresher.refresh('x.json'); | ||
| assert.strictEqual(attempt, 2, 'the second refresh listed again'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Is the bulk branch meant to stay uncovered? It is the same stale-view bug over a whole realm instead of one path.
handle-publish-realm.tsswaps the realm directory on disk and then callsclearLocalSourceCachesAndBroadcast(). That drops peers' byte caches but leaves their kernel view of the swapped tree untouched, so a peer that still has the republished realm mounted can 404 every file the new snapshot added, for the same tens of seconds. Unpublish and delete are covered incidentally — the realm is unmounted there, solookupMountedRealmreturns undefined and the notification is dropped before this branch ever runs — but republish is not.Either wire something in here or say in the PR that it's a follow-up; as it stands the wildcard branch reads as handled.
realm.refreshDirectoryView('')is a one-line partial that covers top-level files (ancestorDirectories('')is just['']); covering nested files needs a recursive walk, which is a real cost decision and probably its own change.Pre-existing, now load-bearing — this PR establishes the mechanism that makes the gap visible. Non-blocking.
Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] (Written by Claude on Matic's behalf.) Follow-up, deliberately: covering republish needs a recursive walk of the swapped tree, which is a cost decision that deserves its own change. The listener's wildcard comment now says explicitly that the kernel view is NOT refreshed there and what the consequence is, so the branch no longer reads as handled.