From 7faed7e5f4739d2208ab645e5d979fd3debc3137 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:46:01 +0100 Subject: [PATCH] fix: send a change notice again when the shared store dropped right after an admin save #1066 --- lib/shared-store/__tests__/boot.test.ts | 22 ++++++++++++++++++++++ lib/shared-store/changes.ts | 23 +++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/shared-store/__tests__/boot.test.ts b/lib/shared-store/__tests__/boot.test.ts index d6964070d..d6e9a44ff 100644 --- a/lib/shared-store/__tests__/boot.test.ts +++ b/lib/shared-store/__tests__/boot.test.ts @@ -87,6 +87,28 @@ describe('change notices', () => { expect(seen).toEqual(['local', 'local', 'remote']); }); + it('sends a change notice again when the store was unreachable right after the write', async () => { + const store = fakeSharedStore(); + setStoreForTests(store); + await store.epochs.bump(INIT_EPOCH); + await markEpochsSeen(); + const bump = store.epochs.bump.bind(store.epochs); + let fail = true; + store.epochs.bump = async (area: string) => { + if (fail) throw new Error('connection dropped'); + return bump(area); + }; + await announceChange('config'); + expect((await store.epochs.all()).config).toBeUndefined(); + await pollChanges(); // still down + expect((await store.epochs.all()).config).toBeUndefined(); + fail = false; + await pollChanges(); + expect((await store.epochs.all()).config).toBe(1); + await pollChanges(); + expect((await store.epochs.all()).config).toBe(1); // sent once, not every poll + }); + it('retries a listener that failed on the next poll', async () => { const store = fakeSharedStore(); setStoreForTests(store); diff --git a/lib/shared-store/changes.ts b/lib/shared-store/changes.ts index 060e8934f..c0bc3a79c 100644 --- a/lib/shared-store/changes.ts +++ b/lib/shared-store/changes.ts @@ -33,13 +33,15 @@ interface ChangeGlobals { polling?: Promise; lastPollOk: number; storeLost: boolean; + /** Areas whose epoch bump failed (store unreachable after the write). */ + pendingBumps: Set; } const KEY = Symbol.for('bulwark.sharedStore.changes'); function globals(): ChangeGlobals { const g = globalThis as typeof globalThis & { [KEY]?: ChangeGlobals }; - return (g[KEY] ??= { listeners: new Map(), seen: new Map(), lastPollOk: 0, storeLost: false }); + return (g[KEY] ??= { listeners: new Map(), seen: new Map(), lastPollOk: 0, storeLost: false, pendingBumps: new Set() }); } /** @@ -74,8 +76,8 @@ async function runListeners(area: string, origin: ChangeOrigin): Promise { const g = globals(); @@ -86,7 +88,8 @@ export async function announceChange(area: string): Promise { // otherwise the poller reloads to pick up the other replica's. if ((g.seen.get(area) ?? 0) === epoch - 1) g.seen.set(area, epoch); } catch (error) { - logger.warn('Could not announce a change to the other replicas', { + g.pendingBumps.add(area); + logger.warn('Could not announce a change to the other replicas; retrying with the next poll', { area, error: error instanceof Error ? error.message : 'Unknown error', }); @@ -95,11 +98,23 @@ export async function announceChange(area: string): Promise { await runListeners(area, 'local'); } +/** Send the bumps that failed earlier. A failure leaves them for the next poll. */ +async function retryPendingBumps(g: ChangeGlobals): Promise { + for (const area of [...g.pendingBumps]) { + await getStore().epochs.bump(area); + g.pendingBumps.delete(area); + logger.info('Announced a change to the other replicas after a retry', { area }); + } +} + /** One poll: read the epochs, run the listeners of every area that moved. */ export async function pollChanges(): Promise { const g = globals(); if (g.polling) return g.polling; g.polling = (async () => { + // Our own retried bump then shows up as a change on this replica too, + // which costs one reload and keeps the logic simple. + if (g.pendingBumps.size) await retryPendingBumps(g); const epochs = await getStore().epochs.all(); if (!epochs[INIT_EPOCH]) { if (!g.storeLost) {