fix(offline): a flush asked for during a running pass gets its own pass

An online edit that has to queue behind an older write to the same entity
starts a flush so it goes out right after that write. When the older write
was already in flight, flush() returned at once on the in-tab flag, and the
running pass had read its pending rows before the new one existed. The edit
then sat in the queue until the next trigger (the 30 second tick, a
visibility change or a reconnect), so collaborators saw it late and a
refresh in that window showed the older server value.

A flush called while a pass runs now asks that pass for one more round,
which it starts once it is done, next to the existing "mine wins" re-run.
Each pass clears the request when it starts, so the extra rounds stay
bounded by the calls made during a pass.
This commit is contained in:
Maurice
2026-10-08 11:57:06 +02:00
parent 48b1e879d2
commit c63ec5e285
2 changed files with 43 additions and 4 deletions
+22
View File
@@ -120,6 +120,28 @@ describe('an online edit of a visit with an older write still queued', () => {
expect(sentTimes()).toEqual(['07:00', '09:00'])
})
it('goes out once the older one lands even when that one was already on its way', async () => {
await queueFirst('pending')
// The older time is in flight when the new one is made: the running pass has
// already read its rows, so it must run once more for the new one.
let edited: Promise<unknown> | undefined
vi.mocked(apiClient.request).mockImplementation(async ({ data }) => {
const sent = (data as typeof first).place_time
if (sent === '07:00') {
edited = assignmentRepo.setTimes(9, assignment, second)
await edited
}
return { data: { assignment: { ...assignment, assignment_time: sent } } }
})
await mutationQueue.flush()
expect(edited).toBeDefined()
expect(assignmentsApi.updateTime).not.toHaveBeenCalled()
expect(sentTimes()).toEqual(['07:00', '09:00'])
expect(await offlineDb.mutationQueue.count()).toBe(0)
})
it('holds a day clear behind an older clear of the same day', async () => {
await assignmentRepo.clearDay(9, 1)
await offlineDb.mutationQueue.toCollection().modify({ status: 'failed', attempts: 8 })
+21 -4
View File
@@ -41,6 +41,10 @@ export function generateUUID(): string {
}
let _flushing = false
// Set when flush() is called while a pass is already running. That pass read its
// pending rows when it began, so a write queued since then waits for one more
// pass, which the running one starts once it is done.
let _flushAgain = false
/**
* Take the cross-tab flush lock, waiting while another tab holds it. Every open
@@ -255,7 +259,8 @@ export const mutationQueue = {
/**
* Start a flush for a write that joined the queue while online (see
* mustQueue). It goes out as soon as the write it waits for is through, not
* at the next trigger. Offline this does nothing.
* at the next trigger: when a flush is already running, that flush runs one
* more pass once it is done (see flush). Offline this does nothing.
*/
sendSoon(): void {
if (isEffectivelyOffline()) return
@@ -270,10 +275,19 @@ export const mutationQueue = {
* failed once it has failed MAX_SERVER_ERROR_ATTEMPTS times. A write parked as
* failed or as a conflict holds back the later writes to the same entity, so
* they keep their order whatever the user decides about it.
*
* A call that arrives while a pass is running returns at once, but asks that
* pass for one more round: the running pass read the pending rows when it
* began and would not see a write queued since.
*/
async flush(): Promise<void> {
if (_flushing || isEffectivelyOffline() || !isAuthed()) return
if (isEffectivelyOffline() || !isAuthed()) return
if (_flushing) {
_flushAgain = true
return
}
_flushing = true
_flushAgain = false
const release = await acquireFlushLock()
// tempId → realId learned during this flush, so a dependent edit/delete
// queued against an offline-created entity (still holding the negative id)
@@ -518,8 +532,10 @@ export const mutationQueue = {
}
// A "mine wins" auto-resolution dropped its base token; one more pass now
// overwrites the server unconditionally. Bounded: the retried write carries
// no token, so it cannot 409 for the same reason.
if (needsRetry && !isEffectivelyOffline()) {
// no token, so it cannot 409 for the same reason. A flush asked for while
// this pass ran gets its pass here too. Bounded as well: the flag is only set
// by a call made during a pass, and every pass clears it when it starts.
if ((needsRetry || _flushAgain) && !isEffectivelyOffline()) {
await this.flush()
}
},
@@ -633,6 +649,7 @@ export const mutationQueue = {
/** Reset internal flushing flag and timestamp counters — useful in tests. */
_resetFlushing(): void {
_flushing = false
_flushAgain = false
_lastTs = 0
_lastTempId = 0
},