Skip to content

fix(admin): stop a slow tag-write errors read from overwriting newer errors - #952

Merged
ajslater merged 1 commit into
developfrom
fix-admin-tag-write-errors-race
Sep 28, 2026
Merged

ajslater merged 1 commit into
developfrom
fix-admin-tag-write-errors-race

Conversation

@ajslater

Copy link
Copy Markdown
Owner

Problem

loadTagWriteErrors in frontend/src/stores/admin.js had the same race that #949 fixed for loadTable. It didn't check the order its responses came back in, and it stamped the list when the response arrived.

  1. The Tagging tab or the settings button mounts and calls loadTagWriteErrors() unforced. Read A is in flight.
  2. A tag write fails. The server sends TAG_WRITE_ERRORS_CHANGED, and socket.js forces reload B, which lands the new list.
  3. A arrives late and puts the older list back. It also restamps it, so every unforced read for the next 5 s is served that stale list.

clearTagWriteErrors wrote [] directly, so it had the same problem. A read that went out before the clear and landed after it put the cleared errors back.

Fix

  • fix(admin): stop a slow table read from overwriting newer rows #949's landing check becomes a small claimLanding(key, request) helper. loadTable, loadTagWriteErrors and clearTagWriteErrors all use it, with the same tableRequests counter. landed is keyed like timestamps, and TagWriteErrors is one of those keys.

  • Tag-write reads take a request number when they go out. They land only if no later request has landed, and they stamp the time they went out.

  • The clear counts as a request. It takes its number when the DELETE goes out, not when the response comes back:

    • a read that went out before the clear can't put the cleared errors back;
    • a read that went out after the clear and has already landed isn't wiped by it. The DELETE broadcasts TAG_WRITE_ERRORS_CHANGED, so this session's own forced reload can land first.

    If the number were taken on arrival, that later reload would be dropped.

  • The store id, state shape and member names are unchanged. The counter stays module-level.

Singleton loaders: not changed

loadTaggingDefaults, loadEmailSettings, loadOidcSettings, loadSettingsDefaults and loadThrottleSettings are only called unforced on mount, and nothing forces them: no WebSocket message reloads them. Their only writer is the same tab's own Save. It could race a mount read only when the tab remounts after its 5 s cache has expired and an admin edits and saves before that read comes back. A save on a first mount isn't possible: every one of these tabs shows a spinner until its settings load. Two overlapping unforced reads both return the same server state, so their order doesn't matter. I found no realistic overlap, so I left them as they are.

Tests

The new frontend/tests/unit/admin-tag-write-errors-store.test.js uses deferred promises, like the loadTable response order block:

  • Slow mount read, then a WebSocket reload lands first, then the mount read lands: the list is the reload's, the stamp is the reload's request time, and an unforced read inside its TTL makes no fetch.
  • Mount read and reload land in order: both land, and each stamps its own request time.
  • A later forced read fails: the earlier read still lands.
  • A read that takes 3 s is stamped when it went out.
  • A read that went out before the clear lands after it: the list stays [], stamped with the clear's request time.
  • A read that went out after the clear lands first: the clear is dropped and the read's errors stay.
  • A read that went out after the clear lands after it: the clear lands, then the read does.
  • The clear fails: an earlier read still lands.

Six of the eight cases fail against the old store code. The two "still lands an earlier read" cases pass on both versions: they guard against the fix dropping too much.

  • bunx vitest run: 89 files, 930 tests passed
  • bunx eslint and bunx prettier --check on the changed files: clean

🤖 Generated with Claude Code

…errors

loadTagWriteErrors had the race loadTable had before #949. The Tagging
tab and the settings button read the list unforced when they mount, and
the WebSocket forces a reload whenever it changes. If a slow mount read
landed after the forced reload, it put the older list back and stamped
it when it arrived, so every unforced read for the next 5 s was served
the stale list. clearTagWriteErrors wrote [] directly, so a read that
went out before the clear could put the cleared errors back the same way.

Both now use loadTable's request counter. #949's landing check moves into
a claimLanding(key, request) helper that all three share, keyed like
timestamps. The tag-write reads take a number when they go out, land only
if no later request has landed, and stamp the time they went out. The
clear takes a number when its DELETE goes out, so an older read can't
undo it, and a read that went out after it and already landed isn't
wiped by it.

The singleton settings loads (tagging defaults, email, OIDC, site
defaults, throttling) are unchanged. Nothing forces them, and their only
writer is the same tab's own Save. That can't realistically fire before
the tab's mount read comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 24a6764 into develop Sep 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant