idb: improve indexeddb memory model

Rather than having everything tied to the page's memory (arena, factory), most
things are now tied to an IDBTransaction and its arena. The IDBTransaction is
reference counted and finalized with v8. The lifecycle is relatively complicated
compared to anything else we have, which is a concern, but using the page arena
seems like a dealbreaker to me.

Any "child" created for the transaction (e.g. IDBRequest) has its v8 acquireRef
and releaseRef forwarded to the Transaction. This should make v8's usage safe.
However, in addition to this, Zig itself must take a RC whenever a drain is
scheduled AND whenever the transaction is parked in the engine. And these
things, especially on cleanup, can be a little messy.

The _awful_ cursor allocations have been improved by a local re-used ArrayLists
for the key/primary key/value. So rather than accumulating _every_ allocation
we now only capture the peak.

One final memory-related area this commit addresses is the lifetime difference
between Transactions (Page) and Engine (Session). This is problematic because
the Engine can reference the Transaction. On js.Context deinit, the engine is
notified and all related Transactions are canceled/removed. This is...unusual
in our design. We have other similar cases that use a list on the frame/GWS to
track resources to cleanup. But, the Engine already _has_ to have this list so
rather than book-keeping in Engine AND Frame AND WGS, only Engine has a list at
the cost of js.Context having to notify it on teardown.
This commit is contained in:
Karl Seguin
2026-07-02 18:30:47 +08:00
parent 73b5386d26
commit 0bb6291ad0
16 changed files with 748 additions and 226 deletions

View File

@@ -207,6 +207,10 @@ pub fn deinit(self: *Context) void {
const env = self.env;
defer env.app.arena_pool.release(self.arena);
// Unlink any IndexedDB gate participants first: the session-scoped engine
// must never wake a waiter into this scheduler once it's torn down.
self.page.session.idb.detachContext(self);
var hs: js.HandleScope = undefined;
const entered = self.enter(&hs);
defer entered.exit();

View File

@@ -629,6 +629,16 @@ pub fn temp(self: Value) !Temp {
return self._persist(false);
}
// Like persist(), but not tracked on the context: the caller owns the handle
// and must deinit (Reset) it. A reset is only idempotent through the same
// instance — copies alias one v8 slot, so keep a single canonical instance and
// reset through it.
pub fn bare(self: Value) BareGlobal {
var global: v8.Global = undefined;
v8.v8__Global__New(self.local.ctx.isolate.handle, self.handle, &global);
return .{ .handle = global, .temps = {} };
}
fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
@@ -688,10 +698,12 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
pub const Temp = G(.temp);
pub const Global = G(.global);
pub const BareGlobal = G(.bare);
const GlobalType = enum(u8) {
temp,
global,
bare,
};
fn G(comptime global_type: GlobalType) type {

View File

@@ -1153,4 +1153,40 @@
await state.done((got) => testing.expectEqual("v", got));
}
</script>
<!-- Per spec, objectStore(name) and index(name) return the same instance for
the same name within one transaction, including a store created during the
upgrade it's requested in. -->
<script id="same_object_store_and_index" type=module>
{
const state = await testing.async();
const open = indexedDB.open("same-object-db", 1);
let upgrade = null;
open.onupgradeneeded = (e) => {
const db = e.target.result;
const tx = e.target.transaction;
const store = db.createObjectStore("s");
const index = store.createIndex("by_x", "x");
upgrade = {
store: tx.objectStore("s") === store,
index: store.index("by_x") === index,
};
};
open.onsuccess = (e) => {
const tx = e.target.result.transaction(["s", "s"], "readonly");
const store = tx.objectStore("s");
state.resolve({
upgrade,
store: tx.objectStore("s") === store,
index: store.index("by_x") === store.index("by_x"),
});
};
await state.done((got) => {
testing.expectEqual(true, got.upgrade.store);
testing.expectEqual(true, got.upgrade.index);
testing.expectEqual(true, got.store);
testing.expectEqual(true, got.index);
});
}
</script>
</body>

View File

@@ -82,7 +82,8 @@ pub fn postMessage(self: *DedicatedWorkerGlobalScope, data: js.Value) !void {
}
pub fn close(self: *DedicatedWorkerGlobalScope) void {
// TOOD: we should also stop new tasks from being scheduled
// TODO: we should also stop new tasks from being scheduled
self._proto._session.idb.detachContext(self._proto.js);
self._proto.js.scheduler.reset();
self._closed = true;
}

View File

@@ -682,6 +682,7 @@ pub fn close(self: *Window) void {
}
}
page.session.idb.detachContext(frame.js);
frame.js.scheduler.reset();
frame.abortTransfers();

View File

