Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/src/native/resqlite_bindings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,19 @@ external int resqliteDbStatusTotal(
ffi.Pointer<ffi.Int> outHighwater,
);

/// Brackets a read worker's request so `resqlite_db_status_total` never
/// reads this NOMUTEX connection from the main isolate while the worker
/// is using it. See `resqlite_reader_set_busy` in `native/resqlite.h`.
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Void>, ffi.Int, ffi.Int)>(
symbol: 'resqlite_reader_set_busy',
isLeaf: true,
)
external void resqliteReaderSetBusy(
ffi.Pointer<ffi.Void> db,
int readerId,
int busy,
);

/// Per-worker persistent buffer for read-table pointer marshalling.
/// Allocated once; reused across calls. Eliminates a ~512-byte
/// calloc/free pair per stream subscription
Expand Down
26 changes: 25 additions & 1 deletion lib/src/reader/read_worker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ void readerEntrypoint(List<Object> args) {

final request = message as ReadRequest;

// Mark this worker's dedicated reader connection busy so
// Database.diagnostics() (sqlite3_db_status from the main isolate)
// skips it while we're stepping over it — the connections are
// NOMUTEX, so a concurrent status read is a data race. Cleared in
// `finally` below, and explicitly before Isolate.exit on the
// sacrifice path (exit skips finally).
resqliteReaderSetBusy(
ffi.Pointer<ffi.Void>.fromAddress(dbHandleAddr),
readerId,
1,
);

// Timeline marker scopes the reader-isolate's per-message work so
// external profilers can see the cross-isolate breakdown. Gated
// behind `kProfileMode` (compile-time const) so release builds pay
Expand Down Expand Up @@ -180,7 +192,14 @@ void readerEntrypoint(List<Object> args) {

if (sacrifice) {
receivePort.close();
// Isolate.exit skips `finally`; close the timeline span manually.
// Isolate.exit skips `finally`; release the busy bracket and
// close the timeline span manually. The result is fully
// materialized at this point — no further native reads occur.
resqliteReaderSetBusy(
ffi.Pointer<ffi.Void>.fromAddress(dbHandleAddr),
readerId,
0,
);
if (kProfileMode) {
Timeline.finishSync();
TraceliteProfile.end(
Expand All @@ -205,6 +224,11 @@ void readerEntrypoint(List<Object> args) {
);
eventPort.send((null, false, error));
} finally {
resqliteReaderSetBusy(
ffi.Pointer<ffi.Void>.fromAddress(dbHandleAddr),
readerId,
0,
);
if (kProfileMode) {
Timeline.finishSync();
TraceliteProfile.end(
Expand Down
16 changes: 15 additions & 1 deletion native/resqlite.c
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ typedef struct {
resqlite_authz_ctx authz_ctx;
resqlite_buf json_buf; // persistent buffer for resqlite_query_bytes
int in_use;
// Set by the reader's worker isolate around each request
// (resqlite_reader_set_busy) so resqlite_db_status_total never reads
// this NOMUTEX connection while the worker is using it. The legacy
// acquire-path `in_use` is dead under dedicated assignment (exp 030).
atomic_int worker_busy;
} resqlite_reader;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -677,6 +682,7 @@ static resqlite_db* resqlite_open_impl(const char* path, int max_readers,
continue;
}
db->readers[idx].in_use = 0;
atomic_init(&db->readers[idx].worker_busy, 0);

// Install authorizer to capture read dependencies (table + column).
// The context lives inline on the reader so its address is stable
Expand Down Expand Up @@ -1239,6 +1245,12 @@ int resqlite_get_dirty_tables(
return count;
}

void resqlite_reader_set_busy(resqlite_db* db, int reader_id, int busy) {
if (!db || reader_id < 0 || reader_id >= db->reader_count) return;
atomic_store_explicit(&db->readers[reader_id].worker_busy, busy,
memory_order_release);
}

// Polish (post-2026-04): returns RESQLITE_DEPENDENCY_COUNT_UNKNOWN when
// the cached entry's read-table dependencies are unreliable (overflow / OOM
// during prepare). Zero would mean "stream has no table deps" → silent stuck
Expand Down Expand Up @@ -1367,7 +1379,9 @@ int resqlite_db_status_total(

sqlite3_mutex_enter(db->pool_mutex);
for (int i = 0; i < db->reader_count; i++) {
if (db->readers[i].in_use) {
if (db->readers[i].in_use ||
atomic_load_explicit(&db->readers[i].worker_busy,
memory_order_acquire)) {
if (rc == SQLITE_OK) rc = SQLITE_BUSY;
continue;
}
Expand Down
12 changes: 12 additions & 0 deletions native/resqlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,18 @@ int resqlite_db_status_total(
int* out_highwater
);

// Mark a dedicated reader connection busy/idle from its worker isolate.
//
// Connections are opened NOMUTEX, so `resqlite_db_status_total` must never
// call sqlite3_db_status() on a connection another thread is actively
// using (SQLITE_DBSTATUS_SCHEMA_USED measures via the connection's
// pnBytesFreed dry-run mechanism — toggling it under a live reader
// corrupts that reader's allocation accounting). The legacy acquire-path
// `in_use` flag is dead under dedicated reader assignment
// ([EXP-030](../experiments/030-dedicated-reader-assignment.md)), so read
// workers bracket each request with this call to make the busy guard real.
void resqlite_reader_set_busy(resqlite_db* db, int reader_id, int busy);

// ---------------------------------------------------------------------------
// Read operations (use reader pool)
// ---------------------------------------------------------------------------
Expand Down
Loading