Summary
IndexedDbFileSystem.flush() returns without awaiting when a write batch is already in flight. Its own doc comment promises the opposite:
Waits for all pending operations to finish, then completes the future.
Each call to [flush] will await pending operations made before the call.
This is a regression in 3.4.0. In 3.3.4 flush() queued a marker behind the running batch and awaited it; the marker was removed in 11be8acb ("Optimize indexeddb flush on idle").
The change
- Future<void> flush() async {
- final item = _submitWorkFunction((_) async {}, 'flush');
- _startWorkingIfNeeded(isImplicit: false);
- await item;
+ Future<void> flush() {
+ return _startWorkingIfNeeded(isImplicit: false);
}
The same commit made _startWorkingIfNeeded async and await the writes, which makes the idle path a genuine fence and removes the marker's overhead. But the busy path is now a no-op:
Future<void> _startWorkingIfNeeded({required bool isImplicit}) async {
if (isImplicit && !_writeAutomatically) return;
if (!_isWorking && _pendingWork.isNotEmpty) { // ← skipped while a batch runs
...
await _asynchronous._performWrites(items).whenComplete(...);
}
}
With writeAutomatically: true (the default), xWrite → _submitWork → _startWorkingIfNeeded(isImplicit: true) sets _isWorking synchronously, so right after any write the flag is already set. A flush() at that moment takes no branch and its future completes immediately, before the IndexedDB transaction commits.
Reproduction
Any write followed by a flush:
final fs = await IndexedDbFileSystem.open(dbName: 'repro');
// ... open a database on this VFS and INSERT something ...
await fs.flush(); // returns before the IndexedDB transaction commits on >= 3.4.0
Measured in headless Chrome: on 3.5.2 flush() completed after 0 event-loop turns; on 3.3.3 it waited 7.
Version boundary, checked against the source at each tag:
| version |
flush() body |
fence? |
| 3.3.3, 3.3.4 |
_submitWorkFunction(() {}, 'flush') |
yes |
| 3.4.0 … 3.5.2 |
_startWorkingIfNeeded(isImplicit: false) |
only when idle |
Why it matters
flush() is the only way to force a transaction. open()'s doc says writes are "asynchronously written to IndexedDB without any durability guarantees. You can invoke [flush] to force a transaction", and the 3.4.0 changelog says writeAutomatically: false users "need to call flush manually" — for those users a flush that does not fence means the write may not have happened at all yet.
For default users the window is small (the VFS streams writes continuously, so what is missed is the batch in flight), but it is exactly the window an app closes in when it flushes on visibilitychange before the tab goes away.
Suggested fix
Keep the idle optimisation, restore the marker only when busy:
Future<void> flush() {
if (!_isWorking) return _startWorkingIfNeeded(isImplicit: false);
// A batch is already running: queue a marker behind it and await that.
return _submitWorkFunction((_) async {}, 'flush');
}
_FunctionWorkItem does not override insertInto, so it always lands at the tail of _pendingWork and is never merged or skipped. When the running batch finishes, whenComplete restarts work and the next batch carries every write submitted before the flush, plus the marker last — so the marker's completer resolves only after they are committed. This is what close() already does.
Happy to open a PR for this.
Found while adding an explicit persistence call to a package that uses this VFS for a vector store (DenisovAV/flutter_gemma#507).
Summary
IndexedDbFileSystem.flush()returns without awaiting when a write batch is already in flight. Its own doc comment promises the opposite:This is a regression in 3.4.0. In 3.3.4
flush()queued a marker behind the running batch and awaited it; the marker was removed in11be8acb("Optimize indexeddb flush on idle").The change
The same commit made
_startWorkingIfNeededasyncandawaitthe writes, which makes the idle path a genuine fence and removes the marker's overhead. But the busy path is now a no-op:With
writeAutomatically: true(the default),xWrite→_submitWork→_startWorkingIfNeeded(isImplicit: true)sets_isWorkingsynchronously, so right after any write the flag is already set. Aflush()at that moment takes no branch and its future completes immediately, before the IndexedDB transaction commits.Reproduction
Any write followed by a flush:
Measured in headless Chrome: on 3.5.2
flush()completed after 0 event-loop turns; on 3.3.3 it waited 7.Version boundary, checked against the source at each tag:
flush()body_submitWorkFunction(() {}, 'flush')_startWorkingIfNeeded(isImplicit: false)Why it matters
flush()is the only way to force a transaction.open()'s doc says writes are "asynchronously written to IndexedDB without any durability guarantees. You can invoke [flush] to force a transaction", and the 3.4.0 changelog sayswriteAutomatically: falseusers "need to callflushmanually" — for those users a flush that does not fence means the write may not have happened at all yet.For default users the window is small (the VFS streams writes continuously, so what is missed is the batch in flight), but it is exactly the window an app closes in when it flushes on
visibilitychangebefore the tab goes away.Suggested fix
Keep the idle optimisation, restore the marker only when busy:
_FunctionWorkItemdoes not overrideinsertInto, so it always lands at the tail of_pendingWorkand is never merged or skipped. When the running batch finishes,whenCompleterestarts work and the next batch carries every write submitted before the flush, plus the marker last — so the marker's completer resolves only after they are committed. This is whatclose()already does.Happy to open a PR for this.
Found while adding an explicit persistence call to a package that uses this VFS for a vector store (DenisovAV/flutter_gemma#507).