@@ -34,22 +34,32 @@ conn: Sqlite.Conn,
// (intrusive, caller-owned) node on `_gate_waiters` and are handed ownership in
// FIFO order as it's released.
//
// LIFETIME GAP (to resolve in the IDB memory rework): the Engine is
// session-scoped but the waiters (IDBTransaction / Open+DeleteContext) are
// page-scoped, so these are the only session->page pointers in IDB. A waiter
// freed on navigation while still owning or parked here leaves a dangling
// pointer/node that a later same-origin page can deref or deadlock on; a parked
// waiter has no scheduler task and so no finalizer to unlink it on teardown. The
// rework must session-scope these objects, add a page-teardown unlink, or drop
// the wait-list for a bool gate.
// The Engine is session-scoped but the waiters (IDBTransaction /
// Open+DeleteContext) are context-scoped, so these are the only session->page
// pointers in IDB. Every participant tags its waiter with its js Context;
// detach() must be called before that context's scheduler is reset or torn
// down, otherwise a parked waiter dangles here and a later wake would schedule
// onto a dead scheduler.
_gate_owner: ?*GateWaiter = null,
_gate_waiters: std.DoublyLinkedList = .{},
pub const GateWaiter = struct {
node: std.DoublyLinkedList.Node = .{},
// A bit unusual. Normally, if we need to tear something down when the frame
// is destroyed, we track that on the frame (see Frame._file_lists). And
// we _could_ do that here too, but the Engine still needs the list, so that
// would require book-keeping in 2 lists. Instead, Engine owns the list and
// we track the *js.Context (because it needs to work for both WGS and
// frames). On context teardown, we can iterate the list and remove any
// waiters associated with that context.
ctx: *anyopaque,
// Called when this waiter is handed the gate; typically reschedules the
// owner's task so it re-runs and finds itself the owner.
wake: *const fn (waiter: *GateWaiter) void,
// Called when detach() removes this waiter because its context is going away.
cancel: *const fn (waiter: *GateWaiter) void,
};
pub fn acquireGate(self: *Engine, waiter: *GateWaiter) bool {
@@ -68,18 +78,39 @@ pub fn acquireGate(self: *Engine, waiter: *GateWaiter) bool {
// Release the gate held by `waiter`, handing it directly to the next parked
// waiter (no window where an unrelated contender can grab it) and waking it.
// No-op if `waiter` isn't the current owner.
pub fn releaseGate(self: *Engine, waiter: *GateWaiter) void {
pub fn releaseGate(self: *Engine, waiter: *GateWaiter) bool {
if (self._gate_owner != waiter) {
return;
return false;
}
if (self._gate_waiters.popFirst()) |node| {
const next: *GateWaiter = @fieldParentPtr("node", node);
self._gate_owner = next;
next.wake(next);
return;
return true;
}
self._gate_owner = null;
return true;
}
// A js Context is being torn down: remove every waiter associated with it.
pub fn detach(self: *Engine, ctx: *anyopaque) void {
var node = self._gate_waiters.first;
while (node) |n| {
const next = n.next;
const waiter: *GateWaiter = @fieldParentPtr("node", n);
if (waiter.ctx == ctx) {
self._gate_waiters.remove(n);
waiter.cancel(waiter);
}
node = next;
}
const owner = self._gate_owner orelse return;
if (owner.ctx != ctx) {
return;
}
owner.cancel(owner);
_ = self.releaseGate(owner);
}
pub fn open(path: [:0]const u8) !Engine {
@@ -807,6 +838,60 @@ test "IDB - Engine: getRange/getKeyRange first-in-range and deleteRange" {
try testing.expectEqual(3, try engine.countRange(store_id, Engine.Bounds.unbounded()));
}
test "IDB - Engine: detach unlinks parked waiters and hands off an owned gate" {
var engine = try Engine.open(":memory:");
defer engine.close();
const TestWaiter = struct {
waiter: Engine.GateWaiter,
woken: u32 = 0,
cancelled: u32 = 0,
fn init(ctx: *anyopaque) @This() {
return .{ .waiter = .{ .ctx = ctx, .wake = wake, .cancel = cancel } };
}
fn wake(w: *Engine.GateWaiter) void {
const self: *@This() = @fieldParentPtr("waiter", w);
self.woken += 1;
}
fn cancel(w: *Engine.GateWaiter) void {
const self: *@This() = @fieldParentPtr("waiter", w);
self.cancelled += 1;
}
};
var ctx_a: u8 = 0;
var ctx_b: u8 = 0;
// a1 owns; a2 and b1 park behind it.
var a1 = TestWaiter.init(&ctx_a);
var a2 = TestWaiter.init(&ctx_a);
var b1 = TestWaiter.init(&ctx_b);
try testing.expectEqual(true, engine.acquireGate(&a1.waiter));
try testing.expectEqual(false, engine.acquireGate(&a2.waiter));
try testing.expectEqual(false, engine.acquireGate(&b1.waiter));
// Detaching context A cancels both its waiters (owner + parked, no wakes
// for either) and hands the gate straight to b1.
engine.detach(&ctx_a);
try testing.expectEqual(1, a1.cancelled);
try testing.expectEqual(0, a1.woken);
try testing.expectEqual(1, a2.cancelled);
try testing.expectEqual(0, a2.woken);
try testing.expectEqual(0, b1.cancelled);
try testing.expectEqual(1, b1.woken);
try testing.expect(engine._gate_owner == &b1.waiter);
// Detaching a context with no participants is a no-op.
engine.detach(&ctx_a);
try testing.expect(engine._gate_owner == &b1.waiter);
// releaseGate reports whether the caller actually owned the gate.
try testing.expectEqual(false, engine.releaseGate(&a1.waiter));
try testing.expectEqual(true, engine.releaseGate(&b1.waiter));
try testing.expectEqual(null, engine._gate_owner);
}
test "IDB - Engine: open creates schema" {
var engine = try Engine.open(":memory:");
defer engine.close();

View File

@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Key = @import("Key.zig");
const Engine = @import("Engine.zig");
@@ -49,7 +50,7 @@ _index_id: ?i64 = null,
// the JS value of this Cursor, pre-converted and cached as an optimization
// since this cursor will be the request value on every iteration.
_js: js.Value.Global,
_js: *js.Value.BareGlobal,
// Encoded current key; null before iteration and at the end. For an index cursor
// this is the index key; for an object store it equals the primary key.
@@ -60,6 +61,14 @@ _primary_key: ?[]const u8 = null,
// Current record's serialized value bytes (null when key-only or exhausted).
_value: ?[]const u8 = null,
// Backing storage for _key/_primary_key/_value, reused across positions so a
// long scan holds one record's worth of memory, not the whole traversal.
// Anything that must survive a reposition (update/delete/continue snapshots)
// is duped onto the transaction's arena instead.
_key_buf: std.ArrayList(u8) = .empty,
_pk_buf: std.ArrayList(u8) = .empty,
_val_buf: std.ArrayList(u8) = .empty,
pub const Direction = enum {
next,
nextunique,
@@ -96,23 +105,22 @@ fn startSentinel(reverse: bool) []const u8 {
}
// Cursor over an object store.
pub fn open(store: *IDBObjectStore, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
const txn = store._txn orelse return error.TransactionInactiveError;
return create(store, txn, null, .{ .store = store }, bounds, direction, key_only, exec);
pub fn init(store: *IDBObjectStore, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
return _init(store, store._txn, null, .{ .store = store }, bounds, direction, key_only, exec);
}
// Cursor over an index (key = index key, primaryKey = store key).
pub fn openIndex(index: *IDBIndex, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
pub fn initIndex(index: *IDBIndex, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
const store = index._store;
const txn = store._txn orelse return error.TransactionInactiveError;
return create(store, txn, index._index_id, .{ .index = index }, bounds, direction, key_only, exec);
return _init(store, store._txn, index._index_id, .{ .index = index }, bounds, direction, key_only, exec);
}
fn create(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source: Source, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
fn _init(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source: Source, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest {
try txn.assertActive();
const request = try txn.newRequest();
const self = try exec._factory.create(IDBCursor{
const self = try txn._arena.create(IDBCursor);
self.* = .{
._engine = store._engine,
._store = store,
._txn = txn,
@@ -123,29 +131,30 @@ fn create(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source:
._index_id = index_id,
._js = undefined,
._source = source,
});
};
request._cursor = self;
const local = exec.js.local.?;
const public: js.Value = if (key_only)
try local.zigValueToJs(self, .{})
else
try local.zigValueToJs(try IDBCursorWithValue.init(self, exec), .{});
try local.zigValueToJs(try IDBCursorWithValue.init(self), .{});
// Pre-converted and cached because it's the request value on every iteration,
// avoiding the bridge's pointer -> js.Value lookup each time.
self._js = try public.persist();
self._js = try txn.persist(public);
// The first seek runs in the drain (or immediately, for a versionchange txn),
// not here — so the connection is only touched while the txn owns it.
return request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = .first, .offset = 0 } }, exec);
}
// Re-arm the cursor's existing request with its next seek and requeue it. Called by
// continue/advance from within a success handler; the drain loop picks the requeued
// request up in the same pass (a versionchange cursor runs it now).
fn reiterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void {
_ = try self._request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = seek, .offset = offset } }, exec);
pub fn acquireRef(self: *IDBCursor) void {
self._txn.acquireRef();
}
pub fn releaseRef(self: *IDBCursor, page: *Page) void {
self._txn.releaseRef(page);
}
// Run the deferred seek, staging the positioned cursor (or null) on the request.
@@ -165,7 +174,7 @@ pub fn @"continue"(self: *IDBCursor, key_arg: ?js.Value, exec: *Execution) !void
try self.prepareIterate();
if (key_arg) |k| {
const encoded = try Key.encodeValue(exec.arena, k);
const encoded = try Key.encodeValue(self._txn._arena, k);
// The target must move past the current key in the iteration direction.
const order = std.mem.order(u8, encoded, self._key.?);
if (if (self._direction.reverse()) order != .lt else order != .gt) {
@@ -185,8 +194,8 @@ pub fn continuePrimaryKey(self: *IDBCursor, key_arg: js.Value, primary_key_arg:
try self.prepareIterate();
const reverse = self._direction.reverse();
const key = try Key.encodeValue(exec.arena, key_arg);
const primary_key = try Key.encodeValue(exec.arena, primary_key_arg);
const key = try Key.encodeValue(self._txn._arena, key_arg);
const primary_key = try Key.encodeValue(self._txn._arena, primary_key_arg);
// The (key, primaryKey) pair must move past the current position.
const ok = switch (std.mem.order(u8, key, self._key.?)) {
@@ -226,25 +235,28 @@ pub fn update(self: *IDBCursor, value: js.Value, exec: *Execution) !*IDBRequest
}
// The record sits at the primary (store) key, even for an index cursor.
const key = self._primary_key orelse return error.InvalidStateError;
const current_key = self._primary_key orelse return error.InvalidStateError;
// For an in-line store, the value's own key must match the record's key.
if (self._store._key_path) |kp| {
const extracted = Key.evaluatePath(value, kp) orelse return error.DataError;
const encoded = try Key.encodeValue(exec.call_arena, extracted);
if (!std.mem.eql(u8, encoded, key)) {
if (!std.mem.eql(u8, encoded, current_key)) {
return error.DataError;
}
}
// Snapshot the record key: the write runs in the drain, by which point a
// `continue` could have moved the cursor's live position on.
// `continue` could have moved the cursor's live position (and reused its
// key buffer).
const key = try self._txn._arena.dupe(u8, current_key);
const request = try self._txn.newRequest();
const value_global = try value.persist();
const value_global = try self._txn.persist(value);
return request.submit(.{ .cursor_update = .{ .cursor = self, .key = key, .value = value_global } }, exec);
}
pub fn runUpdate(self: *IDBCursor, request: *IDBRequest, key: []const u8, value_global: js.Value.Global, exec: *Execution) !void {
pub fn runUpdate(self: *IDBCursor, request: *IDBRequest, key: []const u8, value_global: *js.Value.BareGlobal, exec: *Execution) !void {
defer value_global.deinit();
const local = exec.js.local.?;
const value = value_global.local(local);
@@ -276,7 +288,8 @@ pub fn delete(self: *IDBCursor, exec: *Execution) !*IDBRequest {
}
// Snapshot the record key (see update): the delete runs later, in the drain.
const key = self._primary_key orelse return error.InvalidStateError;
const current_key = self._primary_key orelse return error.InvalidStateError;
const key = try self._txn._arena.dupe(u8, current_key);
const request = try self._txn.newRequest();
return request.submit(.{ .cursor_delete = .{ .cursor = self, .key = key } }, exec);
}
@@ -307,11 +320,12 @@ pub fn getSource(self: *const IDBCursor) Source {
return self._source;
}
// Seek the next record and stage it as the request result. The keys/value live on
// the page arena (they must survive across event-loop turns within the txn).
// Seek the next record and stage it as the request result. The engine dupes
// the row onto the per-call scratch arena; position() then copies it into the
// cursor's reused buffers (which must survive across event-loop turns).
fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void {
const reverse = self._direction.reverse();
const arena = exec.arena;
const arena = exec.call_arena;
const store_id = self._store._store_id;
if (self._index_id) |index_id| {
@@ -322,7 +336,7 @@ fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void {
.to_primary => |tp| .{ tp.key, tp.primary_key, true },
};
const rec = try self._engine.indexCursorSeek(arena, store_id, index_id, self._bounds, reverse, from_key, from_pk, pk_inclusive, !self._key_only, offset);
if (rec) |r| self.position(r.key, r.primary_key, r.value) else self.exhaust();
if (rec) |r| try self.position(r.key, r.primary_key, r.value) else self.exhaust();
} else {
const from_op, const from_key = switch (seek) {
.first => .{ if (reverse) "<= " else ">= ", startSentinel(reverse) },
@@ -332,14 +346,35 @@ fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void {
};
const rec = try self._engine.cursorSeek(arena, store_id, self._bounds, reverse, from_op, from_key, offset, !self._key_only);
// For an object store the key is the primary key.
if (rec) |r| self.position(r.key, r.key, r.value) else self.exhaust();
if (rec) |r| try self.position(r.key, r.key, r.value) else self.exhaust();
}
}
fn position(self: *IDBCursor, key: []const u8, primary_key: []const u8, value: ?[]const u8) void {
self._key = key;
self._primary_key = primary_key;
self._value = value;
// Re-arm the cursor's existing request with its next seek and requeue it. Called by
// continue/advance from within a success handler; the drain loop picks the requeued
// request up in the same pass (a versionchange cursor runs it now).
fn reiterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void {
_ = try self._request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = seek, .offset = offset } }, exec);
}
fn position(self: *IDBCursor, key: []const u8, primary_key: []const u8, value: ?[]const u8) !void {
const arena = self._txn._arena;
self._key_buf.clearRetainingCapacity();
try self._key_buf.appendSlice(arena, key);
self._key = self._key_buf.items;
self._pk_buf.clearRetainingCapacity();
try self._pk_buf.appendSlice(arena, primary_key);
self._primary_key = self._pk_buf.items;
if (value) |v| {
self._val_buf.clearRetainingCapacity();
try self._val_buf.appendSlice(arena, v);
self._value = self._val_buf.items;
} else {
self._value = null;
}
self._request.setValueGlobal(self._js);
}

View File

@@ -26,8 +26,10 @@ const IDBCursorWithValue = @This();
_proto: *IDBCursor,
pub fn init(cursor: *IDBCursor, exec: *Execution) !*IDBCursorWithValue {
return exec._factory.create(IDBCursorWithValue{ ._proto = cursor });
pub fn init(cursor: *IDBCursor) !*IDBCursorWithValue {
const self = try cursor._txn._arena.create(IDBCursorWithValue);
self.* = .{ ._proto = cursor };
return self;
}
pub fn getValue(self: *const IDBCursorWithValue, exec: *Execution) !?js.Value {

View File

@@ -66,7 +66,6 @@ pub fn createObjectStore(
self: *IDBDatabase,
name: []const u8,
options: ?CreateObjectStoreOptions,
exec: *Execution,
) !*IDBObjectStore {
const txn = self._txn orelse return error.InvalidStateError;
@@ -81,17 +80,18 @@ pub fn createObjectStore(
else => return err,
};
const owned_name = try exec.dupeString(name);
const key_path = if (opts.keyPath) |kp| try exec.dupeString(kp) else null;
return IDBObjectStore.init(self._engine, txn, store_id, owned_name, key_path, opts.autoIncrement, exec);
const owned_name = try txn.dupe(name);
const key_path = if (opts.keyPath) |kp| try txn.dupe(kp) else null;
const store = try IDBObjectStore.init(txn, store_id, owned_name, key_path, opts.autoIncrement);
try txn.cacheStore(store);
return store;
}
// Only callable during upgradeneeded, hence the _txn check
pub fn deleteObjectStore(self: *IDBDatabase, name: []const u8, _: *Execution) !void {
if (self._txn == null) {
return error.InvalidStateError;
}
return self._engine.deleteObjectStore(self._database_id, name);
const txn = self._txn orelse return error.InvalidStateError;
try self._engine.deleteObjectStore(self._database_id, name);
txn.uncacheStore(name);
}
const TransactionMode = enum {
@@ -116,32 +116,44 @@ pub fn transaction(
options: ?TransactionOptions,
exec: *Execution,
) !*IDBTransaction {
const scope = try normalizeStoreNames(store_names, exec.arena);
const opts = options orelse TransactionOptions{};
return IDBTransaction.init(exec, self, switch (mode orelse .readonly) {
const txn = try IDBTransaction.init(self, switch (mode orelse .readonly) {
.readonly => .readonly,
.readwrite => .readwrite,
}, opts.durability, scope);
}, opts.durability, exec);
txn._scope = try normalizeStoreNames(txn._arena, store_names);
return txn;
}
// The transaction's scope: the requested store names, sorted with duplicates
// removed (per the IndexedDB spec's "transaction scope" steps).
fn normalizeStoreNames(store_names: StoreNames, arena: Allocator) ![]const []const u8 {
fn normalizeStoreNames(arena: Allocator, store_names: StoreNames) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
switch (store_names) {
.name => |name| try list.append(arena, try arena.dupe(u8, name)),
.names => |names| {
try list.ensureUnusedCapacity(arena, names.len);
for (names) |name| list.appendAssumeCapacity(try arena.dupe(u8, name));
for (names) |name| {
list.appendAssumeCapacity(try arena.dupe(u8, name));
}
},
}
std.mem.sort([]const u8, list.items, {}, lessThan);
std.mem.sort([]const u8, list.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
// Drop duplicates now that equal names are adjacent.
var write: usize = 0;
for (list.items, 0..) |name, read| {
if (read == 0 or !std.mem.eql(u8, name, list.items[write - 1])) {
// Drop duplicates now that equal names are adjacent. The first name always
// survives, so only the rest need comparing.
if (list.items.len <= 1) {
return list.items;
}
var write: usize = 1;
for (list.items[1..]) |name| {
if (!std.mem.eql(u8, name, list.items[write - 1])) {
list.items[write] = name;
write += 1;
}
@@ -149,10 +161,6 @@ fn normalizeStoreNames(store_names: StoreNames, arena: Allocator) ![]const []con
return list.items[0..write];
}
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
pub fn close(_: *IDBDatabase) void {
// Connections are pooled on the Manager and shared across handles, so a
// single handle's close() is a no-op for the bare slice.

View File

@@ -52,7 +52,7 @@ pub fn open(_: *IDBFactory, name: []const u8, version: ?u64, exec: *Execution) !
.name = try exec.dupeString(name),
.version = version,
.exec = exec,
._gate_waiter = .{ .wake = OpenContext.wakeUp },
._gate_waiter = .{ .ctx = exec.js, .wake = OpenContext.wakeUp, .cancel = OpenContext.cancelParked },
});
try exec.js.scheduler.add(ctx, OpenContext.run, 0, .{
@@ -69,14 +69,30 @@ const OpenContext = struct {
exec: *Execution,
// Our node in the engine's connection gate wait-list. See Engine.acquireGate.
_gate_waiter: Engine.GateWaiter,
// Whether a scheduler task currently points at us; its finalizer owns our
// destruction then. When parked on the gate instead, cancelParked owns it.
_scheduled: bool = true,
fn cancelled(ctx: *anyopaque) void {
// What if we're gated? Well, A scheduled task is only canceled on
// teardown, which would have already called Engine.detach(js_ctx).
const self: *OpenContext = @ptrCast(@alignCast(ctx));
self.exec._factory.destroy(self);
}
// Engine.detach cancel: our context is going away while we sit on the
// gate. When parked there's no scheduler task, so we own our destruction;
// in the wake->run window the task finalizer does.
fn cancelParked(waiter: *Engine.GateWaiter) void {
const self: *OpenContext = @fieldParentPtr("_gate_waiter", waiter);
if (!self._scheduled) {
self.exec._factory.destroy(self);
}
}
fn run(ctx: *anyopaque) !?u32 {
const self: *OpenContext = @ptrCast(@alignCast(ctx));
self._scheduled = false;
const engine = self.resolveEngine() catch |err| {
self.exec._factory.destroy(self);
@@ -92,7 +108,7 @@ const OpenContext = struct {
return null; // parked; not destroyed
}
defer self.exec._factory.destroy(self);
defer engine.releaseGate(&self._gate_waiter);
defer _ = engine.releaseGate(&self._gate_waiter);
self.runOpen(engine) catch |err| {
log.warn(.storage, "idb open", .{ .err = err, .name = self.name });
@@ -111,9 +127,11 @@ const OpenContext = struct {
}) catch |err| {
// We were handed the gate; if we can't reschedule, hand it off so the
// waiters behind us aren't stranded.
if (self.resolveEngine()) |engine| engine.releaseGate(&self._gate_waiter) else |_| {}
log.warn(.storage, "idb resume open", .{ .err = err });
if (self.resolveEngine()) |engine| _ = engine.releaseGate(&self._gate_waiter) else |_| {}
self.exec._factory.destroy(self);
};
self._scheduled = true;
}
fn resolveEngine(self: *OpenContext) !*Engine {
@@ -161,8 +179,15 @@ const OpenContext = struct {
const db = try IDBDatabase.init(exec, engine, database_id, self.name, requested);
self.request.setDatabaseResult(db);
const txn = try IDBTransaction.initVersionChange(exec, db);
self.request._txn = txn;
const txn = try IDBTransaction.initVersionChange(db, exec);
txn.acquireRef();
defer txn.releaseRef(exec.page);
self.request._txn = .{ .borrowed = txn };
// The request is page-scoped and must never outlive this pointer; the
// success/abort paths below null it before delivering, this covers the
// error paths.
defer self.request._txn = .none;
{
db._txn = txn;
@@ -175,14 +200,14 @@ const OpenContext = struct {
// updateneeded handler called abort() (what a jerk!) — abort() already
// rolled back.
closed = true;
self.request._txn = null;
self.request._txn = .none;
self.request.setError(error.AbortError);
return self.request.deliver(exec);
}
txn.settle(exec);
closed = true;
self.request._txn = null;
self.request._txn = .none;
return self.request.fireSuccess(exec);
}
};
@@ -199,7 +224,7 @@ pub fn deleteDatabase(_: *IDBFactory, name: []const u8, exec: *Execution) !*IDBR
.request = request,
.name = try exec.dupeString(name),
.exec = exec,
._gate_waiter = .{ .wake = DeleteContext.wakeUp },
._gate_waiter = .{ .ctx = exec.js, .wake = DeleteContext.wakeUp, .cancel = DeleteContext.cancelParked },
});
try exec.js.scheduler.add(ctx, DeleteContext.run, 0, .{
@@ -214,14 +239,27 @@ const DeleteContext = struct {
name: []const u8,
exec: *Execution,
_gate_waiter: Engine.GateWaiter,
// See OpenContext._scheduled.
_scheduled: bool = true,
fn cancelled(ctx: *anyopaque) void {
// What if we're gated? Well, A scheduled task is only canceled on
// teardown, which would have already called Engine.detach(js_ctx).
const self: *DeleteContext = @ptrCast(@alignCast(ctx));
self.exec._factory.destroy(self);
}
// See OpenContext.cancelParked.
fn cancelParked(waiter: *Engine.GateWaiter) void {
const self: *DeleteContext = @fieldParentPtr("_gate_waiter", waiter);
if (!self._scheduled) {
self.exec._factory.destroy(self);
}
}
fn run(ctx: *anyopaque) !?u32 {
const self: *DeleteContext = @ptrCast(@alignCast(ctx));
self._scheduled = false;
const engine = self.resolveEngine() catch |err| {
self.exec._factory.destroy(self);
@@ -234,7 +272,7 @@ const DeleteContext = struct {
return null; // parked; not destroyed
}
defer self.exec._factory.destroy(self);
defer engine.releaseGate(&self._gate_waiter);
defer _ = engine.releaseGate(&self._gate_waiter);
self.runDelete(engine) catch |err| {
log.warn(.storage, "idb deleteDatabase", .{ .err = err, .name = self.name });
@@ -253,9 +291,11 @@ const DeleteContext = struct {
}) catch |err| {
// We were handed the gate; if we can't reschedule, hand it off so the
// waiters behind us aren't stranded.
if (self.resolveEngine()) |engine| engine.releaseGate(&self._gate_waiter) else |_| {}
log.warn(.storage, "idb resume delete", .{ .err = err });
if (self.resolveEngine()) |engine| _ = engine.releaseGate(&self._gate_waiter) else |_| {}
self.exec._factory.destroy(self);
};
self._scheduled = true;
}
fn resolveEngine(self: *DeleteContext) !*Engine {

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Key = @import("Key.zig");
const Engine = @import("Engine.zig");
const IDBCursor = @import("IDBCursor.zig");
@@ -43,8 +44,9 @@ _key_path: []const u8,
_unique: bool,
_multi_entry: bool,
pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8, exec: *Execution) !*IDBIndex {
return exec._factory.create(IDBIndex{
pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8) !*IDBIndex {
const self = try obj_store._txn._arena.create(IDBIndex);
self.* = .{
._store = obj_store,
._engine = obj_store._engine,
._index_id = info.id,
@@ -52,18 +54,27 @@ pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8
._key_path = info.key_path,
._unique = info.unique,
._multi_entry = info.multi_entry,
});
};
return self;
}
pub fn acquireRef(self: *IDBIndex) void {
self._store._txn.acquireRef();
}
pub fn releaseRef(self: *IDBIndex, page: *Page) void {
self._store._txn.releaseRef(page);
}
fn txn(self: *IDBIndex) !*IDBTransaction {
const t = self._store._txn orelse return error.TransactionInactiveError;
const t = self._store._txn;
try t.assertActive();
return t;
}
pub fn get(self: *IDBIndex, query: js.Value, exec: *Execution) !*IDBRequest {
const t = try self.txn();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec);
const request = try t.newRequest();
return request.submit(.{ .index_get = .{ .index = self, .bounds = bounds } }, exec);
}
@@ -81,7 +92,7 @@ pub fn runGet(self: *IDBIndex, request: *IDBRequest, bounds: Engine.Bounds, exec
pub fn getKey(self: *IDBIndex, query: js.Value, exec: *Execution) !*IDBRequest {
const t = try self.txn();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec);
const request = try t.newRequest();
return request.submit(.{ .index_get_key = .{ .index = self, .bounds = bounds } }, exec);
}
@@ -107,14 +118,14 @@ pub fn getAllKeys(self: *IDBIndex, query_or_options: ?js.Value, count_: ?u32, ex
pub fn getAllRecords(self: *IDBIndex, options: ?js.Value, exec: *Execution) !*IDBRequest {
const t = try self.txn();
const args = try IDBKeyRange.resolveGetAllOptions(exec.arena, options, exec);
const args = try IDBKeyRange.resolveGetAllOptions(t._arena, options, exec);
const request = try t.newRequest();
return request.submit(.{ .index_get_all = .{ .index = self, .args = args, .mode = .record } }, exec);
}
fn _getAll(self: *IDBIndex, query_or_options: ?js.Value, count_: ?u32, mode: IDBObjectStore.GetAllMode, exec: *Execution) !*IDBRequest {
const t = try self.txn();
const args = try IDBKeyRange.resolveGetAll(exec.arena, query_or_options, count_, exec);
const args = try IDBKeyRange.resolveGetAll(t._arena, query_or_options, count_, exec);
const request = try t.newRequest();
return request.submit(.{ .index_get_all = .{ .index = self, .args = args, .mode = mode } }, exec);
}
@@ -141,24 +152,24 @@ fn collectAll(self: *IDBIndex, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStor
const arr = local.newArray(0);
var i: u32 = 0;
while (try rows.next()) |row| {
_ = try arr.set(i, try rowToValue(exec, mode, row.get([]const u8, 0), row.get([]const u8, 1), row.get([]const u8, 2)), .{});
_ = try arr.set(i, try self.rowToValue(mode, row.get([]const u8, 0), row.get([]const u8, 1), row.get([]const u8, 2), exec), .{});
i += 1;
}
return arr.toValue();
}
fn rowToValue(exec: *Execution, mode: IDBObjectStore.GetAllMode, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value {
fn rowToValue(self: *IDBIndex, mode: IDBObjectStore.GetAllMode, key: []const u8, primary_key: []const u8, value: []const u8, exec: *Execution) !js.Value {
const local = exec.js.local.?;
return switch (mode) {
.value => js.Value.deserialize(local, value),
.key => Key.decodeToJs(exec.call_arena, local, primary_key),
.record => IDBRecord.initValue(exec, local, key, primary_key, value),
.record => IDBRecord.initValue(self._store._txn, local, key, primary_key, value),
};
}
pub fn count(self: *IDBIndex, query: ?js.Value, exec: *Execution) !*IDBRequest {
const t = try self.txn();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec);
const request = try t.newRequest();
return request.submit(.{ .index_count = .{ .index = self, .bounds = bounds } }, exec);
}
@@ -173,13 +184,13 @@ pub fn runCount(self: *IDBIndex, request: *IDBRequest, bounds: Engine.Bounds, ex
}
pub fn openCursor(self: *IDBIndex, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest {
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
return IDBCursor.openIndex(self, bounds, direction orelse .next, false, exec);
const bounds = try IDBKeyRange.resolveQuery(self._store._txn._arena, query, exec);
return IDBCursor.initIndex(self, bounds, direction orelse .next, false, exec);
}
pub fn openKeyCursor(self: *IDBIndex, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest {
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
return IDBCursor.openIndex(self, bounds, direction orelse .next, true, exec);
const bounds = try IDBKeyRange.resolveQuery(self._store._txn._arena, query, exec);
return IDBCursor.initIndex(self, bounds, direction orelse .next, true, exec);
}
pub fn getName(self: *const IDBIndex) []const u8 {

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Key = @import("Key.zig");
const Engine = @import("Engine.zig");
const IDBIndex = @import("IDBIndex.zig");
@@ -42,26 +43,35 @@ _store_id: i64,
_name: []const u8,
_key_path: ?[]const u8,
_auto_increment: bool,
// only null during an upgradeneeded
_txn: ?*IDBTransaction,
_txn: *IDBTransaction,
// identity map, store.indexes('a') === store.index('a')
_indexes: std.ArrayList(*IDBIndex) = .empty,
pub fn init(
engine: *Engine,
txn: ?*IDBTransaction,
txn: *IDBTransaction,
store_id: i64,
name: []const u8,
key_path: ?[]const u8,
auto_increment: bool,
exec: *Execution,
) !*IDBObjectStore {
return exec._factory.create(IDBObjectStore{
._engine = engine,
const self = try txn._arena.create(IDBObjectStore);
self.* = .{
._txn = txn,
._store_id = store_id,
._name = name,
._engine = txn._engine,
._store_id = store_id,
._key_path = key_path,
._auto_increment = auto_increment,
});
};
return self;
}
pub fn acquireRef(self: *IDBObjectStore) void {
self._txn.acquireRef();
}
pub fn releaseRef(self: *IDBObjectStore, page: *Page) void {
self._txn.releaseRef(page);
}
pub fn add(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execution) !*IDBRequest {
@@ -73,18 +83,14 @@ pub fn put(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execut
}
pub fn get(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
try txn.assertActive();
// Bounds are captured for deferred execution, so they must outlive this call —
// the page arena, not the per-call scratch arena.
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_get = .{ .store = self, .bounds = bounds } }, exec);
}
pub fn runGet(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bounds, exec: *Execution) !void {
// The fetched bytes are deserialized here and then discarded, so the per-call
// scratch arena suffices for them.
const arena = exec.call_arena;
const bytes = self._engine.getRange(arena, self._store_id, bounds) catch |err| {
log.warn(.storage, "idb get", .{ .err = err });
@@ -96,12 +102,12 @@ pub fn runGet(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bounds
}
pub fn delete(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
if (txn._mode == .readonly) {
return error.ReadOnlyError;
}
try txn.assertActive();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_delete = .{ .store = self, .bounds = bounds } }, exec);
}
@@ -119,7 +125,7 @@ fn deleteBounds(self: *IDBObjectStore, bounds: Engine.Bounds) !void {
}
pub fn clear(self: *IDBObjectStore, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
if (txn._mode == .readonly) {
return error.ReadOnlyError;
}
@@ -141,9 +147,9 @@ fn clearAll(self: *IDBObjectStore) !void {
}
pub fn count(self: *IDBObjectStore, query: ?js.Value, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
try txn.assertActive();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_count = .{ .store = self, .bounds = bounds } }, exec);
}
@@ -169,17 +175,17 @@ pub fn getAllKeys(self: *IDBObjectStore, query_or_options: ?js.Value, count_: ?u
}
pub fn getAllRecords(self: *IDBObjectStore, options: ?js.Value, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
try txn.assertActive();
const args = try IDBKeyRange.resolveGetAllOptions(exec.arena, options, exec);
const args = try IDBKeyRange.resolveGetAllOptions(txn._arena, options, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_get_all = .{ .store = self, .args = args, .mode = .record } }, exec);
}
fn _getAll(self: *IDBObjectStore, query_or_options: ?js.Value, count_: ?u32, mode: GetAllMode, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
try txn.assertActive();
const args = try IDBKeyRange.resolveGetAll(exec.arena, query_or_options, count_, exec);
const args = try IDBKeyRange.resolveGetAll(txn._arena, query_or_options, count_, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_get_all = .{ .store = self, .args = args, .mode = mode } }, exec);
}
@@ -210,7 +216,7 @@ fn collectAll(self: *IDBObjectStore, exec: *Execution, args: IDBKeyRange.GetAllA
const out: js.Value = switch (mode) {
.value => try js.Value.deserialize(local, row.get([]const u8, 1)),
.key => try Key.decodeToJs(exec.call_arena, local, key),
.record => try IDBRecord.initValue(exec, local, key, key, row.get([]const u8, 1)),
.record => try IDBRecord.initValue(self._txn, local, key, key, row.get([]const u8, 1)),
};
_ = try arr.set(i, out, .{});
i += 1;
@@ -219,9 +225,9 @@ fn collectAll(self: *IDBObjectStore, exec: *Execution, args: IDBKeyRange.GetAllA
}
pub fn getKey(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
try txn.assertActive();
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec);
const request = try txn.newRequest();
return request.submit(.{ .store_get_key = .{ .store = self, .bounds = bounds } }, exec);
}
@@ -238,13 +244,13 @@ pub fn runGetKey(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bou
}
pub fn openCursor(self: *IDBObjectStore, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest {
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
return IDBCursor.open(self, bounds, direction orelse .next, false, exec);
const bounds = try IDBKeyRange.resolveQuery(self._txn._arena, query, exec);
return IDBCursor.init(self, bounds, direction orelse .next, false, exec);
}
pub fn openKeyCursor(self: *IDBObjectStore, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest {
const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec);
return IDBCursor.open(self, bounds, direction orelse .next, true, exec);
const bounds = try IDBKeyRange.resolveQuery(self._txn._arena, query, exec);
return IDBCursor.init(self, bounds, direction orelse .next, true, exec);
}
pub fn getName(self: *const IDBObjectStore) []const u8 {
@@ -259,7 +265,7 @@ pub fn getAutoIncrement(self: *const IDBObjectStore) bool {
return self._auto_increment;
}
pub fn getTransaction(self: *IDBObjectStore) ?*IDBTransaction {
pub fn getTransaction(self: *IDBObjectStore) *IDBTransaction {
return self._txn;
}
@@ -279,15 +285,15 @@ pub const PreparedKey = union(enum) {
};
fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: WriteKind, exec: *Execution) !*IDBRequest {
const txn = self._txn orelse return error.TransactionInactiveError;
const txn = self._txn;
if (txn._mode == .readonly) {
return error.ReadOnlyError;
}
try txn.assertActive();
// Resolve (and validate) the key now, so DataError / a throwing key getter
// throws synchronously. Encoded key bytes are captured on the page arena to
// outlive this call. The record write itself is deferred to runWrite.
// throws synchronously. Encoded key bytes are captured on the transaction's
// arena to outlive this call. The record write itself is deferred to runWrite.
const prepared: PreparedKey = blk: {
if (self._key_path) |kp| {
if (key_arg != null) {
@@ -296,7 +302,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write
}
if (Key.evaluatePath(value, kp)) |extracted| {
break :blk .{ .explicit = .{
.encoded = try Key.encodeValue(exec.arena, extracted),
.encoded = try Key.encodeValue(txn._arena, extracted),
.bump = if (self._auto_increment and extracted.isNumber()) try extracted.toF64() else null,
} };
}
@@ -313,7 +319,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write
// Out-of-line keys.
if (key_arg) |k| {
break :blk .{ .explicit = .{
.encoded = try Key.encodeValue(exec.arena, k),
.encoded = try Key.encodeValue(txn._arena, k),
.bump = if (self._auto_increment and k.isNumber()) try k.toF64() else null,
} };
}
@@ -324,7 +330,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write
};
const request = try txn.newRequest();
const value_global = try value.persist();
const value_global = try txn.persist(value);
return request.submit(.{ .store_write = .{
.store = self,
.kind = kind,
@@ -333,7 +339,10 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write
} }, exec);
}
pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: js.Value.Global, prepared: PreparedKey, exec: *Execution) !void {
pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: *js.Value.BareGlobal, prepared: PreparedKey, exec: *Execution) !void {
// Written (or failed) is written: the pinned value is dead once this op
// ran, so release its handle now instead of at transaction teardown.
defer value_global.deinit();
self.writeInner(request, kind, value_global, prepared, exec) catch |err| {
if (err != error.Constraint) {
log.warn(.storage, "idb write", .{ .err = err, .kind = kind });
@@ -342,7 +351,7 @@ pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, va
};
}
fn writeInner(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: js.Value.Global, prepared: PreparedKey, exec: *Execution) !void {
fn writeInner(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: *js.Value.BareGlobal, prepared: PreparedKey, exec: *Execution) !void {
const local = exec.js.local.?;
const value = value_global.local(local);
@@ -454,7 +463,7 @@ const CreateIndexOptions = struct {
// Only callable during an upgrade (versionchange transaction).
pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8, options: ?CreateIndexOptions, exec: *Execution) !*IDBIndex {
const txn = self._txn orelse return error.InvalidStateError;
const txn = self._txn;
if (txn._mode != .versionchange) {
return error.InvalidStateError;
}
@@ -484,21 +493,22 @@ pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8
}
}
const owned_name = try exec.dupeString(name);
const owned_key_path = try exec.dupeString(key_path);
const owned_name = try txn.dupe(name);
const owned_key_path = try txn.dupe(key_path);
const idb_index = try IDBIndex.init(self, .{
.id = index_id,
.key_path = owned_key_path,
.unique = opts.unique,
.multi_entry = opts.multiEntry,
}, owned_name, exec);
}, owned_name);
try self._engine.releaseSavepoint();
try self._indexes.append(txn._arena, idb_index);
return idb_index;
}
// Only callable during an upgrade (versionchange transaction).
pub fn deleteIndex(self: *IDBObjectStore, name: []const u8, _: *Execution) !void {
const txn = self._txn orelse return error.InvalidStateError;
const txn = self._txn;
if (txn._mode != .versionchange) {
return error.InvalidStateError;
}
@@ -506,15 +516,27 @@ pub fn deleteIndex(self: *IDBObjectStore, name: []const u8, _: *Execution) !void
error.NotFound => return error.NotFoundError,
else => return err,
};
for (self._indexes.items, 0..) |idx, i| {
if (std.mem.eql(u8, idx._name, name)) {
_ = self._indexes.swapRemove(i);
break;
}
}
}
pub fn index(self: *IDBObjectStore, name: []const u8, exec: *Execution) !*IDBIndex {
if (self._txn == null) {
return error.InvalidStateError;
pub fn index(self: *IDBObjectStore, name: []const u8, _: *Execution) !*IDBIndex {
for (self._indexes.items) |idx| {
if (std.mem.eql(u8, idx._name, name)) {
return idx;
}
}
const info = (try self._engine.indexInfo(exec.arena, self._store_id, name)) orelse return error.NotFound;
const owned_name = try exec.dupeString(name);
return IDBIndex.init(self, info, owned_name, exec);
const txn = self._txn;
const info = (try self._engine.indexInfo(txn._arena, self._store_id, name)) orelse return error.NotFound;
const owned_name = try txn.dupe(name);
const idx = try IDBIndex.init(self, info, owned_name);
try self._indexes.append(txn._arena, idx);
return idx;
}
pub fn getIndexNames(self: *IDBObjectStore, exec: *Execution) !*DOMStringList {

View File

@@ -18,7 +18,9 @@
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Key = @import("Key.zig");
const IDBTransaction = @import("IDBTransaction.zig");
const Execution = js.Execution;
@@ -26,29 +28,31 @@ const IDBRecord = @This();
// A single result of getAllRecords: the (index or store) key, the primary/store
// key, and the value. Stored encoded/serialized and decoded lazily on access
// (mirroring IDBKeyRange). The bytes are page-arena lived — the record is handed
// back inside a JS array the page may retain past the request.
// (mirroring IDBKeyRange). Record and bytes live on the transaction's arena;
_txn: *IDBTransaction,
_key: []const u8,
_primary_key: []const u8,
_value: []const u8,
pub fn init(exec: *Execution, key: []const u8, primary_key: []const u8, value: []const u8) !*IDBRecord {
return exec._factory.create(IDBRecord{
._key = key,
._primary_key = primary_key,
._value = value,
});
pub fn acquireRef(self: *IDBRecord) void {
self._txn.acquireRef();
}
// Build a record from borrowed bytes — duped onto the page arena so the record
// outlives this call — and return its JS value.
pub fn initValue(exec: *Execution, local: *const js.Local, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value {
const record = try init(
exec,
try exec.arena.dupe(u8, key),
try exec.arena.dupe(u8, primary_key),
try exec.arena.dupe(u8, value),
);
pub fn releaseRef(self: *IDBRecord, page: *Page) void {
self._txn.releaseRef(page);
}
// Build a record from borrowed bytes — duped onto the transaction's arena so
// the record outlives this call — and return its JS value.
pub fn initValue(txn: *IDBTransaction, local: *const js.Local, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value {
const arena = txn._arena;
const record = try arena.create(IDBRecord);
record.* = .{
._txn = txn,
._key = try arena.dupe(u8, key),
._primary_key = try arena.dupe(u8, primary_key),
._value = try arena.dupe(u8, value),
};
return local.zigValueToJs(record, .{});
}

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Event = @import("../../Event.zig");
const EventTarget = @import("../../EventTarget.zig");
const DOMException = @import("../../DOMException.zig");
@@ -43,13 +44,16 @@ const IDBRequest = @This();
_proto: *EventTarget,
_op: Operation = .none,
_error: ?anyerror = null,
_txn: ?*IDBTransaction = null,
_txn: Txn = .none,
// Same request can show up multiple times in txn._requests, but it should only
// be executed/fired in its last append (to preserve ordering).
_txn_index: usize = 0,
_cursor: ?*IDBCursor = null,
_ready_state: ReadyState = .pending,
_result: Result = .{ .none = js.Undefined{} },
// whether or not we own th result value, if we do, we can free it once we know
// it cannot be used
_result_owned: bool = false,
_on_success: ?js.Function.Global = null,
_on_error: ?js.Function.Global = null,
@@ -66,10 +70,26 @@ const ReadyState = enum {
const Result = union(enum) {
none: ?js.Undefined, // null or undefined (different APIs return different values)
value: js.Value.Global, // the result of a get/add/put, or a positioned cursor
value: *js.Value.BareGlobal, // the result of a get/add/put, or a positioned cursor
database: *IDBDatabase, // the result of an open
};
const Txn = union(enum) {
// a page-scoped open/deleteDatabase request, outside an upgrade
none,
// The transaction this request belongs to — and whose arena it lives on.
// Set once by IDBTransaction.newRequest, before the request is ever
// wrapped, and never reassigned: the FC acquire and release must resolve
// the same target. Wrapper pins forward to it.
owned: *IDBTransaction,
// An open request's exposure of the versionchange transaction while the
// upgrade runs — spec visibility only, not ownership: the request stays
// page-scoped and pins nothing.
borrowed: *IDBTransaction,
};
pub fn init(exec: *Execution) !*IDBRequest {
return exec._factory.eventTarget(IDBRequest{ ._proto = undefined });
}
@@ -78,18 +98,66 @@ pub fn asEventTarget(self: *IDBRequest) *EventTarget {
return self._proto;
}
// Not exposed to JS, called internally
pub fn setValue(self: *IDBRequest, value: js.Value) !void {
return self.setValueGlobal(try value.persist());
// The FC machinery calls these when a JS wrapper is created/collected. A
// request created through a transaction lives on that transaction's arena and
// forwards its wrapper pins there; open/delete requests are page-scoped and
// pin nothing.
pub fn acquireRef(self: *IDBRequest) void {
switch (self._txn) {
.owned => |txn| txn.acquireRef(),
.none, .borrowed => {},
}
}
// Not exposed to JS, called internally
pub fn setValueGlobal(self: *IDBRequest, global: js.Value.Global) void {
pub fn releaseRef(self: *IDBRequest, page: *Page) void {
if (self._op == .none and self._ready_state == .done) {
// v8 is done with this request and we're done with it. Eagerly release
// our value
self.clearOwnedResult();
}
switch (self._txn) {
.owned => |txn| txn.releaseRef(page),
.none, .borrowed => {},
}
}
// Release the result handle if this request owns one.
fn clearOwnedResult(self: *IDBRequest) void {
if (!self._result_owned) {
return;
}
self._result_owned = false;
switch (self._result) {
.value => |global| {
// It's ok to keep this in txn._globals, deinit can be called multiple times
global.deinit();
self._result = .{ .none = js.Undefined{} };
},
.none, .database => {},
}
}
// Not exposed to JS, called internally. Only requests created through a
// transaction (newRequest) carry value results; open/delete requests use
// setDatabaseResult or errors.
pub fn setValue(self: *IDBRequest, value: js.Value) !void {
const global = try self._txn.owned.persist(value);
self.clearOwnedResult();
self._result = .{ .value = global };
self._result_owned = true;
}
// Not exposed to JS, called internally. The handle is borrowed (a cursor's
// transaction-owned _js), not owned by this request.
pub fn setValueGlobal(self: *IDBRequest, global: *js.Value.BareGlobal) void {
self.clearOwnedResult();
self._result = .{ .value = global };
}
// Not exposed to JS, called internally. Result becomes JS `null` (not undefined).
pub fn setNull(self: *IDBRequest) void {
self.clearOwnedResult();
self._result = .{ .none = null };
}
@@ -140,12 +208,27 @@ pub fn getReadyState(self: *const IDBRequest) ReadyState {
return self._ready_state;
}
pub fn getResult(self: *const IDBRequest) Result {
return self._result;
// What the `result` accessor hands the bridge: the stored handle resolved to a
// local value.
const JsResult = union(enum) {
value: js.Value,
none: ?js.Undefined,
database: *IDBDatabase,
};
pub fn getResult(self: *const IDBRequest, exec: *Execution) JsResult {
return switch (self._result) {
.none => |n| .{ .none = n },
.value => |global| .{ .value = global.local(exec.js.local.?) },
.database => |db| .{ .database = db },
};
}
pub fn getTransaction(self: *const IDBRequest) ?*IDBTransaction {
return self._txn;
return switch (self._txn) {
.none => null,
.owned, .borrowed => |txn| txn,
};
}
// Return this as a DOMException directly. If we return an error, the bridge
@@ -212,11 +295,11 @@ pub const Operation = union(enum) {
const StoreQuery = struct { store: *IDBObjectStore, bounds: Engine.Bounds };
const StoreGetAll = struct { store: *IDBObjectStore, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStore.GetAllMode };
const StoreWrite = struct { store: *IDBObjectStore, kind: IDBObjectStore.WriteKind, value: js.Value.Global, key: IDBObjectStore.PreparedKey };
const StoreWrite = struct { store: *IDBObjectStore, kind: IDBObjectStore.WriteKind, value: *js.Value.BareGlobal, key: IDBObjectStore.PreparedKey };
const IndexQuery = struct { index: *IDBIndex, bounds: Engine.Bounds };
const IndexGetAll = struct { index: *IDBIndex, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStore.GetAllMode };
const CursorIterate = struct { cursor: *IDBCursor, seek: IDBCursor.Seek, offset: u32 };
const CursorUpdate = struct { cursor: *IDBCursor, key: []const u8, value: js.Value.Global };
const CursorUpdate = struct { cursor: *IDBCursor, key: []const u8, value: *js.Value.BareGlobal };
const CursorDelete = struct { cursor: *IDBCursor, key: []const u8 };
};
@@ -224,7 +307,7 @@ pub const Operation = union(enum) {
// transaction's drain.
pub fn submit(self: *IDBRequest, op: Operation, exec: *Execution) !*IDBRequest {
self._op = op;
const txn = self._txn.?;
const txn = self._txn.owned;
try txn.enqueue(self);
if (txn.getMode() == .versionchange) {
@@ -245,8 +328,9 @@ pub fn execute(self: *IDBRequest, exec: *Execution) !void {
}
self._op = .none;
if (self._txn) |txn| {
try txn.ensureBegun();
switch (self._txn) {
.owned => |txn| try txn.ensureBegun(),
.none, .borrowed => {},
}
switch (op) {
.none => unreachable,

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Event = @import("../../Event.zig");
const EventTarget = @import("../../EventTarget.zig");
@@ -33,6 +34,7 @@ const DOMStringList = @import("../../collections.zig").DOMStringList;
const log = lp.log;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
const FunctionSetter = idb.FunctionSetter;
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -50,6 +52,24 @@ _scope: []const []const u8 = &.{},
// Advisory only: we always commit through sqlite. Stored to expose the property.
_durability: Durability = .default,
// The transaction owns everything created under it — requests, stores,
// indexes, cursors, encoded keys, pending write values — on a pooled arena,
// released when `_rc` drops to zero. Refs: one per live JS wrapper of any
// owned object, one while a drain task is scheduled, one while parked on the
// engine's connection gate (gate ownership needs no ref of its own: it's only
// ever held while a drain task exists).
_rc: lp.RC(u32) = .{},
_arena: Allocator,
// v8 handles owned by the transaction, swept (reset) in deinit. Slots are
// arena-allocated so an early release and the sweep hit the same instance —
// a v8 Global reset is only idempotent through a single instance.
_globals: std.ArrayList(*js.Value.BareGlobal) = .empty,
// objectStore() must return the same object for a given name within one
// transaction (per spec); this also keeps repeated lookups off sqlite.
_stores: std.ArrayList(*IDBObjectStore) = .empty,
// request queue, swaps between &_queue_a and &_queue_b so that, as we drain, new
// requests are queued in the new queue and will be processed on the next drain
_queue: *std.ArrayList(*IDBRequest),
@@ -66,6 +86,13 @@ _gate_waiter: Engine.GateWaiter,
// later generation (see assertActive).
_active_turn: u64 = 0,
// The transaction can be freed when v8 doesn't reference it (or any child,
// e.g. an IDBRequest), when we have no scheduled drain AND when we aren't
// parked in the Engine's wait gate. This last one we track explicitly so
// that, when Engine.detach cancels us, we know whether the registration held
// a pin (parked) or the drain task does (owner).
_parked: bool = false,
_on_complete: ?js.Function.Global = null,
_on_error: ?js.Function.Global = null,
_on_abort: ?js.Function.Global = null,
@@ -92,38 +119,46 @@ pub const Durability = enum {
}
};
pub fn init(exec: *Execution, db: *IDBDatabase, mode: Mode, durability: Durability, scope: []const []const u8) !*IDBTransaction {
const self = try exec._factory.eventTarget(IDBTransaction{
._proto = undefined,
._exec = exec,
._db = db,
._engine = db._engine,
._mode = mode,
._scope = scope,
._durability = durability,
._active_turn = exec.js.scheduler.generation,
._queue = undefined,
._gate_waiter = undefined,
});
self._queue = &self._queue_a;
self._gate_waiter = .{ .wake = resumeDrain };
pub fn init(db: *IDBDatabase, mode: Mode, durability: Durability, exec: *Execution) !*IDBTransaction {
const arena = try exec.getArena(.small, "IDBTransaction");
// Schedule the drain even for an empty transaction so it still `complete`s.
try exec.js.scheduler.add(self, drain, 0, .{
.name = "IDBTransaction.drain",
.finalizer = finalize,
});
const self = blk: {
errdefer exec.releaseArena(arena);
const s = try exec._factory.eventTargetWithAllocator(arena, IDBTransaction{
._proto = undefined,
._exec = exec,
._db = db,
._engine = db._engine,
._mode = mode,
._arena = arena,
._durability = durability,
._active_turn = exec.js.scheduler.generation,
._queue = undefined,
._gate_waiter = undefined,
});
s._queue = &s._queue_a;
s._gate_waiter = .{ .ctx = exec.js, .wake = resumeDrain, .cancel = cancelGate };
break :blk s;
};
try self.scheduleDrain();
return self;
}
// We need a "special" transaction for upgradeneeded
pub fn initVersionChange(exec: *Execution, db: *IDBDatabase) !*IDBTransaction {
const self = try exec._factory.eventTarget(IDBTransaction{
// We need a "special" transaction for upgradeneeded. It has no drain task and
// no gate registration, so it starts with zero refs: the caller (the open
// path) must pin it for the duration of the upgrade.
pub fn initVersionChange(db: *IDBDatabase, exec: *Execution) !*IDBTransaction {
const arena = try exec.getArena(.small, "IDBTransaction");
errdefer exec.releaseArena(arena);
const self = try exec._factory.eventTargetWithAllocator(arena, IDBTransaction{
._proto = undefined,
._exec = exec,
._db = db,
._engine = db._engine,
._mode = .versionchange,
._arena = arena,
._begun = true,
._queue = undefined,
._gate_waiter = undefined,
@@ -131,10 +166,47 @@ pub fn initVersionChange(exec: *Execution, db: *IDBDatabase) !*IDBTransaction {
self._queue = &self._queue_a;
// A versionchange transaction never contends for the gate (the open path
// holds it); keep the node well-formed so releaseGate's owner check no-ops.
self._gate_waiter = .{ .wake = resumeDrain };
self._gate_waiter = .{ .ctx = exec.js, .wake = resumeDrain, .cancel = cancelGate };
return self;
}
pub fn deinit(self: *IDBTransaction, page: *Page) void {
if (comptime IS_DEBUG) {
// Pins hold refs, so the last release can't happen while parked (nor
// while a drain task is scheduled).
std.debug.assert(self._parked == false);
}
for (self._globals.items) |slot| {
slot.deinit();
}
page.releaseArena(self._arena);
}
pub fn acquireRef(self: *IDBTransaction) void {
self._rc.acquire();
}
pub fn releaseRef(self: *IDBTransaction, page: *Page) void {
self._rc.release(self, page);
}
// Persist a JS value with the transaction's lifetime: the handle is reset when the
// transaction's memory is released — or earlier, by calling deinit() on the
// returned slot (the sweep's second reset is then a no-op).
pub fn persist(self: *IDBTransaction, value: js.Value) !*js.Value.BareGlobal {
const slot = try self._arena.create(js.Value.BareGlobal);
try self._globals.append(self._arena, slot);
slot.* = value.bare();
return slot;
}
pub fn dupe(self: *IDBTransaction, value: []const u8) ![]const u8 {
if (lp.String.intern(value)) |v| {
return v;
}
return self._arena.dupe(u8, value);
}
pub fn asEventTarget(self: *IDBTransaction) *EventTarget {
return self._proto;
}
@@ -167,8 +239,11 @@ pub fn abort(self: *IDBTransaction, exec: *Execution) !void {
if (self._begun) {
self._engine.rollback();
self._begun = false;
}
self._engine.releaseGate(&self._gate_waiter);
// No-op if we're parked rather than owner; the park pin is then released
// by the eventual wake (drain's settled path) or a detach cancel.
_ = self._engine.releaseGate(&self._gate_waiter);
for ([_]*std.ArrayList(*IDBRequest){ &self._queue_a, &self._queue_b }) |queue| {
for (queue.items, 0..) |request, i| {
@@ -212,12 +287,14 @@ fn commitAndComplete(self: *IDBTransaction, exec: *Execution) void {
self._engine.commit() catch |err| {
log.warn(.storage, "idb commit", .{ .err = err, .sqlite = self._engine.conn.lastError() });
self._engine.rollback();
self._engine.releaseGate(&self._gate_waiter);
self._begun = false;
_ = self._engine.releaseGate(&self._gate_waiter);
self.fire(exec, comptime .wrap("abort"), self._on_abort);
return;
};
self._begun = false;
}
self._engine.releaseGate(&self._gate_waiter);
_ = self._engine.releaseGate(&self._gate_waiter);
self.fire(exec, comptime .wrap("complete"), self._on_complete);
}
@@ -247,24 +324,48 @@ pub fn ensureBegun(self: *IDBTransaction) !void {
}
pub fn newRequest(self: *IDBTransaction) !*IDBRequest {
const request = try IDBRequest.init(self._exec);
request._txn = self;
const request = try self._exec._factory.eventTargetWithAllocator(self._arena, IDBRequest{ ._proto = undefined });
request._txn = .{ .owned = self };
return request;
}
pub fn enqueue(self: *IDBTransaction, request: *IDBRequest) !void {
request._txn_index = self._queue.items.len;
try self._queue.append(self._exec.arena, request);
try self._queue.append(self._arena, request);
}
pub fn objectStore(self: *IDBTransaction, name: []const u8, exec: *Execution) !*IDBObjectStore {
pub fn objectStore(self: *IDBTransaction, name: []const u8) !*IDBObjectStore {
for (self._stores.items) |store| {
if (std.mem.eql(u8, store._name, name)) {
return store;
}
}
const database_id = self._db._database_id;
const info = (try self._engine.objectStoreInfo(exec.arena, database_id, name)) orelse {
const info = (try self._engine.objectStoreInfo(self._arena, database_id, name)) orelse {
return error.NotFound;
};
const owned_name = try exec.dupeString(name);
return IDBObjectStore.init(self._engine, self, info.id, owned_name, info.key_path, info.auto_increment, exec);
const owned_name = try self.dupe(name);
const store = try IDBObjectStore.init(self, info.id, owned_name, info.key_path, info.auto_increment);
try self._stores.append(self._arena, store);
return store;
}
// Register a store created during an upgrade so a later objectStore() returns
// the same object.
pub fn cacheStore(self: *IDBTransaction, store: *IDBObjectStore) !void {
try self._stores.append(self._arena, store);
}
// A store was deleted during an upgrade; a later objectStore() must miss.
pub fn uncacheStore(self: *IDBTransaction, name: []const u8) void {
for (self._stores.items, 0..) |store, i| {
if (std.mem.eql(u8, store._name, name)) {
_ = self._stores.swapRemove(i);
return;
}
}
}
pub fn getMode(self: *const IDBTransaction) Mode {
@@ -285,10 +386,17 @@ pub fn getObjectStoreNames(self: *IDBTransaction, exec: *Execution) !*DOMStringL
// A versionchange transaction spans every store; its set changes as the
// upgrade creates/deletes stores, so resolve it live rather than caching.
// The list is refcounted and can outlive the transaction, so the scope
// names are copied onto the list's own arena.
const names = if (self._mode == .versionchange)
try self._engine.objectStoreNames(arena, self._db._database_id)
else
self._scope;
else blk: {
const copy = try arena.alloc([]const u8, self._scope.len);
for (self._scope, 0..) |name, i| {
copy[i] = try arena.dupe(u8, name);
}
break :blk copy;
};
const list = try arena.create(DOMStringList);
list.* = .{ ._items = names, ._arena = arena };
@@ -338,11 +446,33 @@ fn fire(self: *IDBTransaction, exec: *Execution, typ: lp.String, handler: ?js.Fu
};
}
// Schedule the drain task, which pins the transaction until it runs to completion
// (or its scheduler finalizer runs).
fn scheduleDrain(self: *IDBTransaction) !void {
self.acquireRef();
errdefer self.releaseRef(self._exec.page);
try self._exec.js.scheduler.add(self, drain, 0, .{
.name = "IDBTransaction.drain",
.finalizer = finalize,
});
}
fn drain(ctx: *anyopaque) !?u32 {
const self: *IDBTransaction = @ptrCast(@alignCast(ctx));
const repeat = self.drainInner();
if (repeat == null) {
// The drain task is done; drop its pin. May free the transaction —
// must be the last touch.
self.releaseRef(self._exec.page);
}
return repeat;
}
fn drainInner(self: *IDBTransaction) ?u32 {
if (self._settled) {
// Already settled (e.g. via an abort).
self._engine.releaseGate(&self._gate_waiter);
// Already settled (e.g. via an abort, which released the gate; the
// release here is a defensive no-op in that case).
_ = self._engine.releaseGate(&self._gate_waiter);
return null;
}
@@ -350,7 +480,12 @@ fn drain(ctx: *anyopaque) !?u32 {
if (self._queue.items.len > 0) {
if (self._engine.acquireGate(&self._gate_waiter) == false) {
return null; // parked; resumeDrain reschedules us
// Parked: no drain task exists while we wait for the gate, and our
// wrappers may all be collected, so the registration itself must
// pin us until resumeDrain (or a detach cancel) unparks.
self._parked = true;
self.acquireRef();
return null;
}
self.deliverBatch(exec);
@@ -369,6 +504,14 @@ fn drain(ctx: *anyopaque) !?u32 {
return null;
}
fn unpark(self: *IDBTransaction) void {
if (comptime IS_DEBUG) {
std.debug.assert(self._parked);
}
self._parked = false;
self.releaseRef(self._exec.page);
}
// Scheduler wake-up: the gate was handed to us, so run the drain again.
fn resumeDrain(waiter: *Engine.GateWaiter) void {
const self: *IDBTransaction = @fieldParentPtr("_gate_waiter", waiter);
@@ -376,21 +519,45 @@ fn resumeDrain(waiter: *Engine.GateWaiter) void {
std.debug.assert(self._mode != .versionchange);
}
self._exec.js.scheduler.add(self, drain, 0, .{
.name = "IDBTransaction.drain",
.finalizer = finalize,
}) catch |err| {
self._engine.releaseGate(&self._gate_waiter);
defer self.unpark();
self.scheduleDrain() catch |err| {
// We were handed the gate; if we can't reschedule, hand it off so the
// waiters behind us aren't stranded. Unpark may free self — last touch.
log.warn(.storage, "idb resume drain", .{ .err = err });
_ = self._engine.releaseGate(&self._gate_waiter);
};
}
// Scheduler task finalizer: our context's scheduler is being torn down.
// Engine.detach normally ran first and already unlinked us from the gate; the
// gate handling here is a backstop for a scheduler reset without a detach.
// Never parked here — a parked transaction has no task to finalize.
fn finalize(ctx: *anyopaque) void {
const self: *IDBTransaction = @ptrCast(@alignCast(ctx));
if (self._begun and !self._settled) {
self._engine.rollback();
self._begun = false;
}
self._settled = true;
_ = self._engine.releaseGate(&self._gate_waiter);
// The task pin; may free the transaction — must be the last touch.
self.releaseRef(self._exec.page);
}
// Engine.detach cancel: our context is going away; the unlink/hand-off is
// detach's job. The one site that runs in either gate state: parked, the
// registration held our pin; as owner, a drain task exists and its finalizer
// releases that pin instead.
fn cancelGate(waiter: *Engine.GateWaiter) void {
const self: *IDBTransaction = @fieldParentPtr("_gate_waiter", waiter);
if (self._begun) {
self._engine.rollback();
self._begun = false;
}
self._settled = true;
if (self._parked == true) {
self.unpark();
}
self._engine.releaseGate(&self._gate_waiter);
}
// Deliver the requests queued as of now, one queue's worth. Requests enqueued

View File

@@ -43,6 +43,16 @@ pub fn deinit(self: *Manager) void {
self.engines.deinit(self.allocator);
}
// A js Context is being torn down (navigation, popup close, worker close):
// every engine must drop any gate participant whose callbacks would run in it.
// Must be called before that context's scheduler is reset or deinit'd.
pub fn detachContext(self: *Manager, ctx: *anyopaque) void {
var it = self.engines.valueIterator();
while (it.next()) |engine| {
engine.*.detach(ctx);
}
}
// Gets or creates the engine for the given origin.
pub fn engineForOrigin(self: *Manager, origin: []const u8) !*Engine {
const gop = try self.engines.getOrPut(self.allocator, origin);