LibWeb: Implement IndexedDB transaction dependency ordering

This fixes the regression in idbindex_reverse_cursor.any.html, which
was actually exposing the underlying issue of ignoring conflicting
read/write transactions. Now, if a read/write transaction is in the
queue, no transactions can coincide with its requests' execution.
This commit is contained in:
Zaggy1024 2026-03-05 02:27:40 -06:00 committed by Gregory Bertilson
parent 3c24a394c6
commit ea96072fee
7 changed files with 129 additions and 5 deletions

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/AnyOf.h>
#include <LibWeb/Bindings/IDBDatabasePrototype.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Crypto/Crypto.h>
@ -247,6 +248,8 @@ WebIDL::ExceptionOr<GC::Ref<IDBTransaction>> IDBDatabase::transaction(Variant<St
// 8. Set transactions cleanup event loop to the current event loop.
transaction->set_cleanup_event_loop(HTML::main_thread_event_loop());
block_on_conflicting_transactions(transaction);
// 9. Return an IDBTransaction object representing transaction.
return transaction;
}
@ -299,4 +302,45 @@ void IDBDatabase::check_pending_transaction_waits()
}
}
// https://w3c.github.io/IndexedDB/#transaction-scheduling
void IDBDatabase::block_on_conflicting_transactions(GC::Ref<IDBTransaction> transaction)
{
// The following constraints define when a transaction can be started:
// - A read-only transactions tx can start when there are no read/write transactions which:
// - A read/write transaction tx can start when there are no transactions which:
Vector<GC::Ref<IDBTransaction>> blocking;
for (auto const& other : m_transactions) {
// - Were created before tx; and
if (other.ptr() == transaction.ptr())
break;
// NB: According to the above conditions, we only block on transactions if one is read/write.
if (transaction->is_readonly() && other->is_readonly())
continue;
// - have overlapping scopes with tx; and
bool have_overlapping_scopes = any_of(transaction->scope(), [&](auto const& store) {
return other->scope().contains_slow(store);
});
if (!have_overlapping_scopes)
continue;
// - are not finished.
if (other->is_finished())
continue;
blocking.append(other);
}
if (blocking.is_empty())
return;
transaction->request_list().block_execution();
wait_for_transactions_to_finish(blocking, GC::create_function(realm().heap(), [transaction] {
transaction->request_list().unblock_execution();
}));
}
}

View file

@ -82,6 +82,7 @@ public:
void wait_for_transactions_to_finish(ReadonlySpan<GC::Ref<IDBTransaction>>, GC::Ref<GC::Function<void()>> on_complete);
void check_pending_transaction_waits();
void block_on_conflicting_transactions(GC::Ref<IDBTransaction>);
protected:
explicit IDBDatabase(JS::Realm&, Database&);

View file

@ -18,6 +18,9 @@ void RequestList::enqueue(GC::Ref<IDBRequest> request, GC::Ref<GC::Function<void
void RequestList::maybe_process_next_request()
{
if (m_blocked)
return;
while (!m_entries.is_empty() && !m_entries.first().request)
m_entries.remove(0);
@ -76,6 +79,12 @@ void RequestList::check_all_processed()
callback->function()();
}
void RequestList::unblock_execution()
{
m_blocked = false;
maybe_process_next_request();
}
GC::Ref<IDBRequest> RequestList::RequestIterator::operator*() const
{
return *m_entries[m_index].request;

View file

@ -27,8 +27,12 @@ public:
bool is_empty() const;
void block_execution() { m_blocked = true; }
void unblock_execution();
void set_on_all_processed(GC::Ref<GC::Function<void()>> callback);
void check_all_processed();
void maybe_process_next_request();
private:
struct Entry {
@ -36,10 +40,9 @@ private:
GC::Root<GC::Function<void()>> steps;
};
void maybe_process_next_request();
Vector<Entry> m_entries;
GC::Root<GC::Function<void()>> m_on_all_processed;
bool m_blocked { false };
public:
class RequestIterator {

View file

@ -0,0 +1,7 @@
txn1 complete
rw+rw: val=2
txn2 complete
rw+ro: val=3
txn3 complete
ro+rw: val=4
txn4 complete

View file

@ -2,7 +2,6 @@ Harness status: OK
Found 2 tests
1 Pass
1 Fail
2 Pass
Pass Reverse cursor sees update from separate transactions.
Fail Reverse cursor sees in-transaction update.
Pass Reverse cursor sees in-transaction update.

View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<script src="include.js"></script>
<script>
asyncTest(done => {
setTimeout(() => {
spoofCurrentURL("https://example.com/txn-scheduling.html");
const dbName = "test-txn-scheduling";
const delReq = indexedDB.deleteDatabase(dbName);
delReq.onsuccess = function() {
const openReq = indexedDB.open(dbName, 1);
openReq.onupgradeneeded = function(e) {
e.target.result.createObjectStore('store', {keyPath: 'key'});
};
openReq.onsuccess = function(e) {
const db = e.target.result;
let log = [];
const print = (msg) => { log.push(msg); };
// Case 1: readwrite then readwrite (overlapping scope)
// txn2 must not start until txn1 finishes.
const txn1 = db.transaction('store', 'readwrite');
txn1.objectStore('store').put({key: 'a', val: 1});
txn1.objectStore('store').put({key: 'a', val: 2});
const txn2 = db.transaction('store', 'readwrite');
// This get runs AFTER txn1 commits, so it must see val:2.
const getReq = txn2.objectStore('store').get('a');
getReq.onsuccess = () => print("rw+rw: val=" + getReq.result.val);
txn2.objectStore('store').put({key: 'a', val: 3});
// Case 2: readwrite then readonly (overlapping scope)
// txn3 must not start until txn2 finishes.
const txn3 = db.transaction('store', 'readonly');
const getReq2 = txn3.objectStore('store').get('a');
getReq2.onsuccess = () => print("rw+ro: val=" + getReq2.result.val);
// Case 3: readonly then readwrite (overlapping scope)
// txn4 must not start until txn3 finishes (though txn3 is readonly,
// a subsequent readwrite cannot overlap with it).
const txn4 = db.transaction('store', 'readwrite');
txn4.objectStore('store').put({key: 'a', val: 4});
const getReq3 = txn4.objectStore('store').get('a');
getReq3.onsuccess = () => print("ro+rw: val=" + getReq3.result.val);
txn1.oncomplete = () => print("txn1 complete");
txn2.oncomplete = () => print("txn2 complete");
txn3.oncomplete = () => print("txn3 complete");
txn4.oncomplete = () => {
print("txn4 complete");
for (const msg of log)
println(msg);
db.close();
indexedDB.deleteDatabase(dbName);
done();
};
};
};
}, 0);
});
</script>