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
66 changes: 66 additions & 0 deletions packages/shared/src/nodeSqliteClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,72 @@ const makeTempDatabase = Effect.gen(function* () {
return { filename, other };
});

it.effect.each([false, true])("batches worker results without losing rows (reader=%s)", (reader) =>
Effect.gen(function* () {
const { filename } = yield* makeTempDatabase;
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const route = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
reader ? effect.pipe(SqliteClient.readOnly) : effect;
yield* route(sql`SELECT 1`);
const worker = startedWorkers
.filter((candidate) => !checkpointWorkers.includes(candidate))
.at(-1)!;
const batches: Array<ReadonlyArray<unknown>> = [];
const record = (reply: { rows?: ReadonlyArray<unknown> }) => {
if (Array.isArray(reply.rows) && reply.rows.length > 0) batches.push(reply.rows);
};
worker.prependListener("message", record);
yield* Effect.gen(function* () {
const text = "x".repeat(16 * 1024);
const query = sql<{ n: number; text: string }>`
WITH RECURSIVE sequence(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM sequence WHERE n < 300
) SELECT n, ${text} AS text FROM sequence
`;
const rows = yield* reader ? route(query.values) : query;
assert.equal(rows.length, 300);
for (const [index, row] of rows.entries()) {
assert.deepEqual(row, reader ? [index + 1, text] : { n: index + 1, text });
}
assert.isAbove(batches.length, 1);
for (const batch of batches) {
assert.isAtMost(batch.length, 16_384);
assert.isAtMost(batch.length * text.length * 2, 8 * 1024 * 1024);
}
batches.length = 0;
const narrow = sql<{ n: number }>`
WITH RECURSIVE sequence(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM sequence WHERE n < 10000
) SELECT n FROM sequence
`;
const narrowRows = yield* reader ? route(narrow.values) : narrow;
assert.equal(narrowRows.length, 10_000);
assert.deepEqual(narrowRows.at(-1), reader ? [10_000] : { n: 10_000 });
assert.equal(batches.length, 1);
}).pipe(Effect.ensuring(Effect.sync(() => worker.off("message", record))));
}).pipe(
Effect.provide(SqliteClient.layer({ filename, writerWorker: true, readerWorker: true })),
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reuses a worker query after an iterator constraint failure", () =>
Effect.gen(function* () {
const { filename } = yield* makeTempDatabase;
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`CREATE TABLE unique_entries(name TEXT UNIQUE)`;
const insert = (name: string) =>
sql<{ name: string }>`INSERT INTO unique_entries VALUES (${name}) RETURNING name`;
yield* insert("first");
const error = yield* insert("first").pipe(Effect.flip);
assert.equal(error.reason._tag, "UniqueViolation");
assert.deepEqual(yield* insert("second"), [{ name: "second" }]);
}).pipe(Effect.provide(SqliteClient.layer({ filename, writerWorker: true })));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps another connection from committing between a transaction's read and write", () =>
Effect.gen(function* () {
const { filename, other } = yield* makeTempDatabase;
Expand Down
56 changes: 50 additions & 6 deletions packages/shared/src/nodeSqliteClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ parentPort.on("message", (request) => {
parentPort.close();
return;
}
const { id, sql, params, cached, values, raw, safeIntegers } = request;
const { id, sql, params, cached, values, raw, safeIntegers, received } = request;
let operation = "open";
try {
if (db === undefined) throw new Error("database connection is unavailable");
Expand All @@ -252,12 +252,37 @@ parentPort.on("message", (request) => {
statement.setReadBigInts(safeIntegers);
if (statement.columns().length > 0) {
statement.setReturnArrays(values);
parentPort.postMessage({ id, rows: statement.all(...params) });
let rows = [];
let bytes = 0;
let pendingBatch = false;
for (const row of statement.iterate(...params)) {
let rowBytes = 0;
for (const key in row) {
const value = row[key];
rowBytes += typeof value === "string" ? value.length * 2
: ArrayBuffer.isView(value) ? value.byteLength : 8;
}
if (rows.length > 0 && (rows.length >= 16384 || bytes + rowBytes > 8 * 1024 * 1024)) {
// Build the next batch during receipt, but allow only one unacknowledged message.
if (pendingBatch) Atomics.wait(received, 0, 0);
Atomics.store(received, 0, 0);
parentPort.postMessage({ id, rows, more: true });
rows = [];
bytes = 0;
pendingBatch = true;
}
rows.push(row);
bytes += rowBytes;
}
if (pendingBatch) Atomics.wait(received, 0, 0);
parentPort.postMessage({ id, rows });
} else {
const result = statement.run(...params);
parentPort.postMessage({ id, rows: raw ? result : [] });
}
} catch (error) {
// A failed iterator can report its previous SQLite error when the statement is reused.
statements.delete(sql);
parentPort.postMessage({ id, error: { operation, ...describe(error) } });
}
});
Expand All @@ -283,7 +308,7 @@ type WorkerStartup =
| ({ readonly type: "unavailable" } & WorkerFailure);

type WorkerReply =
| { readonly id: number; readonly rows: ReadonlyArray<any> }
| { readonly id: number; readonly rows: ReadonlyArray<any>; readonly more?: boolean }
| {
readonly id: number;
readonly error: WorkerFailure & { readonly operation: "open" | "prepare" | "execute" };
Expand Down Expand Up @@ -353,7 +378,12 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: {
let stoppedMessage: string | undefined;
const pending = new Map<
number,
{ readonly endsTransaction: boolean; readonly resume: (reply: WorkerReply) => void }
{
readonly endsTransaction: boolean;
readonly received: Int32Array;
readonly rows: Array<unknown>;
readonly resume: (reply: WorkerReply) => void;
}
>();
const stop = (message: string) => {
stoppedMessage = message;
Expand All @@ -377,10 +407,21 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: {
}
const request = pending.get(reply.id);
if (request === undefined) return;
if ("rows" in reply && reply.more) {
for (const row of reply.rows) request.rows.push(row);
Atomics.store(request.received, 0, 1);
Atomics.notify(request.received, 0);
return;
}
pending.delete(reply.id);
if (pending.size === 0) worker.unref();
options.onReply?.();
request.resume(reply);
if ("rows" in reply && request.rows.length > 0) {
for (const row of reply.rows) request.rows.push(row);
request.resume({ id: reply.id, rows: request.rows });
} else {
request.resume(reply);
}
});
worker.on("error", (cause) => stop(`${label} failed: ${cause.message}`));
worker.on("exit", (code) => stop(`${label} exited with code ${code}`));
Expand All @@ -399,8 +440,11 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: {
const safeIntegers = Boolean(Context.get(fiber.context, Client.SafeIntegers));
const reply = Effect.callback<ReadonlyArray<any>, SqlError>((resume) => {
const id = nextRequestId++;
const received = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
pending.set(id, {
endsTransaction,
received,
rows: [],
resume: (reply) =>
resume(
"rows" in reply
Expand All @@ -411,7 +455,7 @@ const makeWorkerConnection = Effect.fnUntraced(function* (options: {
if (pending.size === 1) worker.ref();
const cached = input.cached && !hasLargeBinding(input.params);
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin.
worker.postMessage({ id, safeIntegers, ...input, cached });
worker.postMessage({ id, safeIntegers, ...input, cached, received });
});
// A posted write runs to the end, as it did on the event loop; the caller and its lock
// must not move on while the worker is still applying it. Reads stay cancellable.
Expand Down
Loading