@retaw106/mmc-oc 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +12 -1
- package/dist/config/schema/magic-context.d.ts +33 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/config-init.js +12 -1
- package/dist/{context-wt7g4f8k.js → context-c72brxe5.js} +717 -206
- package/dist/features/magic-context/memory-maintainer/launcher.d.ts +13 -4
- package/dist/features/magic-context/memory-maintainer/launcher.d.ts.map +1 -1
- package/dist/features/magic-context/memory-maintainer/manual-commands.d.ts.map +1 -1
- package/dist/features/magic-context/memory-maintainer/model-chain.d.ts +47 -0
- package/dist/features/magic-context/memory-maintainer/model-chain.d.ts.map +1 -0
- package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts +17 -4
- package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts.map +1 -1
- package/dist/features/magic-context/memory-maintainer/pipeline.d.ts +6 -0
- package/dist/features/magic-context/memory-maintainer/pipeline.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +4 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +57 -2
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts +34 -0
- package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts.map +1 -0
- package/dist/hooks/magic-context/apply-operations.d.ts +3 -1
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -2
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts +4 -0
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger-error.d.ts +11 -0
- package/dist/hooks/magic-context/compartment-trigger-error.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +1 -0
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
- package/dist/hooks/magic-context/degraded-pass-refusal.d.ts +2 -0
- package/dist/hooks/magic-context/degraded-pass-refusal.d.ts.map +1 -1
- package/dist/hooks/magic-context/edit-marker.d.ts +2 -0
- package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/pending-drop-eligibility.d.ts +14 -0
- package/dist/hooks/magic-context/pending-drop-eligibility.d.ts.map +1 -0
- package/dist/hooks/magic-context/pending-materialization.d.ts +2 -0
- package/dist/hooks/magic-context/pending-materialization.d.ts.map +1 -1
- package/dist/hooks/magic-context/provider-visible-parts.d.ts +10 -4
- package/dist/hooks/magic-context/provider-visible-parts.d.ts.map +1 -1
- package/dist/hooks/magic-context/render-options.d.ts +19 -0
- package/dist/hooks/magic-context/render-options.d.ts.map +1 -0
- package/dist/hooks/magic-context/supersession-reclaim.d.ts +5 -3
- package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts +18 -0
- package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts.map +1 -0
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +8 -4
- package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-reclaim.d.ts +5 -2
- package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +2 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +0 -7
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/unresolved-history-boundary.d.ts +5 -1
- package/dist/hooks/magic-context/unresolved-history-boundary.d.ts.map +1 -1
- package/dist/{index-3gj1ak4c.js → index-27ddyx39.js} +1 -1
- package/dist/{index-z2w1eq3y.js → index-48e9znrc.js} +491 -197
- package/dist/{index-z2g20msd.js → index-azbmnekv.js} +4 -4
- package/dist/{index-czba8tw3.js → index-dwh99z66.js} +33 -10
- package/dist/{index-kw21z5pe.js → index-kahkxcqw.js} +7005 -6903
- package/dist/{index-a5mw0xp3.js → index-r7s7d350.js} +1 -1
- package/dist/{index-18yrysyj.js → index-ykq24x1n.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{message-time-backfill-sykevdax.js → message-time-backfill-j96x1sab.js} +1 -1
- package/dist/model-resolution-cvyrrcjw.js +8 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{read-session-chunk-863d6c06.js → read-session-chunk-t8bg6wkz.js} +3 -3
- package/dist/{recomp-orchestrator-wrjy3qym.js → recomp-orchestrator-k760qy1n.js} +6 -6
- package/dist/{send-session-notification-cz6ev088.js → send-session-notification-njjf3b4s.js} +3 -3
- package/dist/shared/model-suggestion-retry.d.ts +21 -0
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/prompt-surface-runtime.d.ts +5 -0
- package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
- package/dist/shared/reclaim-distance.d.ts +33 -0
- package/dist/shared/reclaim-distance.d.ts.map +1 -0
- package/dist/shared/sqlite.d.ts +2 -0
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/stable-json.d.ts +7 -0
- package/dist/shared/stable-json.d.ts.map +1 -1
- package/dist/shared/tail-hygiene-json.d.ts +22 -0
- package/dist/shared/tail-hygiene-json.d.ts.map +1 -0
- package/dist/shared/user-facing-codes.d.ts +2 -2
- package/dist/v2/hidden-completion.d.ts +20 -1
- package/dist/v2/hidden-completion.d.ts.map +1 -1
- package/dist/v2/hooks/context.d.ts.map +1 -1
- package/dist/v2/hooks/hidden-child.d.ts +22 -1
- package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
- package/dist/v2/server.js +8312 -7354
- package/dist/v2/store-reader.d.ts +27 -1
- package/dist/v2/store-reader.d.ts.map +1 -1
- package/dist/web/main.js +34 -10
- package/dist/{wrapup-orchestrator-bmybc5p5.js → wrapup-orchestrator-b2gswatj.js} +6 -6
- package/package.json +1 -1
- package/src/config/latch-permanence-guard.test.ts +4 -0
- package/src/config/project-security.test.ts +17 -0
- package/src/config/schema/magic-context.test.ts +50 -0
- package/src/config/schema/magic-context.ts +34 -1
- package/src/shared/model-suggestion-retry.ts +5 -1
- package/src/shared/prompt-surface-runtime.ts +12 -0
- package/src/shared/reclaim-distance.test.ts +111 -0
- package/src/shared/reclaim-distance.ts +91 -0
- package/src/shared/root-async-context.probe.mjs +36 -0
- package/src/shared/root-async-context.test.ts +49 -0
- package/src/shared/sqlite-busy-budget-review.test.ts +291 -0
- package/src/shared/sqlite-foreground-wait.test.ts +237 -1
- package/src/shared/sqlite.ts +55 -14
- package/src/shared/stable-json.ts +56 -14
- package/src/shared/tail-hygiene-json.test.ts +117 -0
- package/src/shared/tail-hygiene-json.ts +217 -0
- package/src/shared/user-facing-codes.ts +2 -2
- package/dist/model-resolution-wazv26n0.js +0 -6
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { setTimeout as delay, setImmediate as tick } from "node:timers/promises";
|
|
4
|
+
import { Worker } from "node:worker_threads";
|
|
5
|
+
import { inRootAsyncContext } from "./sqlite";
|
|
6
|
+
|
|
7
|
+
const scope = new AsyncLocalStorage();
|
|
8
|
+
const gc = globalThis.Bun ? () => Bun.gc(true) : globalThis.gc;
|
|
9
|
+
const references = {};
|
|
10
|
+
const resources = [];
|
|
11
|
+
const timerStores = {};
|
|
12
|
+
const ambientTick = () => { timerStores.ambient = scope.getStore() !== undefined; };
|
|
13
|
+
const rootTick = () => { timerStores.root = scope.getStore() !== undefined; };
|
|
14
|
+
function create(name, root, worker) {
|
|
15
|
+
const payload = { synthetic: name };
|
|
16
|
+
references[name] = new WeakRef(payload);
|
|
17
|
+
scope.run(payload, () => {
|
|
18
|
+
const make = worker
|
|
19
|
+
? () => new Worker(new URL("worker.mjs", import.meta.url))
|
|
20
|
+
: () => setInterval(root ? rootTick : ambientTick, 5);
|
|
21
|
+
resources.push(root ? inRootAsyncContext(make) : make());
|
|
22
|
+
assert.equal(scope.getStore(), payload, "Root execution must restore the caller's store");
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
create("ambientWorker", false, true);
|
|
26
|
+
create("rootWorker", true, true);
|
|
27
|
+
create("ambientTimer", false, false);
|
|
28
|
+
create("rootTimer", true, false);
|
|
29
|
+
await delay(30);
|
|
30
|
+
for (let i = 0; i < 12; i++) { await tick(); gc(); await tick(); }
|
|
31
|
+
const retained = Object.fromEntries(Object.entries(references).map(([key, ref]) => [key, Number(ref.deref() !== undefined)]));
|
|
32
|
+
for (const resource of resources) {
|
|
33
|
+
if (resource instanceof Worker) await resource.terminate();
|
|
34
|
+
else clearInterval(resource);
|
|
35
|
+
}
|
|
36
|
+
console.log(JSON.stringify({ runtime: globalThis.Bun ? `Bun ${Bun.version}` : process.version, timerStores, retained }));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createTestTempDirFromPath } from "./test-temp-dir";
|
|
6
|
+
|
|
7
|
+
for (const runtime of ["Node", "Bun"] as const) {
|
|
8
|
+
test(`root resources do not inherit request stores (${runtime})`, async () => {
|
|
9
|
+
const directory = createTestTempDirFromPath(join(tmpdir(), "root-context-"));
|
|
10
|
+
try {
|
|
11
|
+
const entry = join(directory, "probe.mjs");
|
|
12
|
+
const build = await Bun.build({
|
|
13
|
+
entrypoints: [new URL("root-async-context.probe.mjs", import.meta.url).pathname],
|
|
14
|
+
target: "node",
|
|
15
|
+
format: "esm",
|
|
16
|
+
external: ["bun:sqlite", "node:sqlite"],
|
|
17
|
+
});
|
|
18
|
+
expect(build.success).toBe(true);
|
|
19
|
+
await writeFile(entry, await build.outputs[0].text());
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(directory, "worker.mjs"),
|
|
22
|
+
`import { parentPort } from 'node:worker_threads'; parentPort.on('message', () => {});`,
|
|
23
|
+
);
|
|
24
|
+
const child = Bun.spawn(
|
|
25
|
+
[...(runtime === "Node" ? ["node", "--expose-gc"] : [process.execPath]), entry],
|
|
26
|
+
{
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
const [stdout, stderr, exit] = await Promise.all([
|
|
33
|
+
new Response(child.stdout).text(),
|
|
34
|
+
new Response(child.stderr).text(),
|
|
35
|
+
child.exited,
|
|
36
|
+
]);
|
|
37
|
+
expect(exit, stderr).toBe(0);
|
|
38
|
+
const result = JSON.parse(stdout);
|
|
39
|
+
console.log(`${runtime} ALS propagation: ${stdout.trim()}`);
|
|
40
|
+
expect(result.timerStores).toEqual({ ambient: true, root: false });
|
|
41
|
+
expect(result.retained.rootWorker).toBe(0);
|
|
42
|
+
expect(result.retained.rootTimer).toBe(0);
|
|
43
|
+
expect(result.retained.ambientTimer).toBe(1);
|
|
44
|
+
if (runtime === "Node") expect(result.retained.ambientWorker).toBe(1);
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(directory, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
Database,
|
|
7
|
+
guardSqliteTransformPass,
|
|
8
|
+
SqliteAcquisitionBusyError,
|
|
9
|
+
withSqliteBackgroundWriter,
|
|
10
|
+
withSqliteTransformPass,
|
|
11
|
+
} from "./sqlite";
|
|
12
|
+
import { createTestTempDirFromPath } from "./test-temp-dir";
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const dir = createTestTempDirFromPath(join(tmpdir(), "mc-busy-review-"));
|
|
16
|
+
const path = join(dir, "context.db");
|
|
17
|
+
const db = new Database(path);
|
|
18
|
+
db.exec("PRAGMA busy_timeout=5000; CREATE TABLE result(id INTEGER PRIMARY KEY, n INTEGER)");
|
|
19
|
+
db.exec("INSERT INTO result VALUES (1, 0)");
|
|
20
|
+
return {
|
|
21
|
+
db,
|
|
22
|
+
path,
|
|
23
|
+
close() {
|
|
24
|
+
db.close();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function lock(path: string, mode: "read" | "write" | "exclusive", holdMs: number) {
|
|
31
|
+
const child = Bun.spawn(
|
|
32
|
+
[
|
|
33
|
+
"bun",
|
|
34
|
+
"--eval",
|
|
35
|
+
`import { Database } from "bun:sqlite";
|
|
36
|
+
const db = new Database(process.argv[1]);
|
|
37
|
+
db.exec(process.argv[2] === "read" ? "BEGIN; SELECT * FROM result"
|
|
38
|
+
: process.argv[2] === "write" ? "BEGIN IMMEDIATE" : "BEGIN EXCLUSIVE");
|
|
39
|
+
console.log("LOCKED");
|
|
40
|
+
Bun.sleepSync(Number(process.argv[3]));
|
|
41
|
+
db.exec("COMMIT");
|
|
42
|
+
db.close();`,
|
|
43
|
+
path,
|
|
44
|
+
mode,
|
|
45
|
+
String(holdMs),
|
|
46
|
+
],
|
|
47
|
+
{ stdout: "pipe", stderr: "pipe", windowsHide: true },
|
|
48
|
+
);
|
|
49
|
+
const reader = child.stdout.getReader();
|
|
50
|
+
let output = "";
|
|
51
|
+
while (!output.includes("LOCKED\n")) {
|
|
52
|
+
const chunk = await reader.read();
|
|
53
|
+
if (chunk.done) {
|
|
54
|
+
throw new Error(`locker failed: ${await new Response(child.stderr).text()}`);
|
|
55
|
+
}
|
|
56
|
+
output += new TextDecoder().decode(chunk.value);
|
|
57
|
+
}
|
|
58
|
+
reader.releaseLock();
|
|
59
|
+
return { exited: child.exited };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const method of ["run", "get", "all", "exec"] as const) {
|
|
63
|
+
test(`a rollback-journal commit BUSY retries ${method} exactly once after the reader leaves`, async () => {
|
|
64
|
+
const { db, path, close } = fixture();
|
|
65
|
+
const sql = "UPDATE result SET n = n + 1 WHERE id = 1";
|
|
66
|
+
const statement = db.prepare(`${sql} RETURNING n`);
|
|
67
|
+
const { exited } = await lock(path, "read", 150);
|
|
68
|
+
try {
|
|
69
|
+
await withSqliteTransformPass(() => {
|
|
70
|
+
if (method === "exec") db.exec(sql);
|
|
71
|
+
else statement[method]();
|
|
72
|
+
});
|
|
73
|
+
expect(db.prepare("SELECT * FROM result").all()).toEqual([{ id: 1, n: 1 }]);
|
|
74
|
+
expect(db.inTransaction).toBe(false);
|
|
75
|
+
} finally {
|
|
76
|
+
expect(await exited).toBe(0);
|
|
77
|
+
close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const scope of ["foreground", "background", "unscoped"] as const) {
|
|
83
|
+
test(`exec in ${scope} does not wait on an unrelated attached database schema`, async () => {
|
|
84
|
+
const main = fixture();
|
|
85
|
+
const aux = fixture();
|
|
86
|
+
main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
|
|
87
|
+
const { exited } = await lock(aux.path, "exclusive", 2500);
|
|
88
|
+
try {
|
|
89
|
+
const started = performance.now();
|
|
90
|
+
const operation = () => main.db.exec("UPDATE main.result SET n = n + 1");
|
|
91
|
+
if (scope === "foreground") {
|
|
92
|
+
await withSqliteTransformPass(() => {
|
|
93
|
+
guardSqliteTransformPass({ assert() {}, remainingMs: () => 75 });
|
|
94
|
+
operation();
|
|
95
|
+
});
|
|
96
|
+
} else if (scope === "background") withSqliteBackgroundWriter(operation);
|
|
97
|
+
else operation();
|
|
98
|
+
expect(performance.now() - started).toBeLessThan(1500);
|
|
99
|
+
expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 1 });
|
|
100
|
+
} finally {
|
|
101
|
+
expect(await exited).toBe(0);
|
|
102
|
+
main.close();
|
|
103
|
+
aux.close();
|
|
104
|
+
}
|
|
105
|
+
}, 10_000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const location of ["main", "aux"] as const) {
|
|
109
|
+
test(`BUSY retries leave single-statement effects atomic in ${location}, including AFTER triggers`, async () => {
|
|
110
|
+
const main = fixture();
|
|
111
|
+
const aux = fixture();
|
|
112
|
+
main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
|
|
113
|
+
main.db.exec("CREATE TABLE audit(n INTEGER)");
|
|
114
|
+
main.db.exec(`CREATE TEMP TRIGGER audit_insert AFTER INSERT ON ${location}.result
|
|
115
|
+
BEGIN INSERT INTO audit VALUES (new.n); END`);
|
|
116
|
+
main.db.exec(`CREATE TEMP TRIGGER audit_update AFTER UPDATE ON ${location}.result
|
|
117
|
+
BEGIN INSERT INTO audit VALUES (new.n); END`);
|
|
118
|
+
main.db.exec(`CREATE TEMP TRIGGER audit_delete AFTER DELETE ON ${location}.result
|
|
119
|
+
BEGIN INSERT INTO audit VALUES (old.n); END`);
|
|
120
|
+
const forms = [
|
|
121
|
+
`INSERT INTO ${location}.result VALUES (2, 10)`,
|
|
122
|
+
`INSERT OR IGNORE INTO ${location}.result VALUES (2, 20)`,
|
|
123
|
+
`INSERT OR REPLACE INTO ${location}.result VALUES (2, 30)`,
|
|
124
|
+
`INSERT INTO ${location}.result VALUES (2, 40) ON CONFLICT(id) DO UPDATE SET n=n+1`,
|
|
125
|
+
`UPDATE ${location}.result SET n=n+1 WHERE id=2`,
|
|
126
|
+
`DELETE FROM ${location}.result WHERE id=2`,
|
|
127
|
+
];
|
|
128
|
+
try {
|
|
129
|
+
for (const sql of forms) {
|
|
130
|
+
const statement = main.db.prepare(`${sql} RETURNING *`);
|
|
131
|
+
const blocker = new Database(location === "main" ? main.path : aux.path);
|
|
132
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
133
|
+
main.db.exec("PRAGMA busy_timeout=0");
|
|
134
|
+
const before = main.db
|
|
135
|
+
.prepare(`SELECT * FROM ${location}.result ORDER BY id`)
|
|
136
|
+
.all();
|
|
137
|
+
const beforeAudit = main.db.prepare("SELECT * FROM audit").all();
|
|
138
|
+
try {
|
|
139
|
+
expect(() => statement.all()).toThrow("locked");
|
|
140
|
+
expect(
|
|
141
|
+
main.db.prepare(`SELECT * FROM ${location}.result ORDER BY id`).all(),
|
|
142
|
+
).toEqual(before);
|
|
143
|
+
expect(main.db.prepare("SELECT * FROM audit").all()).toEqual(beforeAudit);
|
|
144
|
+
expect(main.db.inTransaction).toBe(false);
|
|
145
|
+
} finally {
|
|
146
|
+
blocker.exec("ROLLBACK");
|
|
147
|
+
blocker.close();
|
|
148
|
+
}
|
|
149
|
+
const { exited } = await lock(
|
|
150
|
+
location === "main" ? main.path : aux.path,
|
|
151
|
+
"write",
|
|
152
|
+
60,
|
|
153
|
+
);
|
|
154
|
+
try {
|
|
155
|
+
await withSqliteTransformPass(() => statement.all());
|
|
156
|
+
} finally {
|
|
157
|
+
expect(await exited).toBe(0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
expect(main.db.prepare(`SELECT * FROM ${location}.result ORDER BY id`).all()).toEqual([
|
|
161
|
+
{ id: 1, n: 0 },
|
|
162
|
+
]);
|
|
163
|
+
expect(main.db.prepare("SELECT * FROM audit").all()).toEqual([
|
|
164
|
+
{ n: 10 },
|
|
165
|
+
{ n: 30 },
|
|
166
|
+
{ n: 31 },
|
|
167
|
+
{ n: 32 },
|
|
168
|
+
{ n: 32 },
|
|
169
|
+
]);
|
|
170
|
+
} finally {
|
|
171
|
+
main.close();
|
|
172
|
+
aux.close();
|
|
173
|
+
}
|
|
174
|
+
}, 10_000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
test("an attached DML-only committed prefix is not replayed after a later BUSY", () => {
|
|
178
|
+
const main = fixture();
|
|
179
|
+
const free = fixture();
|
|
180
|
+
const blocked = fixture();
|
|
181
|
+
try {
|
|
182
|
+
main.db.prepare("ATTACH DATABASE ? AS free").run(free.path);
|
|
183
|
+
main.db.prepare("ATTACH DATABASE ? AS blocked").run(blocked.path);
|
|
184
|
+
blocked.db.exec("BEGIN IMMEDIATE");
|
|
185
|
+
withSqliteTransformPass(() => {
|
|
186
|
+
expect(() =>
|
|
187
|
+
main.db.exec("UPDATE free.result SET n=n+1; UPDATE blocked.result SET n=n+1"),
|
|
188
|
+
).toThrow(SqliteAcquisitionBusyError);
|
|
189
|
+
});
|
|
190
|
+
expect(main.db.prepare("SELECT n FROM free.result").get()).toEqual({ n: 1 });
|
|
191
|
+
expect(main.db.prepare("SELECT n FROM blocked.result").get()).toEqual({ n: 0 });
|
|
192
|
+
} finally {
|
|
193
|
+
blocked.db.exec("ROLLBACK");
|
|
194
|
+
main.close();
|
|
195
|
+
free.close();
|
|
196
|
+
blocked.close();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
for (const location of ["main", "free"] as const) {
|
|
201
|
+
test(`an ignored AUTOINCREMENT prefix in ${location} is committed only once before a later BUSY`, () => {
|
|
202
|
+
const main = fixture();
|
|
203
|
+
const free = fixture();
|
|
204
|
+
const blocked = fixture();
|
|
205
|
+
try {
|
|
206
|
+
main.db.prepare("ATTACH DATABASE ? AS free").run(free.path);
|
|
207
|
+
main.db.exec(`CREATE TABLE ${location}.numbered(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT UNIQUE);
|
|
208
|
+
INSERT INTO ${location}.numbered(value) VALUES ('existing')`);
|
|
209
|
+
main.db.prepare("ATTACH DATABASE ? AS blocked").run(blocked.path);
|
|
210
|
+
blocked.db.exec("BEGIN IMMEDIATE");
|
|
211
|
+
const before = main.db.prepare("SELECT total_changes() AS n").get();
|
|
212
|
+
withSqliteTransformPass(() => {
|
|
213
|
+
guardSqliteTransformPass({ assert() {}, remainingMs: () => 50 });
|
|
214
|
+
expect(() =>
|
|
215
|
+
main.db.exec(
|
|
216
|
+
`INSERT OR IGNORE INTO ${location}.numbered(value) VALUES ('existing'); UPDATE blocked.result SET n=n+1`,
|
|
217
|
+
),
|
|
218
|
+
).toThrow(SqliteAcquisitionBusyError);
|
|
219
|
+
});
|
|
220
|
+
expect(main.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
|
|
221
|
+
expect(
|
|
222
|
+
main.db
|
|
223
|
+
.prepare(`SELECT seq FROM ${location}.sqlite_sequence WHERE name='numbered'`)
|
|
224
|
+
.get(),
|
|
225
|
+
).toEqual({ seq: 2 });
|
|
226
|
+
expect(main.db.prepare(`SELECT * FROM ${location}.numbered`).all()).toEqual([
|
|
227
|
+
{ id: 1, value: "existing" },
|
|
228
|
+
]);
|
|
229
|
+
} finally {
|
|
230
|
+
blocked.db.exec("ROLLBACK");
|
|
231
|
+
main.close();
|
|
232
|
+
free.close();
|
|
233
|
+
blocked.close();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("a WAL BUSY_SNAPSHOT in an explicit read transaction is not retried", () => {
|
|
239
|
+
const { db, path, close } = fixture();
|
|
240
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
241
|
+
const other = new Database(path);
|
|
242
|
+
try {
|
|
243
|
+
db.exec("BEGIN; SELECT * FROM result");
|
|
244
|
+
other.exec("UPDATE result SET n=n+1");
|
|
245
|
+
withSqliteTransformPass(() => {
|
|
246
|
+
expect(() => db.prepare("UPDATE result SET n=n+10").run()).toThrow("locked");
|
|
247
|
+
});
|
|
248
|
+
expect(db.inTransaction).toBe(true);
|
|
249
|
+
db.exec("ROLLBACK");
|
|
250
|
+
expect(db.prepare("SELECT n FROM result").get()).toEqual({ n: 1 });
|
|
251
|
+
} finally {
|
|
252
|
+
if (db.inTransaction) db.exec("ROLLBACK");
|
|
253
|
+
other.close();
|
|
254
|
+
close();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a BUSY from an AFTER trigger writing an attached database rolls back the outer statement", async () => {
|
|
259
|
+
const main = fixture();
|
|
260
|
+
const aux = fixture();
|
|
261
|
+
main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
|
|
262
|
+
// The TEMP trigger resolves an unqualified table in main first; give the
|
|
263
|
+
// attached target its own name so the trigger must write the other file.
|
|
264
|
+
main.db.exec("ALTER TABLE aux.result RENAME TO attached_result");
|
|
265
|
+
main.db.exec(`CREATE TEMP TRIGGER update_aux AFTER UPDATE ON main.result
|
|
266
|
+
BEGIN UPDATE attached_result SET n=n+1 WHERE id=1; END`);
|
|
267
|
+
const statement = main.db.prepare("UPDATE main.result SET n=n+1 RETURNING n");
|
|
268
|
+
const blocker = new Database(aux.path);
|
|
269
|
+
try {
|
|
270
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
271
|
+
main.db.exec("PRAGMA busy_timeout=0");
|
|
272
|
+
expect(() => statement.all()).toThrow("locked");
|
|
273
|
+
expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 0 });
|
|
274
|
+
expect(main.db.prepare("SELECT n FROM aux.attached_result").get()).toEqual({ n: 0 });
|
|
275
|
+
expect(main.db.inTransaction).toBe(false);
|
|
276
|
+
blocker.exec("ROLLBACK");
|
|
277
|
+
const { exited } = await lock(aux.path, "write", 100);
|
|
278
|
+
try {
|
|
279
|
+
await withSqliteTransformPass(() => statement.all());
|
|
280
|
+
} finally {
|
|
281
|
+
expect(await exited).toBe(0);
|
|
282
|
+
}
|
|
283
|
+
expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 1 });
|
|
284
|
+
expect(main.db.prepare("SELECT n FROM aux.attached_result").get()).toEqual({ n: 1 });
|
|
285
|
+
} finally {
|
|
286
|
+
if (blocker.inTransaction) blocker.exec("ROLLBACK");
|
|
287
|
+
blocker.close();
|
|
288
|
+
main.close();
|
|
289
|
+
aux.close();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
withPrivilegedWriter,
|
|
12
12
|
withSqliteTransformPass,
|
|
13
13
|
} from "./sqlite";
|
|
14
|
+
import { startSqliteWriteLocker } from "./sqlite-write-locker-test-support";
|
|
14
15
|
import { createTestTempDirFromPath } from "./test-temp-dir";
|
|
15
16
|
|
|
16
17
|
function fixture() {
|
|
@@ -66,3 +67,238 @@ for (const mode of ["BEGIN", "autocommit"]) {
|
|
|
66
67
|
}
|
|
67
68
|
});
|
|
68
69
|
}
|
|
70
|
+
|
|
71
|
+
// Fork shape: upstream drives the "slow uncontended SQL preserves admission"
|
|
72
|
+
// pair through the OC1 messages-transform handler (deleted in this fork — see
|
|
73
|
+
// the note above); the lease invariant it protects is asserted here against
|
|
74
|
+
// the core primitives instead.
|
|
75
|
+
test("uncontended autocommit work does not consume the foreground wait budget", async () => {
|
|
76
|
+
const { db, path, close } = fixture();
|
|
77
|
+
const blocker = new Database(path);
|
|
78
|
+
const timeouts: number[] = [];
|
|
79
|
+
const nativeExec = db.exec.bind(db);
|
|
80
|
+
const exec = spyOn(db, "exec").mockImplementation((sql) => {
|
|
81
|
+
const timeout = /^PRAGMA busy_timeout=(\d+)$/.exec(sql);
|
|
82
|
+
if (timeout) timeouts.push(Number(timeout[1]));
|
|
83
|
+
return nativeExec(sql);
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await withSqliteTransformPass(async () => {
|
|
87
|
+
guardSqliteTransformPass({ assert() {}, remainingMs: () => 30_000 });
|
|
88
|
+
// Spend ~1.5s inside successful, uncontended SQL. Recursive CTEs
|
|
89
|
+
// keep this independent of CPU speed and UDF APIs.
|
|
90
|
+
const cte =
|
|
91
|
+
"WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<1000000)";
|
|
92
|
+
const slowWrite = db.prepare(
|
|
93
|
+
`${cte} INSERT INTO result SELECT sum(x) FROM n RETURNING value`,
|
|
94
|
+
);
|
|
95
|
+
const slowRead = db.prepare(`${cte} SELECT sum(x) FROM n`);
|
|
96
|
+
const started = performance.now();
|
|
97
|
+
do {
|
|
98
|
+
slowWrite.all();
|
|
99
|
+
slowRead.all();
|
|
100
|
+
} while (performance.now() - started < 1500);
|
|
101
|
+
// The contended retry must still see (nearly) the full budget.
|
|
102
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
103
|
+
timeouts.length = 0;
|
|
104
|
+
expect(() => db.prepare("INSERT INTO result VALUES ('blocked')").run()).toThrow(
|
|
105
|
+
SqliteAcquisitionBusyError,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
expect(timeouts[0]).toBe(0);
|
|
109
|
+
expect(timeouts[1]).toBeGreaterThanOrEqual(1900);
|
|
110
|
+
} finally {
|
|
111
|
+
exec.mockRestore();
|
|
112
|
+
if (blocker.inTransaction) blocker.exec("ROLLBACK");
|
|
113
|
+
blocker.close();
|
|
114
|
+
close();
|
|
115
|
+
}
|
|
116
|
+
}, 30_000);
|
|
117
|
+
|
|
118
|
+
test("BUSY autocommit probes do not double-apply INSERT OR IGNORE, UPSERT or RETURNING", async () => {
|
|
119
|
+
const { db, path, close } = fixture();
|
|
120
|
+
db.exec(
|
|
121
|
+
"CREATE TABLE counter(id INTEGER PRIMARY KEY, n INTEGER); INSERT INTO counter VALUES (1, 0)",
|
|
122
|
+
);
|
|
123
|
+
const forms = [
|
|
124
|
+
["INSERT OR IGNORE INTO counter VALUES (2, 1)", "run"],
|
|
125
|
+
["INSERT INTO counter VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET n = n + 1", "run"],
|
|
126
|
+
["UPDATE counter SET n = n + 1 WHERE id = 1 RETURNING n", "get"],
|
|
127
|
+
[
|
|
128
|
+
"INSERT INTO counter VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET n = n + 1 RETURNING n",
|
|
129
|
+
"all",
|
|
130
|
+
],
|
|
131
|
+
] as const;
|
|
132
|
+
try {
|
|
133
|
+
for (const [sql, method] of forms) {
|
|
134
|
+
const blocker = new Database(path);
|
|
135
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
136
|
+
db.exec("PRAGMA busy_timeout=0");
|
|
137
|
+
const before = db.prepare("SELECT * FROM counter ORDER BY id").all();
|
|
138
|
+
try {
|
|
139
|
+
expect(() => db.prepare(sql)[method]()).toThrow("locked");
|
|
140
|
+
expect(db.prepare("SELECT * FROM counter ORDER BY id").all()).toEqual(before);
|
|
141
|
+
expect(db.inTransaction).toBe(false);
|
|
142
|
+
} finally {
|
|
143
|
+
blocker.exec("ROLLBACK");
|
|
144
|
+
blocker.close();
|
|
145
|
+
}
|
|
146
|
+
const locker = await startSqliteWriteLocker(path, 100);
|
|
147
|
+
try {
|
|
148
|
+
await withSqliteTransformPass(() => db.prepare(sql)[method]());
|
|
149
|
+
} finally {
|
|
150
|
+
expect(await locker.exited).toBe(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
expect(db.prepare("SELECT * FROM counter ORDER BY id").all()).toEqual([
|
|
154
|
+
{ id: 1, n: 3 },
|
|
155
|
+
{ id: 2, n: 1 },
|
|
156
|
+
]);
|
|
157
|
+
} finally {
|
|
158
|
+
close();
|
|
159
|
+
}
|
|
160
|
+
}, 30_000);
|
|
161
|
+
|
|
162
|
+
test("many contended autocommit writes share at most two seconds of lock waiting", async () => {
|
|
163
|
+
const { db, path, close } = fixture();
|
|
164
|
+
const blocker = new Database(path);
|
|
165
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
166
|
+
const timeouts: number[] = [];
|
|
167
|
+
const nativeExec = db.exec.bind(db);
|
|
168
|
+
const exec = spyOn(db, "exec").mockImplementation((sql) => {
|
|
169
|
+
const timeout = /^PRAGMA busy_timeout=(\d+)$/.exec(sql);
|
|
170
|
+
if (timeout) timeouts.push(Number(timeout[1]));
|
|
171
|
+
return nativeExec(sql);
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await withSqliteTransformPass(() => {
|
|
175
|
+
// Use real BUSY failures but a controlled accounting clock: each
|
|
176
|
+
// retry costs 400ms regardless of runner scheduling. The deadline
|
|
177
|
+
// keeps the real waits short without hiding lease exhaustion.
|
|
178
|
+
guardSqliteTransformPass({ assert() {}, remainingMs: () => 10 });
|
|
179
|
+
let now = 0;
|
|
180
|
+
const clock = spyOn(performance, "now").mockImplementation(() => (now += 400));
|
|
181
|
+
try {
|
|
182
|
+
for (let i = 0; i < 8; i++) {
|
|
183
|
+
expect(() => db.prepare("INSERT INTO result VALUES ('one')").run()).toThrow(
|
|
184
|
+
SqliteAcquisitionBusyError,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
clock.mockRestore();
|
|
189
|
+
}
|
|
190
|
+
// Five charged retries exhaust 2000ms. All later retries must have
|
|
191
|
+
// zero timeout even though the deadline still offers another 10ms.
|
|
192
|
+
expect(timeouts).toEqual([
|
|
193
|
+
...Array.from({ length: 5 }, () => [0, 10, 5000]).flat(),
|
|
194
|
+
...Array.from({ length: 3 }, () => [0, 0, 5000]).flat(),
|
|
195
|
+
]);
|
|
196
|
+
blocker.exec("ROLLBACK");
|
|
197
|
+
db.prepare("INSERT INTO result VALUES ('uncontended')").run();
|
|
198
|
+
});
|
|
199
|
+
expect(db.prepare("SELECT * FROM result").all()).toEqual([{ value: "uncontended" }]);
|
|
200
|
+
} finally {
|
|
201
|
+
exec.mockRestore();
|
|
202
|
+
if (blocker.inTransaction) blocker.exec("ROLLBACK");
|
|
203
|
+
blocker.close();
|
|
204
|
+
close();
|
|
205
|
+
}
|
|
206
|
+
}, 30_000);
|
|
207
|
+
|
|
208
|
+
test("an exec script charges its single attempt without a zero-wait probe", () => {
|
|
209
|
+
const { db, path, close } = fixture();
|
|
210
|
+
const blocker = new Database(path);
|
|
211
|
+
const timeouts: number[] = [];
|
|
212
|
+
const nativeExec = db.exec.bind(db);
|
|
213
|
+
const exec = spyOn(db, "exec").mockImplementation((sql) => {
|
|
214
|
+
const timeout = /^PRAGMA busy_timeout=(\d+)$/.exec(sql);
|
|
215
|
+
if (timeout) timeouts.push(Number(timeout[1]));
|
|
216
|
+
return nativeExec(sql);
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
withSqliteTransformPass(() => {
|
|
220
|
+
let now = 0;
|
|
221
|
+
const clock = spyOn(performance, "now").mockImplementation(() => (now += 2500));
|
|
222
|
+
try {
|
|
223
|
+
db.exec(
|
|
224
|
+
"INSERT INTO result VALUES ('prefix'); INSERT INTO result VALUES ('suffix')",
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
clock.mockRestore();
|
|
228
|
+
}
|
|
229
|
+
blocker.exec("BEGIN IMMEDIATE");
|
|
230
|
+
expect(() => db.prepare("INSERT INTO result VALUES ('blocked')").run()).toThrow(
|
|
231
|
+
SqliteAcquisitionBusyError,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
expect(timeouts).toEqual([2000, 5000, 0, 0, 5000]);
|
|
235
|
+
expect(db.prepare("SELECT * FROM result").all()).toEqual([
|
|
236
|
+
{ value: "prefix" },
|
|
237
|
+
{ value: "suffix" },
|
|
238
|
+
]);
|
|
239
|
+
} finally {
|
|
240
|
+
exec.mockRestore();
|
|
241
|
+
if (blocker.inTransaction) blocker.exec("ROLLBACK");
|
|
242
|
+
blocker.close();
|
|
243
|
+
close();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an explicit transaction keeps its timeout and never probes or retries", () => {
|
|
248
|
+
const { db, path, close } = fixture();
|
|
249
|
+
const other = new Database(path);
|
|
250
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN; SELECT * FROM result");
|
|
251
|
+
other.exec("INSERT INTO result VALUES ('other')");
|
|
252
|
+
const exec = spyOn(db, "exec");
|
|
253
|
+
try {
|
|
254
|
+
withSqliteTransformPass(() => {
|
|
255
|
+
expect(() => db.prepare("INSERT INTO result VALUES ('stale')").run()).toThrow("locked");
|
|
256
|
+
});
|
|
257
|
+
expect(exec).not.toHaveBeenCalled();
|
|
258
|
+
expect(db.inTransaction).toBe(true);
|
|
259
|
+
expect(db.prepare("PRAGMA busy_timeout").get()).toEqual({ timeout: 0 });
|
|
260
|
+
} finally {
|
|
261
|
+
exec.mockRestore();
|
|
262
|
+
if (db.inTransaction) db.exec("ROLLBACK");
|
|
263
|
+
other.close();
|
|
264
|
+
close();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("an autocommit exec script never replays a committed prefix after BUSY", () => {
|
|
269
|
+
const main = fixture();
|
|
270
|
+
const auxiliary = fixture();
|
|
271
|
+
const free = fixture();
|
|
272
|
+
try {
|
|
273
|
+
main.db.prepare("ATTACH DATABASE ? AS aux").run(auxiliary.path);
|
|
274
|
+
main.db.prepare("ATTACH DATABASE ? AS free").run(free.path);
|
|
275
|
+
auxiliary.db.exec("BEGIN IMMEDIATE");
|
|
276
|
+
withSqliteTransformPass(() => {
|
|
277
|
+
guardSqliteTransformPass({ assert() {}, remainingMs: () => 100 });
|
|
278
|
+
expect(() =>
|
|
279
|
+
main.db.exec(
|
|
280
|
+
"INSERT INTO main.result VALUES ('prefix'); INSERT INTO aux.result VALUES ('blocked')",
|
|
281
|
+
),
|
|
282
|
+
).toThrow(SqliteAcquisitionBusyError);
|
|
283
|
+
expect(() =>
|
|
284
|
+
main.db.exec(
|
|
285
|
+
"CREATE TABLE applied_once(id); INSERT INTO aux.result VALUES ('blocked')",
|
|
286
|
+
),
|
|
287
|
+
).toThrow(SqliteAcquisitionBusyError);
|
|
288
|
+
expect(() =>
|
|
289
|
+
main.db.exec(
|
|
290
|
+
"CREATE TABLE free.applied_once(id); INSERT INTO aux.result VALUES ('blocked')",
|
|
291
|
+
),
|
|
292
|
+
).toThrow(SqliteAcquisitionBusyError);
|
|
293
|
+
});
|
|
294
|
+
expect(main.db.prepare("SELECT * FROM main.result").all()).toEqual([{ value: "prefix" }]);
|
|
295
|
+
expect(
|
|
296
|
+
main.db.prepare("SELECT name FROM sqlite_schema WHERE name='applied_once'").all(),
|
|
297
|
+
).toHaveLength(1);
|
|
298
|
+
} finally {
|
|
299
|
+
auxiliary.db.exec("ROLLBACK");
|
|
300
|
+
auxiliary.close();
|
|
301
|
+
main.close();
|
|
302
|
+
free.close();
|
|
303
|
+
}
|
|
304
|
+
});
|