Skip to content

Commit f5df884

Browse files
Merge pull request #875 from SceneTech/fix/indexeddb-readwrite-serialization-269
Serialize IndexedDB readwrite commits
2 parents 98077f2 + 9e61638 commit f5df884

3 files changed

Lines changed: 72 additions & 3 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# IndexedDB read/write transaction serialization
2+
3+
## Consumer failure
4+
5+
The unchanged VS Code OSS 1.137 workbench opens several `readwrite`
6+
transactions against its `vscode-web-state-db-global` database during startup
7+
and state flush. WebScene previously let every transaction commit from the
8+
revision visible when the transaction was created. More than four overlapping
9+
writes could exhaust the compatibility layer's conflict retry limit and reject
10+
a valid transaction with `AbortError`.
11+
12+
The exact AppScene `cd0a02e` / WebScene `98077f23` Release package reproduced
13+
this while saving the encrypted MCP input metadata. The native log reported
14+
`IndexedDB database changed before this transaction committed` before the
15+
reload lifecycle began. The encrypted key survived, while the workspace
16+
`mcpInputs` record did not.
17+
18+
## Contract
19+
20+
WebScene now serializes `readwrite` commits per database inside one runtime.
21+
Each transaction retains its isolated mutation log. When it reaches the head of
22+
the queue, the existing optimistic concurrency path reloads the newest durable
23+
revision, replays the mutations, and commits atomically. A transaction aborted
24+
while queued is skipped. The native revision check remains in place for writes
25+
from separate runtimes or processes.
26+
27+
Serialization is database scoped, so independent databases continue in
28+
parallel. Request delivery and readonly transaction behavior are unchanged.
29+
30+
## Focused gate
31+
32+
`webscene_native_indexeddb_contract` starts twelve overlapping read/write
33+
transactions after the initial database write. This exceeds the former
34+
four-retry ceiling and requires all fourteen records to be visible through the
35+
normal cursor path. The focused Release/V8 test passes in 1.31 seconds.
36+
37+
The final consumer gate is the exact packaged Code OSS MCP store, reload, and
38+
reopen sequence. It remains tracked by WebScene #269 and the Code OSS release
39+
acceptance epic.

‎experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_compatibility.h‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ inline constexpr std::string_view indexeddb_compatibility_source = R"JS(
1111
if (typeof nativeStorage !== 'function') return;
1212
const openConnections = new Map();
1313
const loadedDatabases = new Map();
14+
const writeTails = new Map();
1415
const clone = value => structuredClone(value);
1516
const failure = (message, name) => new DOMException(message, name);
1617
const event = (type, fields = {}) => Object.assign({
@@ -233,6 +234,7 @@ R"JS( class IDBTransaction extends EventTarget {
233234
this._active = true;
234235
this._pending = 0;
235236
this._finishScheduled = false;
237+
this._commitQueued = false;
236238
this._mutations = [];
237239
this._settled = versionchange ? new Promise((resolve, reject) => {
238240
this._settleResolve = resolve;
@@ -289,7 +291,23 @@ R"JS( class IDBTransaction extends EventTarget {
289291
else this._commit();
290292
}, 0);
291293
}
292-
async _commit(attempt = 0) {
294+
async _commit() {
295+
if (this._commitQueued) return;
296+
this._commitQueued = true;
297+
const name = this.db.name;
298+
const previous = writeTails.get(name) ?? Promise.resolve();
299+
let release;
300+
const current = new Promise(resolve => { release = resolve; });
301+
writeTails.set(name, current);
302+
await previous.catch(() => {});
303+
try {
304+
if (this._active) await this._commitNow();
305+
} finally {
306+
release();
307+
if (writeTails.get(name) === current) writeTails.delete(name);
308+
}
309+
}
310+
async _commitNow(attempt = 0) {
293311
try {
294312
const revision = await nativeStorage(
295313
'store', this.db.name, this._revision, this._state);
@@ -321,7 +339,7 @@ R"JS( class IDBTransaction extends EventTarget {
321339
}
322340
this._state = state;
323341
this._revision = latest.revision;
324-
return this._commit(attempt + 1);
342+
return this._commitNow(attempt + 1);
325343
} catch (reloadError) { error = reloadError; }
326344
}
327345
this._fail(error);

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,16 @@ void test_indexeddb_persistence_and_transaction_contract()
388388
write.objectStore('ItemTable').put('second', 'beta');
389389
await transactionDone(write);
390390
391+
stage = 'concurrent-writes';
392+
const concurrentWrites = [];
393+
for (let index = 0; index < 12; ++index) {
394+
const transaction = database.transaction('ItemTable', 'readwrite');
395+
transaction.objectStore('ItemTable').put(
396+
`value-${index}`, `parallel-${index}`);
397+
concurrentWrites.push(transactionDone(transaction));
398+
}
399+
await Promise.all(concurrentWrites);
400+
391401
stage = 'abort-v1';
392402
const rolledBack = database.transaction('ItemTable', 'readwrite');
393403
rolledBack.objectStore('ItemTable').put('must-not-commit', 'alpha');
@@ -441,7 +451,9 @@ void test_indexeddb_persistence_and_transaction_contract()
441451
"indexedDbProbe.upgradeOld===0&&indexedDbProbe.abortName==='AbortError'"
442452
"&&indexedDbProbe.versionChange==='1->2'&&indexedDbProbe.version===2"
443453
"&&indexedDbProbe.stores.join(',')==='ItemTable,SecondTable'"
444-
"&&indexedDbProbe.cursorValues.length===2"
454+
"&&indexedDbProbe.cursorValues.length===14"
455+
"&&indexedDbProbe.cursorValues.filter(item=>"
456+
"item[0].startsWith('parallel-')).length===12"
445457
"&&indexedDbProbe.cursorValues[0][0]==='alpha'"
446458
"&&indexedDbProbe.cursorValues[0][1].nested.enabled"
447459
"&&indexedDbProbe.cursorValues[0][1].bytes[2]===255"

0 commit comments

Comments
 (0)