bunqueue-dashboard 0.0.45 → 0.0.47
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/agent/compiledRuntime.ts +27 -0
- package/agent/db/boundedStream.ts +18 -0
- package/agent/db/exportTimeout.ts +8 -6
- package/agent/db/processWorker.ts +121 -0
- package/agent/db/queryTimeout.ts +62 -50
- package/agent/db/readAdmission.ts +53 -0
- package/agent/db/readOperations.ts +27 -0
- package/agent/db/readProcessMain.ts +64 -0
- package/agent/db/types.ts +3 -0
- package/agent/db/workerFactory.ts +12 -10
- package/agent/dbReadWorker.ts +21 -0
- package/agent/index.ts +2 -1
- package/agent/manager/configStore.ts +65 -0
- package/agent/manager/process.ts +8 -1
- package/agent/server/databaseRoutes.ts +13 -17
- package/agent/server/errors.ts +3 -0
- package/agent/server/handler.ts +2 -1
- package/agent/server/workflowReadRoutes.ts +9 -11
- package/agent/workflow/sqlite.ts +1 -1
- package/package.json +7 -4
- package/scripts/serve.ts +10 -15
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Bun uses a POSIX virtual root and a drive-qualified Windows virtual root. */
|
|
2
|
+
function compiledRoot(moduleUrl: string): URL | null {
|
|
3
|
+
const url = new URL(moduleUrl);
|
|
4
|
+
if (url.protocol !== 'file:') return null;
|
|
5
|
+
// Windows import.meta.url escapes the virtual root as /B:/%7EBUN/root/.
|
|
6
|
+
let pathname: string;
|
|
7
|
+
try { pathname = decodeURIComponent(url.pathname); } catch { return null; }
|
|
8
|
+
for (const marker of ['/$bunfs/root/', '/~BUN/root/']) {
|
|
9
|
+
const index = pathname.indexOf(marker);
|
|
10
|
+
if (index !== -1) {
|
|
11
|
+
url.pathname = pathname.slice(0, index + marker.length);
|
|
12
|
+
url.search = '';
|
|
13
|
+
url.hash = '';
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isCompiledModule(moduleUrl: string): boolean {
|
|
21
|
+
return compiledRoot(moduleUrl) !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compiledWorkerUrl(moduleUrl: string, path: string): string | null {
|
|
25
|
+
const root = compiledRoot(moduleUrl);
|
|
26
|
+
return root ? new URL(path, root).href : null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Bound both pipe buffering and the final allocation before decoding a child response. */
|
|
2
|
+
export async function readBoundedBytes(stream: ReadableStream<Uint8Array>, limit: number): Promise<Buffer> {
|
|
3
|
+
const reader = stream.getReader();
|
|
4
|
+
const chunks: Uint8Array[] = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
try {
|
|
7
|
+
for (;;) {
|
|
8
|
+
const next = await reader.read();
|
|
9
|
+
if (next.done) break;
|
|
10
|
+
bytes += next.value.byteLength;
|
|
11
|
+
if (bytes > limit) throw new Error('Database process output exceeded its byte limit');
|
|
12
|
+
chunks.push(next.value);
|
|
13
|
+
}
|
|
14
|
+
return Buffer.concat(chunks, bytes);
|
|
15
|
+
} finally {
|
|
16
|
+
reader.releaseLock();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateWorkerBounds } from './core';
|
|
2
|
+
import type { DatabaseWorker } from './processWorker';
|
|
2
3
|
import { createExportWorker } from './workerFactory';
|
|
3
4
|
import {
|
|
4
5
|
DB_EXPORT_MAX_ROWS,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
|
|
15
16
|
let liveExportWorkers = 0;
|
|
16
17
|
|
|
17
|
-
/** Export workers alive now, including
|
|
18
|
+
/** Export workers alive now, including cancelled children being reaped. */
|
|
18
19
|
export const exportWorkerLoad = (): number => liveExportWorkers;
|
|
19
20
|
|
|
20
21
|
function validatedWorkerExport(value: unknown, expectedTable: string): DbCsvExport {
|
|
@@ -67,11 +68,11 @@ export async function exportWithTimeout(
|
|
|
67
68
|
signal?.throwIfAborted();
|
|
68
69
|
if (liveExportWorkers >= MAX_CONCURRENT_EXPORTS) {
|
|
69
70
|
throw new DbExportBusyError(
|
|
70
|
-
'A database export is already running.
|
|
71
|
+
'A database export is already running. Retry after it finishes.'
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
let worker:
|
|
75
|
+
let worker: DatabaseWorker;
|
|
75
76
|
try {
|
|
76
77
|
worker = createExportWorker();
|
|
77
78
|
} catch (error) {
|
|
@@ -117,7 +118,7 @@ export async function exportWithTimeout(
|
|
|
117
118
|
reject(new DbExportUnavailableError('Database export worker exited unexpectedly'));
|
|
118
119
|
}
|
|
119
120
|
});
|
|
120
|
-
worker.addEventListener('message', (event
|
|
121
|
+
worker.addEventListener('message', (event) => {
|
|
121
122
|
if (settled) return;
|
|
122
123
|
settled = true;
|
|
123
124
|
cleanup();
|
|
@@ -147,7 +148,7 @@ export async function exportWithTimeout(
|
|
|
147
148
|
);
|
|
148
149
|
}
|
|
149
150
|
});
|
|
150
|
-
worker.addEventListener('error', (event
|
|
151
|
+
worker.addEventListener('error', (event) => {
|
|
151
152
|
if (settled) return;
|
|
152
153
|
event.preventDefault?.();
|
|
153
154
|
abandon(
|
|
@@ -161,7 +162,7 @@ export async function exportWithTimeout(
|
|
|
161
162
|
() =>
|
|
162
163
|
abandon(
|
|
163
164
|
new DbExportUnavailableError(
|
|
164
|
-
`Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was
|
|
165
|
+
`Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was terminated.`
|
|
165
166
|
)
|
|
166
167
|
),
|
|
167
168
|
DB_EXPORT_TIMEOUT_MS
|
|
@@ -186,5 +187,6 @@ export async function exportWithTimeout(
|
|
|
186
187
|
});
|
|
187
188
|
} finally {
|
|
188
189
|
worker.terminate();
|
|
190
|
+
if (worker.exited) { await worker.exited; release(); }
|
|
189
191
|
}
|
|
190
192
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { deserialize, serialize } from 'node:v8';
|
|
3
|
+
import { readBoundedBytes } from './boundedStream';
|
|
4
|
+
import { isCompiledModule } from '../compiledRuntime';
|
|
5
|
+
|
|
6
|
+
type WorkerEvents = {
|
|
7
|
+
message: { data: unknown };
|
|
8
|
+
error: { message: string; preventDefault?: () => void };
|
|
9
|
+
close: Record<string, never>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Minimal Worker-compatible seam retained for existing integrations and fault injection. */
|
|
13
|
+
export interface DatabaseWorker {
|
|
14
|
+
addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void;
|
|
15
|
+
postMessage(request: unknown): void;
|
|
16
|
+
terminate(): void;
|
|
17
|
+
/** Production workers resolve only after the OS has reaped the child. */
|
|
18
|
+
exited?: Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const active = new Set<DatabaseProcessWorker>();
|
|
22
|
+
export const databaseProcessLoad = (): number => active.size;
|
|
23
|
+
export function terminateDatabaseProcesses(): void {
|
|
24
|
+
for (const worker of active) worker.terminate();
|
|
25
|
+
}
|
|
26
|
+
process.once('exit', terminateDatabaseProcesses);
|
|
27
|
+
|
|
28
|
+
/** Unlike Worker.terminate(), SIGKILL interrupts synchronous sqlite3_step. */
|
|
29
|
+
export class DatabaseProcessWorker implements DatabaseWorker {
|
|
30
|
+
private listeners = new Map<string, Array<(event: unknown) => void>>();
|
|
31
|
+
private child?: Bun.Subprocess<'ignore', 'pipe', 'ignore' | 'inherit'>;
|
|
32
|
+
private stopped = false;
|
|
33
|
+
private started = false;
|
|
34
|
+
private closed = false;
|
|
35
|
+
private resolveExit!: () => void;
|
|
36
|
+
readonly exited = new Promise<void>((resolve) => { this.resolveExit = resolve; });
|
|
37
|
+
get pid(): number | undefined { return this.child?.pid; }
|
|
38
|
+
|
|
39
|
+
constructor(private readonly workerUrl?: string) {}
|
|
40
|
+
|
|
41
|
+
addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void {
|
|
42
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), (event) => listener(event as WorkerEvents[K])]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
postMessage(request: unknown): void {
|
|
46
|
+
if (this.started || this.stopped) throw new Error('Database process is already used');
|
|
47
|
+
this.started = true;
|
|
48
|
+
active.add(this);
|
|
49
|
+
void this.run(request);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
terminate(): void {
|
|
53
|
+
this.stopped = true;
|
|
54
|
+
if (this.child && this.child.exitCode === null) this.child.kill('SIGKILL');
|
|
55
|
+
if (!this.started) this.close();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private emit<K extends keyof WorkerEvents>(type: K, event: WorkerEvents[K]): void {
|
|
59
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private close(): void {
|
|
63
|
+
if (this.closed) return;
|
|
64
|
+
this.closed = true;
|
|
65
|
+
active.delete(this);
|
|
66
|
+
this.resolveExit();
|
|
67
|
+
this.emit('close', {});
|
|
68
|
+
this.listeners.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async run(request: unknown): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
const message = request as Record<string, unknown>;
|
|
74
|
+
const operation = message.operation ? request : message.kind === 'export'
|
|
75
|
+
? { operation: 'dbExportCsv', args: [message.path, message.table, message.orderBy, message.dir, message.filter] }
|
|
76
|
+
: { operation: 'dbQuery', args: [message.path, message.sql] };
|
|
77
|
+
// Legacy overrides never handled the newly isolated browse/workflow operations.
|
|
78
|
+
const input = serialize(this.workerUrl !== undefined && !message.operation
|
|
79
|
+
? { customWorkerUrl: this.workerUrl, request }
|
|
80
|
+
: operation);
|
|
81
|
+
if (input.byteLength > 128 * 1024) throw new Error('Database request exceeds 128 KiB');
|
|
82
|
+
const compiled = isCompiledModule(import.meta.url);
|
|
83
|
+
const trace = process.env.BQ_DB_PROCESS_TRACE === '1';
|
|
84
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'spawn', module: import.meta.url, compiled, executable: process.execPath }));
|
|
85
|
+
let delivered = false;
|
|
86
|
+
this.child = Bun.spawn(compiled
|
|
87
|
+
? [process.execPath, '--bq-db-read']
|
|
88
|
+
: [process.execPath, fileURLToPath(new URL('./readProcessMain.ts', import.meta.url))], {
|
|
89
|
+
// Database readers need no managed-server tokens or cloud credentials.
|
|
90
|
+
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMPDIR: process.env.TMPDIR, BQ_DB_PROCESS_TRACE: trace ? '1' : undefined },
|
|
91
|
+
stdin: 'ignore', stdout: 'pipe', stderr: trace ? 'inherit' : 'ignore',
|
|
92
|
+
ipc: (message, subprocess) => {
|
|
93
|
+
if (message !== 'ready' || delivered || this.stopped) return;
|
|
94
|
+
delivered = true;
|
|
95
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'ready', pid: subprocess.pid }));
|
|
96
|
+
try { subprocess.send(input); } catch { this.terminate(); }
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const [output, code] = await Promise.all([
|
|
100
|
+
readBoundedBytes(this.child.stdout, 32 * 1024 * 1024), this.child.exited,
|
|
101
|
+
]);
|
|
102
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'exited', code, bytes: output.byteLength }));
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
if (code !== 0) throw new Error(`Database process exited with code ${code}`);
|
|
105
|
+
const response = deserialize(output) as Record<string, unknown>;
|
|
106
|
+
if (message.kind === 'export' && response.ok && !Object.hasOwn(response, 'export') && Object.hasOwn(response, 'result')) {
|
|
107
|
+
response.export = response.result;
|
|
108
|
+
}
|
|
109
|
+
// The process has exited before any caller can release its lifecycle lease.
|
|
110
|
+
this.emit('message', { data: response });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.terminate();
|
|
113
|
+
if (this.child) await this.child.exited;
|
|
114
|
+
this.emit('error', { message: error instanceof Error ? error.message : 'Database process failed' });
|
|
115
|
+
} finally {
|
|
116
|
+
try { this.child?.disconnect(); } catch { /* child already closed its IPC channel */ }
|
|
117
|
+
this.child = undefined;
|
|
118
|
+
this.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/agent/db/queryTimeout.ts
CHANGED
|
@@ -1,76 +1,88 @@
|
|
|
1
|
+
import { ReadAdmission } from './readAdmission';
|
|
1
2
|
import { createQueryWorker } from './workerFactory';
|
|
3
|
+
import type { ReadArguments, ReadOperation, ReadResult } from './readOperations';
|
|
2
4
|
import {
|
|
3
|
-
|
|
4
|
-
MissingDbError,
|
|
5
|
-
QUERY_TIMEOUT_MS,
|
|
6
|
-
type DbQueryResult,
|
|
5
|
+
DbReadUnavailableError,
|
|
6
|
+
MissingDbError, QUERY_TIMEOUT_MS, type DbQueryResult,
|
|
7
7
|
} from './types';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const admission = new ReadAdmission();
|
|
10
|
+
/** Active database read processes, including children being reaped after cancellation. */
|
|
11
|
+
export const queryWorkerLoad = (): number => admission.load;
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
export function queryWithTimeout(path: string, sql: string, signal?: AbortSignal): Promise<DbQueryResult> {
|
|
14
|
+
return runReadWorker({ path, sql }, signal);
|
|
15
|
+
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
17
|
+
/** Every HTTP SQLite read shares the same bounded admission pool. */
|
|
18
|
+
export function readWithTimeout<K extends ReadOperation>(
|
|
19
|
+
operation: K, args: ReadArguments<K>, signal?: AbortSignal,
|
|
20
|
+
timeoutMs = QUERY_TIMEOUT_MS
|
|
21
|
+
): Promise<ReadResult<K>> {
|
|
22
|
+
return runReadWorker({ operation, args }, signal, timeoutMs, true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runReadWorker<T>(request: unknown, signal?: AbortSignal, timeoutMs = QUERY_TIMEOUT_MS, queue = false): Promise<T> {
|
|
26
|
+
const limit = Math.max(1, Math.min(timeoutMs, QUERY_TIMEOUT_MS));
|
|
27
|
+
const deadline = performance.now() + limit;
|
|
28
|
+
const ticket = admission.acquire(queue, deadline, signal);
|
|
29
|
+
const release = typeof ticket === 'function' ? ticket : await ticket;
|
|
30
|
+
let worker;
|
|
21
31
|
try {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
if (performance.now() >= deadline) throw new Error('Database read exceeded the time limit while waiting for capacity');
|
|
22
34
|
worker = createQueryWorker();
|
|
23
35
|
} catch (error) {
|
|
24
|
-
|
|
36
|
+
release();
|
|
37
|
+
throw new DbReadUnavailableError(`Query worker unavailable: ${(error as Error).message}`);
|
|
25
38
|
}
|
|
26
|
-
|
|
27
|
-
let released = false;
|
|
28
|
-
const release = () => {
|
|
29
|
-
if (released) return;
|
|
30
|
-
released = true;
|
|
31
|
-
liveQueryWorkers--;
|
|
32
|
-
};
|
|
33
|
-
worker.addEventListener('close', release);
|
|
39
|
+
let cleanup = () => {};
|
|
34
40
|
try {
|
|
35
|
-
return await new Promise<
|
|
41
|
+
return await new Promise<T>((resolve, reject) => {
|
|
36
42
|
let settled = false;
|
|
37
|
-
const
|
|
43
|
+
const fail = (error: Error) => {
|
|
38
44
|
if (settled) return;
|
|
39
45
|
settled = true;
|
|
46
|
+
cleanup();
|
|
40
47
|
worker.terminate();
|
|
41
|
-
reject(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
settled = true;
|
|
50
|
-
clearTimeout(timer);
|
|
48
|
+
reject(error);
|
|
49
|
+
};
|
|
50
|
+
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
|
|
51
|
+
const timer = setTimeout(() => fail(new DbReadUnavailableError(
|
|
52
|
+
`Query exceeded the ${limit / 1000}s time limit and was terminated.`
|
|
53
|
+
)), Math.max(1, deadline - performance.now()));
|
|
54
|
+
cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
|
|
55
|
+
worker.addEventListener('close', () => {
|
|
51
56
|
release();
|
|
52
|
-
|
|
53
|
-
ok: boolean;
|
|
54
|
-
result?: DbQueryResult;
|
|
55
|
-
error?: string;
|
|
56
|
-
missing?: boolean;
|
|
57
|
-
};
|
|
58
|
-
if (response.ok && response.result) resolve(response.result);
|
|
59
|
-
else if (response.missing) {
|
|
60
|
-
reject(new MissingDbError(response.error ?? 'Database not found'));
|
|
61
|
-
} else reject(new Error(response.error ?? 'Query failed'));
|
|
57
|
+
if (!settled) fail(new DbReadUnavailableError('Query worker exited unexpectedly'));
|
|
62
58
|
});
|
|
63
|
-
worker.addEventListener('
|
|
59
|
+
worker.addEventListener('message', (event) => {
|
|
64
60
|
if (settled) return;
|
|
61
|
+
const response = event.data as { ok?: unknown; result?: unknown; missing?: unknown; error?: unknown } | null;
|
|
62
|
+
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
|
|
63
|
+
fail(new DbReadUnavailableError('Query worker returned a malformed response'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
65
66
|
settled = true;
|
|
66
|
-
|
|
67
|
-
release();
|
|
67
|
+
cleanup();
|
|
68
|
+
if (!worker.exited) release();
|
|
69
|
+
if (response.ok && Object.hasOwn(response, 'result')) resolve(response.result as T);
|
|
70
|
+
else if (response.missing === true) reject(new MissingDbError(typeof response.error === 'string' ? response.error : 'Database not found'));
|
|
71
|
+
else reject(new Error(typeof response.error === 'string' ? response.error : 'Query failed'));
|
|
72
|
+
});
|
|
73
|
+
worker.addEventListener('error', (event) => {
|
|
68
74
|
event.preventDefault?.();
|
|
69
|
-
|
|
75
|
+
fail(new DbReadUnavailableError(`Query worker failed: ${event.message || 'unknown worker error'}`));
|
|
70
76
|
});
|
|
71
|
-
|
|
77
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
78
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
79
|
+
try { worker.postMessage(request); } catch (error) {
|
|
80
|
+
fail(new DbReadUnavailableError(`Could not start database read: ${(error as Error).message}`));
|
|
81
|
+
}
|
|
72
82
|
});
|
|
73
83
|
} finally {
|
|
84
|
+
cleanup();
|
|
74
85
|
worker.terminate();
|
|
86
|
+
if (worker.exited) { await worker.exited; release(); }
|
|
75
87
|
}
|
|
76
88
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DbReadBusyError, DbReadUnavailableError, MAX_CONCURRENT_QUERIES } from './types';
|
|
2
|
+
|
|
3
|
+
type Release = () => void;
|
|
4
|
+
interface Waiter { admit(): void; cancel(error: unknown): void }
|
|
5
|
+
const MAX_PENDING_READS = 32;
|
|
6
|
+
|
|
7
|
+
/** Fair, bounded admission absorbs a page's parallel polls without spawning more SQLite processes. */
|
|
8
|
+
export class ReadAdmission {
|
|
9
|
+
private active = 0;
|
|
10
|
+
private waiting: Waiter[] = [];
|
|
11
|
+
get load(): number { return this.active; }
|
|
12
|
+
get pending(): number { return this.waiting.length; }
|
|
13
|
+
|
|
14
|
+
acquire(queue: boolean, deadline: number, signal?: AbortSignal): Release | Promise<Release> {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (this.active < MAX_CONCURRENT_QUERIES) return this.reserve();
|
|
17
|
+
if (!queue || this.waiting.length >= MAX_PENDING_READS) {
|
|
18
|
+
throw new DbReadBusyError(`Too many queries running (${this.active}/${MAX_CONCURRENT_QUERIES}). Retry after a current read finishes.`);
|
|
19
|
+
}
|
|
20
|
+
return new Promise<Release>((resolve, reject) => {
|
|
21
|
+
let settled = false;
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
settled = true;
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
signal?.removeEventListener('abort', onAbort);
|
|
26
|
+
const index = this.waiting.indexOf(waiter);
|
|
27
|
+
if (index !== -1) this.waiting.splice(index, 1);
|
|
28
|
+
};
|
|
29
|
+
const waiter: Waiter = {
|
|
30
|
+
admit: () => { if (!settled) { cleanup(); resolve(this.reserve()); } },
|
|
31
|
+
cancel: (error) => { if (!settled) { cleanup(); reject(error); } },
|
|
32
|
+
};
|
|
33
|
+
const onAbort = () => waiter.cancel(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
|
|
34
|
+
const timer = setTimeout(() => waiter.cancel(new DbReadUnavailableError(
|
|
35
|
+
'Database read exceeded the time limit while waiting for capacity'
|
|
36
|
+
)), Math.max(1, deadline - performance.now()));
|
|
37
|
+
this.waiting.push(waiter);
|
|
38
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
39
|
+
if (signal?.aborted) onAbort();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private reserve(): Release {
|
|
44
|
+
this.active++;
|
|
45
|
+
let released = false;
|
|
46
|
+
return () => {
|
|
47
|
+
if (released) return;
|
|
48
|
+
released = true;
|
|
49
|
+
this.active--;
|
|
50
|
+
this.waiting[0]?.admit();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { dbExportCsv } from './csvExport';
|
|
2
|
+
import { dbQuery } from './query';
|
|
3
|
+
import { dbInfo, dbSchema } from './schema';
|
|
4
|
+
import { dbCell, dbRows, dbTables } from './tables';
|
|
5
|
+
import { workflowExecution } from '../workflow/detail';
|
|
6
|
+
import { workflowExecutions } from '../workflow/list';
|
|
7
|
+
import { workflowStats } from '../workflow/stats';
|
|
8
|
+
|
|
9
|
+
/** Fixed allowlist shared by the typed caller and isolated SQLite process. */
|
|
10
|
+
export const READ_OPERATIONS = {
|
|
11
|
+
dbQuery, dbExportCsv, dbInfo, dbSchema, dbCell, dbRows, dbTables,
|
|
12
|
+
workflowExecution, workflowExecutions, workflowStats,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ReadOperation = keyof typeof READ_OPERATIONS;
|
|
16
|
+
export type ReadArguments<K extends ReadOperation> = Parameters<(typeof READ_OPERATIONS)[K]>;
|
|
17
|
+
export type ReadResult<K extends ReadOperation> = ReturnType<(typeof READ_OPERATIONS)[K]>;
|
|
18
|
+
|
|
19
|
+
export function dispatchRead(request: unknown): unknown {
|
|
20
|
+
if (!request || typeof request !== 'object') throw new Error('Invalid database read request');
|
|
21
|
+
const { operation, args } = request as { operation: string; args: unknown[] };
|
|
22
|
+
if (!Object.hasOwn(READ_OPERATIONS, operation) || !Array.isArray(args)) {
|
|
23
|
+
throw new Error('Unknown database read operation');
|
|
24
|
+
}
|
|
25
|
+
const execute = READ_OPERATIONS[operation as ReadOperation] as (...args: unknown[]) => unknown;
|
|
26
|
+
return execute(...args);
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { deserialize, serialize } from 'node:v8';
|
|
2
|
+
import { compiledWorkerUrl } from '../compiledRuntime';
|
|
3
|
+
|
|
4
|
+
/** One request, one readonly connection, one process. Also bundled into the standalone server. */
|
|
5
|
+
export async function runDatabaseReadProcess(): Promise<void> {
|
|
6
|
+
let response: unknown;
|
|
7
|
+
let worker: Worker | undefined;
|
|
8
|
+
const trace = (phase: string, details: Record<string, unknown> = {}): void => {
|
|
9
|
+
if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-supervisor', phase, ...details }));
|
|
10
|
+
};
|
|
11
|
+
try {
|
|
12
|
+
trace('started', { module: import.meta.url, ipc: Boolean(process.send), connected: process.connected });
|
|
13
|
+
if (!process.send || !process.connected) throw new Error('Database process requires an IPC parent');
|
|
14
|
+
// Keep request delivery and parent liveness separate from standard streams.
|
|
15
|
+
// Disconnect also covers abrupt parent death.
|
|
16
|
+
process.once('disconnect', () => process.exit(1));
|
|
17
|
+
const pending = new Promise<unknown>((resolve) => process.once('message', resolve));
|
|
18
|
+
process.send('ready');
|
|
19
|
+
const message = await pending;
|
|
20
|
+
trace('request-received');
|
|
21
|
+
if (!(message instanceof Uint8Array) || message.byteLength > 128 * 1024) {
|
|
22
|
+
throw new Error('Invalid database request or request exceeds 128 KiB');
|
|
23
|
+
}
|
|
24
|
+
let input: unknown = deserialize(message);
|
|
25
|
+
let workerUrl = compiledWorkerUrl(import.meta.url, 'agent/dbReadWorker.js')
|
|
26
|
+
?? new URL('../dbReadWorker.ts', import.meta.url).href;
|
|
27
|
+
let customWorker = false;
|
|
28
|
+
if (input && typeof input === 'object' && 'customWorkerUrl' in input) {
|
|
29
|
+
if (typeof input.customWorkerUrl !== 'string' || !input.customWorkerUrl || !('request' in input)) {
|
|
30
|
+
throw new Error('Invalid database worker override');
|
|
31
|
+
}
|
|
32
|
+
workerUrl = input.customWorkerUrl;
|
|
33
|
+
input = input.request;
|
|
34
|
+
customWorker = true;
|
|
35
|
+
}
|
|
36
|
+
trace('creating-worker', customWorker ? { customWorker: true } : { workerUrl });
|
|
37
|
+
worker = new Worker(workerUrl, { type: 'module' });
|
|
38
|
+
trace('worker-created');
|
|
39
|
+
response = await new Promise<unknown>((resolve, reject) => {
|
|
40
|
+
worker!.addEventListener('message', (event: MessageEvent) => { trace('worker-result'); resolve(event.data); });
|
|
41
|
+
worker!.addEventListener('error', (event: ErrorEvent) => {
|
|
42
|
+
event.preventDefault?.();
|
|
43
|
+
reject(new Error(event.message || 'Database worker failed'));
|
|
44
|
+
});
|
|
45
|
+
worker!.postMessage(input);
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
trace('failed');
|
|
49
|
+
response = {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: error instanceof Error ? error.message : 'Database read failed',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const output = serialize(response);
|
|
55
|
+
await Bun.write(Bun.stdout, output.byteLength <= 32 * 1024 * 1024
|
|
56
|
+
? output
|
|
57
|
+
: serialize({ ok: false, error: 'Database result exceeded 32 MiB' }));
|
|
58
|
+
trace('output-written', { bytes: output.byteLength });
|
|
59
|
+
// Exit the whole process, including its SQLite thread and IPC channel.
|
|
60
|
+
worker?.terminate();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (import.meta.main) await runDatabaseReadProcess();
|
package/agent/db/types.ts
CHANGED
|
@@ -19,6 +19,9 @@ export class MissingDbError extends Error {
|
|
|
19
19
|
readonly missing = true;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export class DbReadBusyError extends Error {}
|
|
23
|
+
export class DbReadUnavailableError extends Error {}
|
|
24
|
+
|
|
22
25
|
/** Maps a saturated export worker pool to HTTP 429. */
|
|
23
26
|
export class DbExportBusyError extends Error {}
|
|
24
27
|
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
import { DatabaseProcessWorker, type DatabaseWorker } from './processWorker';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
let
|
|
3
|
+
let workerUrl: string | undefined;
|
|
4
|
+
const spawnWorker = (): DatabaseWorker => new DatabaseProcessWorker(workerUrl);
|
|
5
|
+
let queryFactory: () => DatabaseWorker = spawnWorker;
|
|
6
|
+
let exportFactory: () => DatabaseWorker = spawnWorker;
|
|
6
7
|
|
|
7
|
-
export function createQueryWorker():
|
|
8
|
+
export function createQueryWorker(): DatabaseWorker {
|
|
8
9
|
return queryFactory();
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function createExportWorker():
|
|
12
|
+
export function createExportWorker(): DatabaseWorker {
|
|
12
13
|
return exportFactory();
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
-
|
|
16
|
+
/** Optional legacy query/export worker override, still hosted in a killable process. */
|
|
17
|
+
export function setQueryWorkerUrl(url: string | null): void {
|
|
18
|
+
workerUrl = url ?? undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export function setQueryWorkerFactory(factory: (() =>
|
|
21
|
+
export function setQueryWorkerFactory(factory: (() => DatabaseWorker) | null): void {
|
|
20
22
|
queryFactory = factory ?? spawnWorker;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export function setExportWorkerFactory(factory: (() =>
|
|
25
|
+
export function setExportWorkerFactory(factory: (() => DatabaseWorker) | null): void {
|
|
24
26
|
exportFactory = factory ?? spawnWorker;
|
|
25
27
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { dispatchRead } from './db/readOperations';
|
|
2
|
+
import { MissingDbError } from './db/types';
|
|
3
|
+
|
|
4
|
+
declare const self: Worker;
|
|
5
|
+
|
|
6
|
+
if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-thread', phase: 'listening' }));
|
|
7
|
+
|
|
8
|
+
// Runs inside a disposable PROCESS. Its supervisor remains responsive to IPC
|
|
9
|
+
// disconnects when the agent dies, even while this thread is in sqlite3_step.
|
|
10
|
+
self.addEventListener('message', (event: MessageEvent) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = dispatchRead(event.data);
|
|
13
|
+
const content = result && typeof result === 'object' && 'content' in result ? result.content : null;
|
|
14
|
+
self.postMessage({ ok: true, result }, content instanceof Uint8Array && content.buffer instanceof ArrayBuffer ? [content.buffer] : []);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
self.postMessage({ ok: false,
|
|
17
|
+
error: error instanceof Error ? error.message : 'Database read failed',
|
|
18
|
+
missing: error instanceof MissingDbError,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
package/agent/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { assertRequiredBunVersion } from '../scripts/bunVersion';
|
|
16
16
|
import { logger } from './logger';
|
|
17
17
|
import { ProcessManager } from './manager';
|
|
18
|
+
import { agentConfigStore } from './manager/configStore';
|
|
18
19
|
import {
|
|
19
20
|
createFetchHandler,
|
|
20
21
|
resolveAllowedHosts,
|
|
@@ -25,7 +26,7 @@ import { installAgentShutdown } from './shutdown';
|
|
|
25
26
|
|
|
26
27
|
assertRequiredBunVersion();
|
|
27
28
|
|
|
28
|
-
const mgr = new ProcessManager();
|
|
29
|
+
const mgr = new ProcessManager(undefined, agentConfigStore());
|
|
29
30
|
const PORT = Number(process.env.AGENT_PORT) || 6800;
|
|
30
31
|
const allowedOrigins = resolveAllowedOrigins();
|
|
31
32
|
// The agent binds loopback only, so a legitimate Host is always a loopback
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { copyConfig, validateServerConfig } from './config';
|
|
4
|
+
import type { ServerConfig } from './types';
|
|
5
|
+
|
|
6
|
+
export interface ConfigStore {
|
|
7
|
+
load(fallback: ServerConfig): ServerConfig;
|
|
8
|
+
save(config: ServerConfig): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/** Private atomic snapshots. Errors never include configuration secrets. */
|
|
14
|
+
export class FileConfigStore implements ConfigStore {
|
|
15
|
+
constructor(readonly path: string) {}
|
|
16
|
+
|
|
17
|
+
load(fallback: ServerConfig): ServerConfig {
|
|
18
|
+
let descriptor: number;
|
|
19
|
+
try {
|
|
20
|
+
descriptor = openSync(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return copyConfig(fallback);
|
|
23
|
+
throw new Error('Cannot read saved agent configuration');
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
if (fstatSync(descriptor).size > MAX_CONFIG_BYTES) throw new Error('oversize');
|
|
27
|
+
const raw = readFileSync(descriptor);
|
|
28
|
+
if (raw.byteLength > MAX_CONFIG_BYTES) throw new Error('oversize');
|
|
29
|
+
const saved = JSON.parse(raw.toString()) as { version?: unknown; config?: unknown };
|
|
30
|
+
if (saved?.version !== 1) throw new Error('unsupported format');
|
|
31
|
+
return copyConfig(validateServerConfig(saved.config));
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error('Saved agent configuration is invalid; repair or remove AGENT_CONFIG_PATH before restarting');
|
|
34
|
+
} finally {
|
|
35
|
+
closeSync(descriptor);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
save(config: ServerConfig): void {
|
|
40
|
+
const content = JSON.stringify({ version: 1, config: validateServerConfig(config) }) + '\n';
|
|
41
|
+
if (Buffer.byteLength(content) > MAX_CONFIG_BYTES) throw new Error('Agent configuration exceeds 1 MiB');
|
|
42
|
+
const directory = dirname(this.path);
|
|
43
|
+
const temporary = `${this.path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
44
|
+
let descriptor: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
47
|
+
descriptor = openSync(temporary, 'wx', 0o600);
|
|
48
|
+
writeFileSync(descriptor, content);
|
|
49
|
+
fsyncSync(descriptor);
|
|
50
|
+
closeSync(descriptor);
|
|
51
|
+
descriptor = undefined;
|
|
52
|
+
renameSync(temporary, this.path);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error('Cannot persist agent configuration; the change was not applied');
|
|
55
|
+
} finally {
|
|
56
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
57
|
+
rmSync(temporary, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Paths resolve against the launch directory, just like the database path. Use one path per agent. */
|
|
63
|
+
export function agentConfigStore(env: NodeJS.ProcessEnv = process.env): FileConfigStore {
|
|
64
|
+
return new FileConfigStore(resolve(env.AGENT_CONFIG_PATH || '.bunqueue-dashboard/config.json'));
|
|
65
|
+
}
|
package/agent/manager/process.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
|
|
2
2
|
import { ProcessLogs } from './logs';
|
|
3
|
+
import type { ConfigStore } from './configStore';
|
|
3
4
|
import { databaseStats } from './storage';
|
|
4
5
|
import { removeSqlitePaths, validateManagedStorage } from './storageMode';
|
|
5
6
|
import type {
|
|
@@ -40,7 +41,12 @@ export class ProcessManager {
|
|
|
40
41
|
private maintenance: string | null = null;
|
|
41
42
|
private output = new ProcessLogs();
|
|
42
43
|
|
|
43
|
-
constructor(
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly stopTimeoutMs = STOP_TIMEOUT_MS,
|
|
46
|
+
private readonly configStore?: ConfigStore
|
|
47
|
+
) {
|
|
48
|
+
this.config = configStore?.load(this.config) ?? this.config;
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
getConfig(): ServerConfig {
|
|
46
52
|
return copyConfig(this.config);
|
|
@@ -63,6 +69,7 @@ export class ProcessManager {
|
|
|
63
69
|
extraEnv: valid.extraEnv ?? this.config.extraEnv,
|
|
64
70
|
});
|
|
65
71
|
if (!sameConfig(this.config, next)) {
|
|
72
|
+
this.configStore?.save(next);
|
|
66
73
|
this.config = next;
|
|
67
74
|
this.configRevision = nextConfigRevision(this.configRevision);
|
|
68
75
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
+
import { readWithTimeout } from '../db/queryTimeout';
|
|
1
2
|
import {
|
|
2
3
|
type DbFilter,
|
|
3
|
-
dbCell,
|
|
4
|
-
dbInfo,
|
|
5
|
-
dbRows,
|
|
6
|
-
dbSchema,
|
|
7
|
-
dbTables,
|
|
8
4
|
exportWithTimeout,
|
|
9
5
|
queryWithTimeout,
|
|
10
6
|
} from '../db';
|
|
@@ -105,15 +101,15 @@ export async function routeDatabaseRequest(
|
|
|
105
101
|
admission?: ManagedDatabaseAdmission
|
|
106
102
|
): Promise<RouteResponse | Response | null> {
|
|
107
103
|
if (pathname === '/db/info' && method === 'GET') {
|
|
108
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
104
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
109
105
|
status: 200,
|
|
110
|
-
body: { ok: true, ...dbInfo
|
|
106
|
+
body: { ok: true, ...(await readWithTimeout('dbInfo', [path], request.signal)) },
|
|
111
107
|
}));
|
|
112
108
|
}
|
|
113
109
|
if (pathname === '/db/tables' && method === 'GET') {
|
|
114
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
110
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
115
111
|
status: 200,
|
|
116
|
-
body: { ok: true, tables: dbTables
|
|
112
|
+
body: { ok: true, tables: await readWithTimeout('dbTables', [path], request.signal) },
|
|
117
113
|
}));
|
|
118
114
|
}
|
|
119
115
|
if (pathname.startsWith('/db/tables/') && method === 'GET') {
|
|
@@ -126,20 +122,20 @@ export async function routeDatabaseRequest(
|
|
|
126
122
|
);
|
|
127
123
|
}
|
|
128
124
|
if (segments.length === 2 && subresource === 'schema') {
|
|
129
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
125
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
130
126
|
status: 200,
|
|
131
|
-
body: { ok: true, ...dbSchema
|
|
127
|
+
body: { ok: true, ...(await readWithTimeout('dbSchema', [path, table], request.signal)) },
|
|
132
128
|
}));
|
|
133
129
|
}
|
|
134
130
|
if (segments.length === 2 && subresource === 'cell') {
|
|
135
131
|
const query = new URL(request.url).searchParams;
|
|
136
132
|
const rowid = query.get('rowid');
|
|
137
133
|
if (rowid === null) throw new Error('rowid is required');
|
|
138
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
134
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
139
135
|
status: 200,
|
|
140
136
|
body: {
|
|
141
137
|
ok: true,
|
|
142
|
-
...dbCell
|
|
138
|
+
...(await readWithTimeout('dbCell', [path, table, rowid, query.get('column') ?? ''], request.signal)),
|
|
143
139
|
},
|
|
144
140
|
}));
|
|
145
141
|
}
|
|
@@ -147,11 +143,11 @@ export async function routeDatabaseRequest(
|
|
|
147
143
|
return { status: 404, body: { ok: false, error: 'Not found' } };
|
|
148
144
|
}
|
|
149
145
|
const query = new URL(request.url).searchParams;
|
|
150
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
146
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
151
147
|
status: 200,
|
|
152
148
|
body: {
|
|
153
149
|
ok: true,
|
|
154
|
-
...dbRows
|
|
150
|
+
...(await readWithTimeout('dbRows', [
|
|
155
151
|
path,
|
|
156
152
|
table,
|
|
157
153
|
Number(query.get('limit')) || 50,
|
|
@@ -159,7 +155,7 @@ export async function routeDatabaseRequest(
|
|
|
159
155
|
query.get('orderBy') || undefined,
|
|
160
156
|
query.get('dir') === 'desc' ? 'desc' : 'asc',
|
|
161
157
|
browseFilter(query)
|
|
162
|
-
),
|
|
158
|
+
], request.signal)),
|
|
163
159
|
},
|
|
164
160
|
}));
|
|
165
161
|
}
|
|
@@ -171,7 +167,7 @@ export async function routeDatabaseRequest(
|
|
|
171
167
|
})) as { sql?: string };
|
|
172
168
|
return withDatabase(dataPath, admission, async (path) => ({
|
|
173
169
|
status: 200,
|
|
174
|
-
body: { ok: true, ...(await queryWithTimeout(path, sql ?? '')) },
|
|
170
|
+
body: { ok: true, ...(await queryWithTimeout(path, sql ?? '', request.signal)) },
|
|
175
171
|
}));
|
|
176
172
|
}
|
|
177
173
|
return null;
|
package/agent/server/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DbExportUnavailableError,
|
|
4
4
|
MissingDbError,
|
|
5
5
|
} from '../db';
|
|
6
|
+
import { DbReadBusyError, DbReadUnavailableError } from '../db/types';
|
|
6
7
|
import { safeErrorMessage } from '../errorMessage';
|
|
7
8
|
import { QueueOperationsUnavailableError } from '../queue/types';
|
|
8
9
|
import { WorkflowRuntimeUnavailableError } from '../workflow/runtime';
|
|
@@ -26,6 +27,8 @@ export function errorStatus(error: unknown): number {
|
|
|
26
27
|
try {
|
|
27
28
|
if (error instanceof AgentLifecycleClosedError) return 503;
|
|
28
29
|
if (error instanceof MissingDbError) return 404;
|
|
30
|
+
if (error instanceof DbReadBusyError) return 429;
|
|
31
|
+
if (error instanceof DbReadUnavailableError) return 503;
|
|
29
32
|
if (error instanceof DbExportBusyError) return 429;
|
|
30
33
|
if (error instanceof DbExportUnavailableError) return 503;
|
|
31
34
|
if (error instanceof QueueOperationsUnavailableError) {
|
package/agent/server/handler.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { terminateDatabaseProcesses } from '../db/processWorker';
|
|
1
2
|
import { BunqueueBackupRunner, type BackupRunnerPort } from '../backup/runner';
|
|
2
3
|
import type { ProcessManager } from '../manager';
|
|
3
4
|
import { QueueOperationsRuntime } from '../queue/runtime';
|
|
@@ -110,7 +111,7 @@ export function createFetchHandler(
|
|
|
110
111
|
shuttingDown ??= closeThenShutdown(beginShutdown, manager);
|
|
111
112
|
return shuttingDown;
|
|
112
113
|
};
|
|
113
|
-
const forceShutdown = () => manager.forceShutdown();
|
|
114
|
+
const forceShutdown = () => { terminateDatabaseProcesses(); manager.forceShutdown(); };
|
|
114
115
|
return Object.assign(handle, { close, beginShutdown, shutdown, forceShutdown });
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
+
import { readWithTimeout } from '../db/queryTimeout';
|
|
1
2
|
import {
|
|
2
3
|
type WorkflowStateFilter,
|
|
3
4
|
type WorkflowStoreKind,
|
|
4
|
-
workflowExecution,
|
|
5
|
-
workflowExecutions,
|
|
6
|
-
workflowStats,
|
|
7
5
|
WORKFLOW_STATES,
|
|
8
6
|
} from '../workflows';
|
|
9
7
|
import type { RouteResponse } from './types';
|
|
@@ -32,9 +30,9 @@ export async function routeWorkflowReadRequest(
|
|
|
32
30
|
admission?: ManagedDatabaseAdmission
|
|
33
31
|
): Promise<RouteResponse | null> {
|
|
34
32
|
if (pathname === '/workflows/stats' && method === 'GET') {
|
|
35
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
33
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
36
34
|
status: 200,
|
|
37
|
-
body: { ok: true, ...workflowStats
|
|
35
|
+
body: { ok: true, ...(await readWithTimeout('workflowStats', [path], request.signal)) },
|
|
38
36
|
}));
|
|
39
37
|
}
|
|
40
38
|
if (pathname === '/workflows' && method === 'GET') {
|
|
@@ -52,17 +50,17 @@ export async function routeWorkflowReadRequest(
|
|
|
52
50
|
) {
|
|
53
51
|
throw new Error('Unknown workflow execution state');
|
|
54
52
|
}
|
|
55
|
-
return withDatabase(dataPath, admission, (path) => ({
|
|
53
|
+
return withDatabase(dataPath, admission, async (path) => ({
|
|
56
54
|
status: 200,
|
|
57
55
|
body: {
|
|
58
56
|
ok: true,
|
|
59
|
-
...workflowExecutions
|
|
57
|
+
...(await readWithTimeout('workflowExecutions', [path, {
|
|
60
58
|
kind: workflowKind(query),
|
|
61
59
|
workflowName: query.get('workflowName') || undefined,
|
|
62
60
|
state: state as WorkflowStateFilter | undefined,
|
|
63
61
|
limit: integer(query, 'limit', 50),
|
|
64
62
|
offset: integer(query, 'offset', 0),
|
|
65
|
-
}),
|
|
63
|
+
}], request.signal)),
|
|
66
64
|
},
|
|
67
65
|
}));
|
|
68
66
|
}
|
|
@@ -78,8 +76,8 @@ export async function routeWorkflowReadRequest(
|
|
|
78
76
|
throw new Error(`Duplicate workflow detail option: ${key}`);
|
|
79
77
|
}
|
|
80
78
|
}
|
|
81
|
-
return withDatabase(dataPath, admission, (path) => {
|
|
82
|
-
const execution = workflowExecution
|
|
79
|
+
return withDatabase(dataPath, admission, async (path) => {
|
|
80
|
+
const execution = await readWithTimeout('workflowExecution', [path, decodeURIComponent(rawId), workflowKind(query)], request.signal);
|
|
83
81
|
return execution
|
|
84
82
|
? { status: 200, body: { ok: true, execution } }
|
|
85
83
|
: { status: 404, body: { ok: false, error: 'Workflow execution not found' } };
|
|
@@ -89,7 +87,7 @@ export async function routeWorkflowReadRequest(
|
|
|
89
87
|
function withDatabase<T>(
|
|
90
88
|
dataPath: string,
|
|
91
89
|
admission: ManagedDatabaseAdmission | undefined,
|
|
92
|
-
operation: (path: string) => T
|
|
90
|
+
operation: (path: string) => T | Promise<T>
|
|
93
91
|
): Promise<T> {
|
|
94
92
|
return admission
|
|
95
93
|
? admission((snapshot) => operation(snapshot.dataPath))
|
package/agent/workflow/sqlite.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Web dashboard for a bunqueue server (queues, jobs, DLQ, cron, metrics, workers, live activity). Run with `bunx bunqueue-dashboard` (requires Bun 1.4.2).",
|
|
6
6
|
"packageManager": "bun@1.4.2",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"docs:dev": "vitepress dev docs",
|
|
67
67
|
"docs:build": "bun run scripts/build-docs.ts",
|
|
68
68
|
"docs:preview": "vitepress preview docs",
|
|
69
|
-
"build:bin": "bun run build && bun run scripts/gen-embed.ts && bun build scripts/serve.ts agent/
|
|
69
|
+
"build:bin": "bun run build && bun run scripts/gen-embed.ts && bun build scripts/serve.ts agent/dbReadWorker.ts agent/backup/standaloneWorker.ts --compile --minify --outfile bunqueue-dashboard",
|
|
70
70
|
"preview": "vite preview",
|
|
71
71
|
"test": "bun test",
|
|
72
72
|
"test:e2e:flow": "bun run scripts/validate-flow-runtime.ts",
|
|
@@ -80,8 +80,11 @@
|
|
|
80
80
|
"test:e2e:browser:serve": "bun e2e/runtime.ts",
|
|
81
81
|
"test:e2e:browser:postgres-fleet": "bun run scripts/validate-postgres-fleet-dashboard-runtime.ts",
|
|
82
82
|
"test:e2e:browser": "bun run build && bun scripts/gen-embed.ts && playwright test",
|
|
83
|
+
"test:binary": "bun scripts/validate-standalone-runtime.ts ./bunqueue-dashboard",
|
|
84
|
+
"test:runtime": "bun scripts/validate-standalone-runtime.ts --source",
|
|
85
|
+
"test:soak": "bun scripts/validate-resilience.ts",
|
|
83
86
|
"test:package": "bun run scripts/validate-package-runtime.ts",
|
|
84
|
-
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage && bun scripts/check-coverage.ts && bun scripts/check-test-inventory.ts",
|
|
87
|
+
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage && bun scripts/check-coverage.ts && bun scripts/check-critical-coverage.ts && bun scripts/check-test-inventory.ts",
|
|
85
88
|
"lint": "oxlint . && bun run scripts/check-implicit-any-let.ts",
|
|
86
89
|
"lint:fix": "oxlint --fix . && bun run scripts/check-implicit-any-let.ts",
|
|
87
90
|
"format": "oxfmt .",
|
|
@@ -89,7 +92,7 @@
|
|
|
89
92
|
"check": "bun run lint && bun run format:check",
|
|
90
93
|
"check:fix": "bun run lint:fix && bun run format",
|
|
91
94
|
"audit:high": "bun audit --audit-level=high --ignore=GHSA-qwww-vcr4-c8h2",
|
|
92
|
-
"quality": "bun run version:check && bun run architecture && bun run check && bun run build && bun run size && bun run docs:build && bun run test:coverage && bun run test:e2e && bun run test:package && bun run audit:high",
|
|
95
|
+
"quality": "bun run version:check && bun run architecture && bun run check && bun run build && bun run size && bun run docs:build && bun run test:coverage && bun run test:e2e && bun run test:package && bun run test:runtime && bun run test:soak && bun run audit:high",
|
|
93
96
|
"typecheck:e2e": "tsc -p tsconfig.e2e.json",
|
|
94
97
|
"test:e2e:browser:managed": "bun run build && bun scripts/gen-embed.ts && playwright test -c playwright.managed.config.ts",
|
|
95
98
|
"test:e2e:docs": "bun run docs:build && playwright test -c playwright.docs.config.ts"
|
package/scripts/serve.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* local control agent through a same-origin /agent bridge.
|
|
5
5
|
*/
|
|
6
6
|
import { logger } from '../agent/logger';
|
|
7
|
+
import { compiledWorkerUrl } from '../agent/compiledRuntime';
|
|
8
|
+
import { agentConfigStore } from '../agent/manager/configStore';
|
|
7
9
|
import { BunqueueBackupRunner } from '../agent/backup/runner';
|
|
8
10
|
import { setBackupWorkerUrl } from '../agent/backup/workerFactory';
|
|
9
|
-
import { setQueryWorkerUrl } from '../agent/db';
|
|
10
11
|
import { QueueOperationsRuntime } from '../agent/queue/runtime';
|
|
11
12
|
import {
|
|
12
13
|
AgentLifecycleGate,
|
|
@@ -50,18 +51,7 @@ export {
|
|
|
50
51
|
} from './servePolicy';
|
|
51
52
|
|
|
52
53
|
async function main(): Promise<void> {
|
|
53
|
-
|
|
54
|
-
const compiled = import.meta.url.includes('/$bunfs/');
|
|
55
|
-
setQueryWorkerUrl(
|
|
56
|
-
compiled
|
|
57
|
-
? new URL('/$bunfs/root/agent/dbQueryWorker.js', 'file:///').href
|
|
58
|
-
: new URL('../agent/dbQueryWorker.ts', import.meta.url).href
|
|
59
|
-
);
|
|
60
|
-
setBackupWorkerUrl(
|
|
61
|
-
compiled
|
|
62
|
-
? new URL('/$bunfs/root/agent/backup/standaloneWorker.js', 'file:///').href
|
|
63
|
-
: null
|
|
64
|
-
);
|
|
54
|
+
setBackupWorkerUrl(compiledWorkerUrl(import.meta.url, 'agent/backup/standaloneWorker.js'));
|
|
65
55
|
|
|
66
56
|
const port = Number(process.env.PORT) || 8080;
|
|
67
57
|
const host = process.env.BIND_ADDR || '127.0.0.1';
|
|
@@ -109,7 +99,7 @@ async function main(): Promise<void> {
|
|
|
109
99
|
|
|
110
100
|
// Keep ProcessManager out of policy-only test imports.
|
|
111
101
|
const { ProcessManager } = await import('../agent/manager');
|
|
112
|
-
const mgr = new ProcessManager();
|
|
102
|
+
const mgr = new ProcessManager(undefined, agentConfigStore());
|
|
113
103
|
const workflowRuntime = new WorkflowRuntime();
|
|
114
104
|
const backupRunner = new BunqueueBackupRunner();
|
|
115
105
|
const queueRuntime = new QueueOperationsRuntime();
|
|
@@ -198,4 +188,9 @@ async function main(): Promise<void> {
|
|
|
198
188
|
);
|
|
199
189
|
}
|
|
200
190
|
|
|
201
|
-
if (import.meta.main)
|
|
191
|
+
if (import.meta.main) {
|
|
192
|
+
if (process.argv.includes('--bq-db-read')) {
|
|
193
|
+
const { runDatabaseReadProcess } = await import('../agent/db/readProcessMain');
|
|
194
|
+
await runDatabaseReadProcess();
|
|
195
|
+
} else await main();
|
|
196
|
+
}
|