@vellumai/assistant 0.10.3-dev.202606271934.9783ef3 → 0.10.3-dev.202606272129.e87b552
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/package.json +1 -1
- package/src/__tests__/processing-flag-persist-failure.test.ts +280 -0
- package/src/daemon/conversation.ts +14 -5
- package/src/daemon/lifecycle.ts +2 -16
- package/src/daemon/shutdown-handlers.ts +20 -3
- package/src/memory/__tests__/db-busy-timeout.test.ts +26 -0
- package/src/memory/db-async-query.ts +13 -2
- package/src/memory/db-connection.ts +11 -1
package/package.json
CHANGED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `Conversation.setProcessing(value)` flips the in-memory flag and
|
|
3
|
+
* then persists it to the `processing_started_at` column. If that DB write
|
|
4
|
+
* throws (e.g. SQLITE_BUSY under contention), the in-memory flag must not be
|
|
5
|
+
* left stranded out of sync with the column — it reverts to its prior value
|
|
6
|
+
* and the error re-throws so callers' existing failure handling still runs.
|
|
7
|
+
*/
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import { CompactionCircuit } from "../agent/compaction-circuit.js";
|
|
11
|
+
import type { AgentEvent } from "../agent/loop.js";
|
|
12
|
+
import type { Message, ProviderResponse } from "../providers/types.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Persistence mock — `setConversationProcessingStartedAt` throws on demand so
|
|
16
|
+
// the test can simulate a locked SQLite write.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let persistShouldThrow = false;
|
|
20
|
+
|
|
21
|
+
mock.module("../util/logger.js", () => ({
|
|
22
|
+
getLogger: () =>
|
|
23
|
+
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../providers/registry.js", () => ({
|
|
27
|
+
getProvider: () => ({ name: "mock-provider" }),
|
|
28
|
+
initializeProviders: async () => {},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module("../config/loader.js", () => ({
|
|
32
|
+
getConfig: () => ({
|
|
33
|
+
ui: {},
|
|
34
|
+
llm: {
|
|
35
|
+
default: {
|
|
36
|
+
provider: "mock-provider",
|
|
37
|
+
model: "mock-model",
|
|
38
|
+
maxTokens: 4096,
|
|
39
|
+
effort: "max" as const,
|
|
40
|
+
speed: "standard" as const,
|
|
41
|
+
temperature: null,
|
|
42
|
+
thinking: { enabled: false, streamThinking: true },
|
|
43
|
+
contextWindow: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
maxInputTokens: 100000,
|
|
46
|
+
targetBudgetRatio: 0.3,
|
|
47
|
+
compactThreshold: 0.8,
|
|
48
|
+
summaryBudgetRatio: 0.05,
|
|
49
|
+
overflowRecovery: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
safetyMarginRatio: 0.05,
|
|
52
|
+
maxAttempts: 3,
|
|
53
|
+
interactiveLatestTurnCompression: "summarize",
|
|
54
|
+
nonInteractiveLatestTurnCompression: "truncate",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
profiles: {},
|
|
59
|
+
callSites: {},
|
|
60
|
+
pricingOverrides: [],
|
|
61
|
+
},
|
|
62
|
+
rateLimit: { maxRequestsPerMinute: 0 },
|
|
63
|
+
memory: { enabled: false },
|
|
64
|
+
}),
|
|
65
|
+
loadRawConfig: () => ({}),
|
|
66
|
+
saveRawConfig: () => {},
|
|
67
|
+
invalidateConfigCache: () => {},
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
mock.module("../prompts/system-prompt.js", () => ({
|
|
71
|
+
buildSystemPrompt: () => "system prompt",
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
mock.module("../config/skills.js", () => ({
|
|
75
|
+
loadSkillCatalog: () => [],
|
|
76
|
+
loadSkillBySelector: () => ({ skill: null }),
|
|
77
|
+
ensureSkillIcon: async () => null,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
mock.module("../config/skill-state.js", () => ({
|
|
81
|
+
resolveSkillStates: () => [],
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
mock.module("../permissions/trust-store.js", () => ({
|
|
85
|
+
addRule: () => {},
|
|
86
|
+
findHighestPriorityRule: () => null,
|
|
87
|
+
clearCache: () => {},
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
mock.module("../security/secret-allowlist.js", () => ({
|
|
91
|
+
resetAllowlist: () => {},
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
mock.module("../memory/conversation-crud.js", () => ({
|
|
95
|
+
setConversationProcessingStartedAt: () => {
|
|
96
|
+
if (persistShouldThrow) {
|
|
97
|
+
throw new Error("database is locked (SQLITE_BUSY)");
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
isConversationProcessing: () => false,
|
|
101
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
102
|
+
setConversationHistoryStrippedAt: () => {},
|
|
103
|
+
provenanceFromTrustContext: () => ({
|
|
104
|
+
source: "user",
|
|
105
|
+
trustContext: undefined,
|
|
106
|
+
}),
|
|
107
|
+
getConversationOriginInterface: () => null,
|
|
108
|
+
getConversationOriginChannel: () => null,
|
|
109
|
+
getMessages: () => [],
|
|
110
|
+
getConversation: () => ({
|
|
111
|
+
id: "conv-1",
|
|
112
|
+
createdAt: Date.parse("2026-03-19T12:00:00.000Z"),
|
|
113
|
+
contextSummary: null,
|
|
114
|
+
contextCompactedMessageCount: 0,
|
|
115
|
+
contextCompactedAt: null,
|
|
116
|
+
totalInputTokens: 0,
|
|
117
|
+
totalOutputTokens: 0,
|
|
118
|
+
totalEstimatedCost: 0,
|
|
119
|
+
}),
|
|
120
|
+
addMessage: () => ({ id: "msg-1" }),
|
|
121
|
+
updateConversationUsage: () => {},
|
|
122
|
+
updateConversationTitle: () => {},
|
|
123
|
+
updateConversationContextWindow: () => {},
|
|
124
|
+
deleteMessageById: () => ({ segmentIds: [], deletedSummaryIds: [] }),
|
|
125
|
+
deleteLastExchange: () => 0,
|
|
126
|
+
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
mock.module("../memory/conversation-queries.js", () => ({
|
|
130
|
+
isLastUserMessageToolResult: () => false,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
mock.module("../memory/attachments-store.js", () => ({
|
|
134
|
+
uploadAttachment: () => ({ id: "att-1" }),
|
|
135
|
+
linkAttachmentToMessage: () => {},
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
mock.module("../memory/retriever.js", () => ({
|
|
139
|
+
buildMemoryRecall: async () => null,
|
|
140
|
+
injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
mock.module("../memory/query-builder.js", () => ({
|
|
144
|
+
buildMemoryQuery: () => "",
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
mock.module("../memory/retrieval-budget.js", () => ({
|
|
148
|
+
computeRecallBudget: () => 0,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
mock.module("../runtime/sync/sync-publisher.js", () => ({
|
|
152
|
+
publishSyncInvalidation: () => {},
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
mock.module("../daemon/message-types/sync.js", () => ({
|
|
156
|
+
conversationMetadataSyncTag: (id: string) => `conversation-metadata:${id}`,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
|
|
160
|
+
ContextWindowManager: class {
|
|
161
|
+
estimateInputTokens() {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
get tokenCountInputs() {
|
|
165
|
+
return { systemPrompt: "", tools: undefined };
|
|
166
|
+
}
|
|
167
|
+
constructor() {}
|
|
168
|
+
updateConfig() {}
|
|
169
|
+
shouldCompact() {
|
|
170
|
+
return { needed: false, estimatedTokens: 0 };
|
|
171
|
+
}
|
|
172
|
+
async maybeCompact() {
|
|
173
|
+
return { compacted: false };
|
|
174
|
+
}
|
|
175
|
+
resetOverflowRecovery() {}
|
|
176
|
+
},
|
|
177
|
+
createContextSummaryMessage: () => ({
|
|
178
|
+
role: "user",
|
|
179
|
+
content: [{ type: "text", text: "summary" }],
|
|
180
|
+
}),
|
|
181
|
+
getSummaryFromContextMessage: () => null,
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
mock.module("../memory/llm-usage-store.js", () => ({
|
|
185
|
+
recordUsageEvent: () => ({ id: "usage-1", createdAt: Date.now() }),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
mock.module("../memory/app-store.js", () => ({
|
|
189
|
+
getApp: () => null,
|
|
190
|
+
updateApp: () => {},
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
mock.module("../agent/loop.js", () => ({
|
|
194
|
+
AgentLoop: class {
|
|
195
|
+
compactionCircuit = new CompactionCircuit("test-conv");
|
|
196
|
+
constructor() {}
|
|
197
|
+
getToolTokenBudget() {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
getResolvedTools() {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
getActiveModel() {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
async run(options: {
|
|
207
|
+
messages: Message[];
|
|
208
|
+
onEvent: (event: AgentEvent) => void;
|
|
209
|
+
}): Promise<Message[]> {
|
|
210
|
+
return options.messages;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
import { Conversation } from "../daemon/conversation.js";
|
|
216
|
+
|
|
217
|
+
function makeConversation(): Conversation {
|
|
218
|
+
const provider = {
|
|
219
|
+
name: "mock",
|
|
220
|
+
async sendMessage(): Promise<ProviderResponse> {
|
|
221
|
+
return {
|
|
222
|
+
content: [],
|
|
223
|
+
model: "mock",
|
|
224
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
225
|
+
stopReason: "end_turn",
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
return new Conversation(
|
|
230
|
+
"conv-1",
|
|
231
|
+
provider,
|
|
232
|
+
"system prompt",
|
|
233
|
+
() => {},
|
|
234
|
+
"/tmp",
|
|
235
|
+
{ maxTokens: 4096 },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
describe("Conversation.setProcessing persistence failure", () => {
|
|
240
|
+
let conversation: Conversation;
|
|
241
|
+
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
persistShouldThrow = false;
|
|
244
|
+
conversation = makeConversation();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("reverts the in-memory flag and re-throws when the DB write fails", () => {
|
|
248
|
+
expect(conversation.isProcessing()).toBe(false);
|
|
249
|
+
|
|
250
|
+
persistShouldThrow = true;
|
|
251
|
+
expect(() => conversation.setProcessing(true)).toThrow(
|
|
252
|
+
"database is locked",
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// The persisted column never landed, so the in-memory flag must not be
|
|
256
|
+
// stranded at `true`.
|
|
257
|
+
expect(conversation.isProcessing()).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("restores the prior value when clearing fails mid-turn", () => {
|
|
261
|
+
conversation.setProcessing(true);
|
|
262
|
+
expect(conversation.isProcessing()).toBe(true);
|
|
263
|
+
|
|
264
|
+
persistShouldThrow = true;
|
|
265
|
+
expect(() => conversation.setProcessing(false)).toThrow(
|
|
266
|
+
"database is locked",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Clear didn't persist, so the flag stays consistent with the column.
|
|
270
|
+
expect(conversation.isProcessing()).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("commits the flag when the DB write succeeds", () => {
|
|
274
|
+
conversation.setProcessing(true);
|
|
275
|
+
expect(conversation.isProcessing()).toBe(true);
|
|
276
|
+
|
|
277
|
+
conversation.setProcessing(false);
|
|
278
|
+
expect(conversation.isProcessing()).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -1352,11 +1352,20 @@ export class Conversation {
|
|
|
1352
1352
|
this._processing = value;
|
|
1353
1353
|
// Persist the cross-process source of truth so out-of-process callers
|
|
1354
1354
|
// (retrospective CLI, future detached workers) can detect mid-turn state
|
|
1355
|
-
// by reading the conversations row directly.
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1355
|
+
// by reading the conversations row directly. If the write fails (e.g.
|
|
1356
|
+
// SQLITE_BUSY), the persisted column keeps its prior value, so revert the
|
|
1357
|
+
// in-memory flag to match rather than stranding `processing = true` in
|
|
1358
|
+
// memory against a NULL column. Re-throw so callers' existing failure
|
|
1359
|
+
// handling still runs.
|
|
1360
|
+
try {
|
|
1361
|
+
setConversationProcessingStartedAt(
|
|
1362
|
+
this.conversationId,
|
|
1363
|
+
value ? Date.now() : null,
|
|
1364
|
+
);
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
this._processing = wasProcessing;
|
|
1367
|
+
throw err;
|
|
1368
|
+
}
|
|
1360
1369
|
if (wasProcessing && !value) {
|
|
1361
1370
|
void publishSyncInvalidation([
|
|
1362
1371
|
conversationMetadataSyncTag(this.conversationId),
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -38,10 +38,7 @@ import { FilingService } from "../filing/filing-service.js";
|
|
|
38
38
|
import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
|
|
39
39
|
import { backfillRelationshipStateIfMissing } from "../home/relationship-state-writer.js";
|
|
40
40
|
import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
|
|
41
|
-
import {
|
|
42
|
-
startGatewayFlagListener,
|
|
43
|
-
stopGatewayFlagListener,
|
|
44
|
-
} from "../ipc/gateway-flag-listener.js";
|
|
41
|
+
import { startGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
|
|
45
42
|
import { getMcpServerManager } from "../mcp/manager.js";
|
|
46
43
|
import {
|
|
47
44
|
getAttachmentsByIds,
|
|
@@ -112,11 +109,7 @@ import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/adaptive-t
|
|
|
112
109
|
import { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
|
|
113
110
|
import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
|
|
114
111
|
import { runWorkspaceMigrations } from "../workspace/migrations/runner.js";
|
|
115
|
-
import {
|
|
116
|
-
cleanupPidFile,
|
|
117
|
-
cleanupPidFileIfOwner,
|
|
118
|
-
writePid,
|
|
119
|
-
} from "./daemon-control.js";
|
|
112
|
+
import { cleanupPidFileIfOwner, writePid } from "./daemon-control.js";
|
|
120
113
|
import {
|
|
121
114
|
evaluateDiskPressureNow,
|
|
122
115
|
startDiskPressureGuard,
|
|
@@ -1440,13 +1433,6 @@ export async function runDaemon(): Promise<void> {
|
|
|
1440
1433
|
getQdrantManager: () => bgRefs.qdrantManager,
|
|
1441
1434
|
mcpManager,
|
|
1442
1435
|
telemetryReporter,
|
|
1443
|
-
cleanupPidFile: () => {
|
|
1444
|
-
stopGatewayFlagListener();
|
|
1445
|
-
stopDiskPressureGuardForLifecycle();
|
|
1446
|
-
stopOrphanReaper();
|
|
1447
|
-
stopEventLoopWatchdog();
|
|
1448
|
-
cleanupPidFile();
|
|
1449
|
-
},
|
|
1450
1436
|
});
|
|
1451
1437
|
|
|
1452
1438
|
log.info(
|
|
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/node";
|
|
|
2
2
|
|
|
3
3
|
import type { FilingService } from "../filing/filing-service.js";
|
|
4
4
|
import type { HeartbeatService } from "../heartbeat/heartbeat-service.js";
|
|
5
|
+
import { stopGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
|
|
5
6
|
import type { McpServerManager } from "../mcp/manager.js";
|
|
6
7
|
import { getSqlite, resetDb } from "../memory/db-connection.js";
|
|
7
8
|
import type { QdrantManager } from "../memory/qdrant-manager.js";
|
|
@@ -12,11 +13,28 @@ import { cleanupShellOutputTempFiles } from "../tools/shared/shell-output.js";
|
|
|
12
13
|
import { getLogger } from "../util/logger.js";
|
|
13
14
|
import { getEnrichmentService } from "../workspace/commit-message-enrichment-service.js";
|
|
14
15
|
import type { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
|
|
16
|
+
import { cleanupPidFile } from "./daemon-control.js";
|
|
17
|
+
import { stopEventLoopWatchdog } from "./event-loop-watchdog.js";
|
|
18
|
+
import { stopDiskPressureGuardForLifecycle } from "./lifecycle.js";
|
|
19
|
+
import { stopOrphanReaper } from "./orphan-reaper.js";
|
|
15
20
|
import type { DaemonServer } from "./server.js";
|
|
16
21
|
import { runShutdownHooks } from "./shutdown-registry.js";
|
|
17
22
|
|
|
18
23
|
const log = getLogger("lifecycle");
|
|
19
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Stop the daemon's background services and remove the PID file. Invoked on
|
|
27
|
+
* both the graceful-shutdown and force-exit-timeout paths so the process never
|
|
28
|
+
* leaves a stale PID file or orphaned timers behind.
|
|
29
|
+
*/
|
|
30
|
+
function stopBackgroundServicesAndCleanupPidFile(): void {
|
|
31
|
+
stopGatewayFlagListener();
|
|
32
|
+
stopDiskPressureGuardForLifecycle();
|
|
33
|
+
stopOrphanReaper();
|
|
34
|
+
stopEventLoopWatchdog();
|
|
35
|
+
cleanupPidFile();
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export interface ShutdownDeps {
|
|
21
39
|
server: DaemonServer;
|
|
22
40
|
workspaceHeartbeat: WorkspaceHeartbeatService;
|
|
@@ -28,7 +46,6 @@ export interface ShutdownDeps {
|
|
|
28
46
|
getQdrantManager: () => QdrantManager | null;
|
|
29
47
|
mcpManager: McpServerManager | null;
|
|
30
48
|
telemetryReporter: { stop(): Promise<void> } | null;
|
|
31
|
-
cleanupPidFile: () => void;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
export function installShutdownHandlers(deps: ShutdownDeps): void {
|
|
@@ -51,7 +68,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
|
|
|
51
68
|
// bump only changes behavior for the stuck-shutdown path.
|
|
52
69
|
const forceTimer = setTimeout(() => {
|
|
53
70
|
log.warn("Graceful shutdown timed out, forcing exit");
|
|
54
|
-
|
|
71
|
+
stopBackgroundServicesAndCleanupPidFile();
|
|
55
72
|
process.exit(1);
|
|
56
73
|
}, 20_000);
|
|
57
74
|
forceTimer.unref();
|
|
@@ -171,7 +188,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
|
|
|
171
188
|
|
|
172
189
|
await Sentry.flush(2000);
|
|
173
190
|
clearTimeout(forceTimer);
|
|
174
|
-
|
|
191
|
+
stopBackgroundServicesAndCleanupPidFile();
|
|
175
192
|
process.exit(exitCode);
|
|
176
193
|
};
|
|
177
194
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks in that every assistant SQLite connection shares one busy_timeout.
|
|
3
|
+
*
|
|
4
|
+
* `runAsyncSqlite` can open an out-of-process (sqlite3 CLI) or transient
|
|
5
|
+
* in-process connection that writes the same database file as the live
|
|
6
|
+
* daemon connection. If those connections don't wait the same amount of
|
|
7
|
+
* time for a lock, a concurrent writer fails immediately with SQLITE_BUSY
|
|
8
|
+
* instead of waiting its turn. The shared `SQLITE_BUSY_TIMEOUT_MS` constant
|
|
9
|
+
* is the single source of truth; this test guards against the live daemon
|
|
10
|
+
* connection drifting away from it.
|
|
11
|
+
*/
|
|
12
|
+
import { expect, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
const { getSqlite, SQLITE_BUSY_TIMEOUT_MS } =
|
|
15
|
+
await import("../db-connection.js");
|
|
16
|
+
const { initializeDb } = await import("../db-init.js");
|
|
17
|
+
|
|
18
|
+
await initializeDb();
|
|
19
|
+
|
|
20
|
+
test("the daemon connection runs with the shared busy_timeout", () => {
|
|
21
|
+
const sqlite = getSqlite();
|
|
22
|
+
const value = (
|
|
23
|
+
sqlite.query("PRAGMA busy_timeout").get() as { timeout: number }
|
|
24
|
+
).timeout;
|
|
25
|
+
expect(value).toBe(SQLITE_BUSY_TIMEOUT_MS);
|
|
26
|
+
});
|
|
@@ -31,7 +31,7 @@ import { Database } from "bun:sqlite";
|
|
|
31
31
|
import { getLogger } from "../util/logger.js";
|
|
32
32
|
import { getDbPath } from "../util/platform.js";
|
|
33
33
|
import { findSqlite3 } from "../util/sqlite3-runtime.js";
|
|
34
|
-
import { getSqlite } from "./db-connection.js";
|
|
34
|
+
import { getSqlite, SQLITE_BUSY_TIMEOUT_MS } from "./db-connection.js";
|
|
35
35
|
|
|
36
36
|
const log = getLogger("db-async-query");
|
|
37
37
|
|
|
@@ -172,8 +172,14 @@ async function runViaCli(
|
|
|
172
172
|
stderr: "pipe",
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// Match the busy_timeout the daemon connection uses so this subprocess
|
|
176
|
+
// waits for — rather than instantly failing against — a lock held by the
|
|
177
|
+
// still-running in-process connection (and vice versa). Prepended to the
|
|
178
|
+
// piped SQL so it takes effect before the statement runs.
|
|
179
|
+
const sqlWithPragma = `PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS};\n${sql}`;
|
|
180
|
+
|
|
175
181
|
// Write the SQL and close stdin so sqlite3 sees EOF and exits.
|
|
176
|
-
proc.stdin.write(
|
|
182
|
+
proc.stdin.write(sqlWithPragma + "\n");
|
|
177
183
|
await proc.stdin.end();
|
|
178
184
|
|
|
179
185
|
// Begin draining the streams immediately so the subprocess never
|
|
@@ -253,6 +259,11 @@ async function runInProcessBlocking(
|
|
|
253
259
|
let sqlite: Database;
|
|
254
260
|
if (usesDedicatedFile) {
|
|
255
261
|
transient = new Database(options.dbPath ?? getDbPath());
|
|
262
|
+
// Match the daemon connection's busy_timeout so this transient
|
|
263
|
+
// connection waits for a lock held by another writer rather than
|
|
264
|
+
// failing immediately with SQLITE_BUSY. (The daemon connection reused
|
|
265
|
+
// in the else branch already has it set via applyConnectionPragmas.)
|
|
266
|
+
transient.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
256
267
|
for (const a of options.attach ?? []) {
|
|
257
268
|
transient.exec(
|
|
258
269
|
`ATTACH DATABASE '${a.path.replace(/'/g, "''")}' AS ${a.alias}`,
|
|
@@ -77,6 +77,16 @@ export function assertTestDbIsIsolated(): void {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* How long a SQLite connection waits to acquire a lock held by another
|
|
82
|
+
* writer before giving up with `SQLITE_BUSY`. Every connection that touches
|
|
83
|
+
* an assistant database — the main/dedicated daemon connections here and the
|
|
84
|
+
* out-of-process / transient connections in `db-async-query.ts` — must set
|
|
85
|
+
* the same value, so a writer that grabs the lock can finish without a
|
|
86
|
+
* concurrent writer failing immediately instead of waiting its turn.
|
|
87
|
+
*/
|
|
88
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 5000;
|
|
89
|
+
|
|
80
90
|
/**
|
|
81
91
|
* Apply the connection-wide PRAGMAs every assistant SQLite connection runs
|
|
82
92
|
* with. These are per-connection settings, so the dedicated logs/memory
|
|
@@ -85,7 +95,7 @@ export function assertTestDbIsIsolated(): void {
|
|
|
85
95
|
function applyConnectionPragmas(sqlite: Database): void {
|
|
86
96
|
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
87
97
|
sqlite.exec("PRAGMA synchronous=FULL");
|
|
88
|
-
sqlite.exec(
|
|
98
|
+
sqlite.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
89
99
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
90
100
|
sqlite.exec("PRAGMA cache_size=-256000");
|
|
91
101
|
sqlite.exec("PRAGMA temp_store=MEMORY");
|