Published events not offered to the EventStore/IDB cache — stale reads and phantom cache entries (siblings of #62) #64
Labels
No labels
bug
discussion
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
edufeed/edufeed-app#64
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while fixing #62 (PR #63). Same class, different call sites. All verified by grep at
dev@3a4b9e8c, none of them reproduced in a browser — so treat the severity ordering as reasoned, not measured.Why this class matters at all
The local IndexedDB cache is the first step of applesauce's
addressPointerLoadingSequence, and a cache hit ends the sequence — relay hints, additional relays and lookup relays are never tried. So for any cacheable kind, whatever is in IDB is what the app shows, and a relay cannot correct it.The cache is fed exclusively from
eventStore.insert$(event-cache.svelte.js:70-78).publishEventdoes not touch the EventStore. Soawait publishEvent(...)without a followingeventStore.add(signed)means the new version never reaches the cache.1.
relay-settings-service.js:66— kind 10002, the worst of the setgrep -c eventStore src/lib/services/relay-settings-service.jsreturns 0. Kind 10002 is cacheable (event-cache.svelte.js:21), and it is the relay list itself — a stale read there mis-routes every subsequent query, so the blast radius is not one screen.Fix:
eventStore.add(signed)after a successful publish.2.
calendar-actions.svelte.js:279— kind 31924 (calendars)Cacheable (
event-cache.svelte.js:29), noeventStore.add. Milder than #62 because it is a create: there is no stale prior version to be served, only "missing until a relay round-trip". Same one-line shape.3.
publishEventOptimistic's failure path cannot reach IDB — phantom cache entriespublish-service.js:333callseventStore.remove(signedEvent)when no relay accepted the publish. But the cache pipeline is insert-only:grep -rn 'nostrIDB.remove|nostrIDB.delete|deleteEvents' src/lib/returns nothing, and the only IDB delete in the app isnostrIDB.deleteAllEvents()(event-cache.svelte.js:187, the manual "clear cache" action).Failure is detected only after
Promise.allSettledagainst a 5000ms default timeout, whilepersistEventsToCachebatches at 1000ms — so unless every relay fails inside 1s, the batch has already flushed and the event is in IDB permanently.For an addressable kind that is worse than a leak. nostr-idb keys by
kind:pubkey:d, newest-wins, so the phantom overwrites the last good version:eventStore.removeleaves memory empty at that address, IDB holds an event that exists on no relay, and the cache-hit short-circuit means no relay is ever asked to correct it. Failed edit → the app shows the edit forever, and the real version is unreachable from cache.createEvent(calendar-actions.svelte.js:105) takes this path live today for kinds 31922/31923.The discriminating experiment is dead-relay → save → reload. Not run.
Fix options: a single-event IDB delete wired to
eventStore.removed$, or stop adding optimistically for cacheable kinds and add only on success (theEditProfileModal/pin-list-service/helpers/comments.jsshape, which is what #62 adopted).4.
calendarActions.deleteEvent(calendar-actions.svelte.js:234) — dead code encoding the wrong patternPublishes a kind 5 with no
eventStore.addand nocacheDeletion— exactly the failure thecacheDeletiondoc comment (event-cache.svelte.js:106-113) warns about, deleted content reappearing on reload.It is not a live bug:
grep -rn '\.deleteEvent(' src/returns nothing. The calendar UI goes throughdeleteCalendarEventin$lib/helpers/eventDeletion.js, which does both (eventDeletion.js:69-70), imported atCalendarEventDetailView.svelte:30andEventManagementActions.svelte:8.Worth deleting so it does not get copied.
Checked and NOT affected
pin-list-service.js:102,135—eventStore.add(signed)at 103 and 136. Correct already, and kind 10001 is not cacheable.EditProfileModal:283,EditCommunityModal:299,helpers/comments.js:41-49— allawait publishEvent(...)theneventStore.addon success. This is the pattern the others should follow.Credit: sites 1–4 were surfaced and corrected by TestOER during the #62 review; the not-affected list is theirs too. I re-ran every grep above myself.
A fifth item for this list: the same-second replacement tie
Found while closing out #62 (PR #63), and it is not calendar-specific — it applies to every replaceable/addressable update in the app.
unixNow()inapplesauce-core/helpers/time.jsisMath.round(Date.now() / 1000), not floor. So an event created at…x.5–…x.999is stamped in the next second, and an edit made shortly after lands in that same second. Acreated_attie is then resolved three different ways, none of them in the user's favour:EventStore.addevent-store.js,incomingBeatsWinner)nostr-idb addEventsevent.created_at > existing(database/insert.js:23)The cache is deterministically stale, and because the IDB cache is the first step of applesauce's address-loader sequence and a cache hit ends that sequence, no relay ever corrects it. Measured on the e2e suite: the tie predicted pass/fail for 6 of 6 attempts.
updateEventnow stampsMath.max(unixNow(), existing.created_at + 1)(edufeed-app485cab0d). Every other replaceable update in the app still has this, including therelay-settings-service.jskind-10002 path listed above — so that file needs both fixes, not just theeventStore.add.Worth pairing with a check on any code that assumes
unixNow()floors; it can also stamp up to 500ms into the future.Adding the second half of #62's root cause here, because it is not calendar-specific and this issue is where the sibling audit lives.
unixNow()isMath.round, notMath.floor:applesauce-core/dist/helpers/time.jsSo an event created at
.5–.999of a second is stamped in the next second with zero elapsed time, and a quick edit lands in that same second. Acreated_attie is then resolved differently at three layers:created_atEventStore.add—incomingBeatsWinner,event-store.js:168-185nostr-idbaddEvents—event.created_at > existing,database/insert.js:23The cache layer is the dangerous one: it is not a race, it is deterministic. On a tie the local cache keeps the pre-edit event, and because a cache hit ends applesauce's
addressPointerLoadingSequence(address-loader.js:57-63), no relay is ever asked to correct it.updateEventnow guards withcreated_at: Math.max(unixNow(), existing.created_at + 1)(485cab0d), but that fix is local to the calendar update path. Every other replaceable-event update in the app has the same exposure — includingrelay-settings-service.js(kind 10002), already the worst entry on this issue for the missingeventStore.add. A same-second edit of the relay list would be dropped from cache deterministically, and a stale relay list mis-routes every query after it.Two tabs on one coordinate, or an edit straight after a create, both reach this outside the tests.
Whoever picks this up: the guard belongs in a shared helper rather than copied per call site, otherwise it goes the same way as the
eventStore.addomissions this issue is already about.PR #69 opened — #69 (branch
fix/issue-64-eventstore-add-omissions, commit13ac018d).All four sites in this issue are addressed, plus a shared helper the owner signed off on.
Sites 1, 2, 4 are the one-line shape the issue describes. Site 3 (the phantom) needed two guards rather than one, and the obvious delete turned out to be a no-op: nostr-idb keys replaceable events by
kind:pubkey:d, not by id, sodeleteEvent(event.id)silently matches nothing for exactly the kinds that matter.uncacheEventkeys bygetEventUIDand refuses to delete when a newer version holds the address; the write callback separately re-checkseventStore.hasEventat flush time for the case where the failure lands inside the batch window.Beyond the issue:
nextCreatedAt+cachePublishedEventmoved intohelpers/replaceableUpdates.js, and adoptingnextCreatedAtsurfaced three further update paths carrying the #62 bug with no guard —updateWiki(30818),updateArticle(30023),updateResource(30142).Verification: 473 files / 5213 tests pass (+20) at
13ac018d,pnpm check0 errors, lint clean. Each new cache test confirmed load-bearing by reverting the guard it covers.Still not established, as the issue said: the discriminating experiment for site 3 is dead-relay → save → reload in a browser. Not run — assigned to TestOER. Leaving this issue open until that lands and the PR merges.
Fixed and merged to
devvia PR #69.All four sites in this issue, plus a shared helper (
helpers/replaceableUpdates.js) whose adoption surfaced three further update paths carrying the #62 bug —updateWiki(30818),updateArticle(30023),updateResource(30142).Browser-verified by TestOER across four dead-relay modes, IndexedDB read directly, with negative controls. Two of my original claims did not survive that measurement and were corrected before merge:
uncacheEventalone deletes rather than recovers — the phantom overwrites the good version at its address before anything knows the publish failed, so the address ended EMPTY 9/9 (user-visibly a 404 on a resource that exists).publishEventOptimisticnow captures the replaced version before the optimistic add and re-adds it after the un-cache.hasEventflush-time guard does not fire on this path — the fastest possible failure reports at 1006-1015ms against a 1000ms batch, and deleting the guard left outcomes identical 5/5. Kept for the invariant it does cover (superseded versions, NIP-09 deletions), with the comment and test name corrected.uncacheEventis the whole of the fix: disabled, the phantom leaks 4/4.Closing.