@ixo/oracle-runtime-workers 0.4.1 → 0.10.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/README.md +5 -1
- package/package.json +2 -2
- package/src/attachments/download.ts +43 -22
- package/src/attachments/index.ts +1 -0
- package/src/core/env.ts +28 -0
- package/src/core/main-agent.ts +56 -4
- package/src/core/middlewares/dangling-tool-calls.ts +99 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/runtime-context.ts +14 -5
- package/src/core/subagent-as-tool.ts +12 -1
- package/src/do/ambient.ts +13 -4
- package/src/do/boot-dirty.ts +38 -0
- package/src/do/contracts.ts +113 -18
- package/src/do/matrix-turn-ledger.ts +126 -0
- package/src/do/reauth-prompt.ts +76 -0
- package/src/do/room-mirror.ts +111 -0
- package/src/do/sse-stream.ts +104 -2
- package/src/do/transcript.ts +15 -2
- package/src/do/turn-config.ts +24 -0
- package/src/do/user-oracle-do.ts +484 -91
- package/src/matrix/gateway-do.ts +233 -42
- package/src/matrix/inbox-store.ts +188 -0
- package/src/matrix/txn-id.ts +35 -0
- package/src/matrix/work-status.ts +15 -3
- package/src/owner-store/ixo-vfs-store.ts +12 -5
- package/src/owner-store/matrix-media-store.ts +65 -40
- package/src/owner-store/measure.ts +39 -0
- package/src/owner-store/migrating-store.ts +29 -34
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/types.ts +25 -1
- package/src/plugins/portal/action-log.ts +36 -9
- package/src/plugins/tasks/tasks-tools.ts +7 -7
- package/src/shell/app.ts +92 -7
- package/src/shell/turn-body-cap.ts +19 -0
- package/src/sqlite/cache-config.ts +27 -6
- package/src/sqlite/chunk-layout.ts +14 -0
- package/src/sqlite/database.ts +188 -19
- package/src/sqlite/do-vfs.ts +963 -99
- package/src/sqlite/index.ts +13 -0
- package/src/sqlite/page-tier.ts +668 -0
- package/src/tasks/schedule-input.ts +102 -0
- package/src/tasks/scheduler.ts +351 -54
- package/src/tasks/store.ts +198 -3
package/src/shell/app.ts
CHANGED
|
@@ -26,6 +26,18 @@ import {
|
|
|
26
26
|
type AuthResult,
|
|
27
27
|
type RouteExclusion,
|
|
28
28
|
} from './auth';
|
|
29
|
+
import { turnBodyTooLarge } from './turn-body-cap';
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
|
|
32
|
+
const DebugEventBody = z.object({
|
|
33
|
+
type: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(255)
|
|
37
|
+
.refine((t) => t !== 'm.room.redaction', 'redactions are not events'),
|
|
38
|
+
content: z.record(z.string(), z.unknown()).default({}),
|
|
39
|
+
txnId: z.string().min(1).max(255).optional(),
|
|
40
|
+
});
|
|
29
41
|
|
|
30
42
|
export interface PluginRoute {
|
|
31
43
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'ALL';
|
|
@@ -54,6 +66,17 @@ export interface ShellOptions {
|
|
|
54
66
|
|
|
55
67
|
type Variables = { auth: AuthResult };
|
|
56
68
|
|
|
69
|
+
function errorText(err: unknown): string {
|
|
70
|
+
return err instanceof Error ? err.message : String(err);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
|
|
74
|
+
function isAbortRejection(err: unknown): boolean {
|
|
75
|
+
return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
|
|
76
|
+
errorText(err),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
|
|
58
81
|
{ path: '/', method: 'GET' },
|
|
59
82
|
{ path: '/health', method: 'ALL' },
|
|
@@ -66,9 +89,6 @@ const BUILTIN_EXCLUSIONS: RouteExclusion[] = [
|
|
|
66
89
|
{ path: '/debug/matrix/stop', method: 'POST' },
|
|
67
90
|
];
|
|
68
91
|
|
|
69
|
-
/** Express body-parser default in the Node runtime: JSON bodies over 100 KiB are a 413. */
|
|
70
|
-
const MAX_TURN_BODY_BYTES = 100 * 1024;
|
|
71
|
-
|
|
72
92
|
export function createShell(
|
|
73
93
|
opts: ShellOptions = {},
|
|
74
94
|
): Hono<{ Bindings: OracleWorkerEnv; Variables: Variables }> {
|
|
@@ -267,9 +287,9 @@ export function createShell(
|
|
|
267
287
|
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
268
288
|
const sessionId = c.req.param('sessionId');
|
|
269
289
|
const body = await c.req.text();
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
if (
|
|
290
|
+
// Bounds the memory one request can pin (see turn-body-cap.ts); the
|
|
291
|
+
// status and message are the ones Node's body parser uses.
|
|
292
|
+
if (turnBodyTooLarge(body)) {
|
|
273
293
|
return c.json(
|
|
274
294
|
{ statusCode: 413, message: 'request entity too large' },
|
|
275
295
|
413,
|
|
@@ -451,9 +471,33 @@ export function createShell(
|
|
|
451
471
|
app.post('/debug/storage/flush', async (c) =>
|
|
452
472
|
c.json(await userStub(c.env, c.get('auth').userDid).flushToOwnerStore()),
|
|
453
473
|
);
|
|
474
|
+
app.post('/debug/storage/tier-flush', async (c) => {
|
|
475
|
+
const body: unknown = await c.req.json().catch(() => ({}));
|
|
476
|
+
const rec = typeof body === 'object' && body !== null ? body : {};
|
|
477
|
+
const force = Reflect.get(rec, 'force') === true;
|
|
478
|
+
const max = Number(Reflect.get(rec, 'maxSegments'));
|
|
479
|
+
return c.json(
|
|
480
|
+
await userStub(c.env, c.get('auth').userDid).tierFlush({
|
|
481
|
+
force,
|
|
482
|
+
...(Number.isInteger(max) && max > 0 && { maxSegments: max }),
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
454
486
|
app.post('/debug/storage/reset', async (c) =>
|
|
455
487
|
c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
|
|
456
488
|
);
|
|
489
|
+
app.post('/debug/object/abort', async (c) => {
|
|
490
|
+
try {
|
|
491
|
+
await userStub(c.env, c.get('auth').userDid).debugAbortObject();
|
|
492
|
+
} catch (err) {
|
|
493
|
+
// The abort tears the object down under the call: that rejection is
|
|
494
|
+
// the expected signal. Anything else (an older build without the
|
|
495
|
+
// method, a transport error) is a real failure.
|
|
496
|
+
if (!isAbortRejection(err))
|
|
497
|
+
return c.json({ aborted: false, error: errorText(err) }, 500);
|
|
498
|
+
}
|
|
499
|
+
return c.json({ aborted: true });
|
|
500
|
+
});
|
|
457
501
|
app.get('/debug/memory-schema', async (c) => {
|
|
458
502
|
const userDid = c.get('auth').userDid;
|
|
459
503
|
return c.json(await userStub(c.env, userDid).debugMemorySchema(userDid));
|
|
@@ -469,11 +513,19 @@ export function createShell(
|
|
|
469
513
|
: c.json({ statusCode: 404, message: 'Session not found' }, 404);
|
|
470
514
|
});
|
|
471
515
|
app.get('/debug/tasks', async (c) =>
|
|
472
|
-
c.json(
|
|
516
|
+
c.json(
|
|
517
|
+
await userStub(c.env, c.get('auth').userDid).tasksStatus(
|
|
518
|
+
c.get('auth').userDid,
|
|
519
|
+
),
|
|
520
|
+
),
|
|
473
521
|
);
|
|
474
522
|
app.get('/debug/realtime', async (c) =>
|
|
475
523
|
c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
|
|
476
524
|
);
|
|
525
|
+
app.post('/debug/reauth-prompt/reset', async (c) => {
|
|
526
|
+
await userStub(c.env, c.get('auth').userDid).debugResetReauthThrottle();
|
|
527
|
+
return c.json({ reset: true });
|
|
528
|
+
});
|
|
477
529
|
app.get('/debug/delegation', async (c) =>
|
|
478
530
|
c.json(
|
|
479
531
|
await userStub(c.env, c.get('auth').userDid).delegationStatus(
|
|
@@ -487,6 +539,39 @@ export function createShell(
|
|
|
487
539
|
app.get('/debug/matrix/outbox', async (c) =>
|
|
488
540
|
c.json({ rows: await gateway(c.env).listOutbox() }),
|
|
489
541
|
);
|
|
542
|
+
// Drill: post a custom event into the caller's own room under a
|
|
543
|
+
// caller-pinned transaction id. The same id twice returns the same event
|
|
544
|
+
// (the homeserver deduplicates per device), which is what makes a
|
|
545
|
+
// best-effort post retried across a gateway restart land once.
|
|
546
|
+
app.post('/debug/matrix/event', async (c) => {
|
|
547
|
+
const parsed = DebugEventBody.safeParse(
|
|
548
|
+
await c.req.json().catch(() => null),
|
|
549
|
+
);
|
|
550
|
+
if (!parsed.success)
|
|
551
|
+
return c.json(
|
|
552
|
+
{ error: 'expected { type: string, content: object, txnId?: string }' },
|
|
553
|
+
400,
|
|
554
|
+
);
|
|
555
|
+
const room = await gateway(c.env).resolveUserRoom(c.get('auth').userDid);
|
|
556
|
+
if (!room) return c.json({ error: 'no room for this user' }, 404);
|
|
557
|
+
const { type, content, txnId } = parsed.data;
|
|
558
|
+
const eventId = await gateway(c.env).sendEvent(
|
|
559
|
+
room.roomId,
|
|
560
|
+
type,
|
|
561
|
+
JSON.stringify(content),
|
|
562
|
+
txnId === undefined ? {} : { txnId },
|
|
563
|
+
);
|
|
564
|
+
return c.json({ roomId: room.roomId, eventId });
|
|
565
|
+
});
|
|
566
|
+
app.post('/debug/matrix/abort', async (c) => {
|
|
567
|
+
try {
|
|
568
|
+
await gateway(c.env).debugAbortObject();
|
|
569
|
+
} catch (err) {
|
|
570
|
+
if (!isAbortRejection(err))
|
|
571
|
+
return c.json({ aborted: false, error: errorText(err) }, 500);
|
|
572
|
+
}
|
|
573
|
+
return c.json({ aborted: true });
|
|
574
|
+
});
|
|
490
575
|
app.post('/debug/matrix/restart', async (c) =>
|
|
491
576
|
c.json(await gateway(c.env).restart()),
|
|
492
577
|
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upper bound on a chat turn's request body (`POST /messages/:sessionId`).
|
|
3
|
+
*
|
|
4
|
+
* The cap bounds the memory one request can pin: the shell reads the body in
|
|
5
|
+
* full before forwarding it to the user object. It no longer mirrors the Node
|
|
6
|
+
* runtime, which keeps Express body-parser's 100 kb default — a normal Portal
|
|
7
|
+
* turn now exceeds that. Every turn carries the browser-tool catalogue (26–32
|
|
8
|
+
* tools, 65–70 KiB of JSON schema) and the AG-UI action schemas (~27 KiB)
|
|
9
|
+
* before the message text, and a compose-topic turn adds the pinned text in
|
|
10
|
+
* `metadata.contextToolCall`; on devnet turns of 102–115 KiB were refused.
|
|
11
|
+
* 256 KiB leaves room for those. Attachments never count: files go to Matrix
|
|
12
|
+
* media first and the body carries only their references.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_TURN_BODY_BYTES = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Whether a raw body is over the cap, measured as the UTF-8 bytes the wire carried. */
|
|
17
|
+
export function turnBodyTooLarge(body: string): boolean {
|
|
18
|
+
return new TextEncoder().encode(body).byteLength > MAX_TURN_BODY_BYTES;
|
|
19
|
+
}
|
|
@@ -19,22 +19,43 @@ export function parseChunkCacheBytes(
|
|
|
19
19
|
raw: string | undefined,
|
|
20
20
|
warn: (message: string) => void = () => undefined,
|
|
21
21
|
): number {
|
|
22
|
-
|
|
22
|
+
return parseByteSize(raw, {
|
|
23
|
+
name: 'CHUNK_CACHE_BYTES',
|
|
24
|
+
fallback: DEFAULT_CHUNK_CACHE_BYTES,
|
|
25
|
+
min: MIN_CHUNK_CACHE_BYTES,
|
|
26
|
+
max: MAX_CHUNK_CACHE_BYTES,
|
|
27
|
+
warn,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Same grammar for any byte-sized env knob (`TIER_HOT_BUDGET_BYTES`, ...). */
|
|
32
|
+
export function parseByteSize(
|
|
33
|
+
raw: string | undefined,
|
|
34
|
+
options: {
|
|
35
|
+
name: string;
|
|
36
|
+
fallback: number;
|
|
37
|
+
min: number;
|
|
38
|
+
max: number;
|
|
39
|
+
warn?: (message: string) => void;
|
|
40
|
+
},
|
|
41
|
+
): number {
|
|
42
|
+
const warn = options.warn ?? (() => undefined);
|
|
43
|
+
if (raw === undefined || raw.trim() === '') return options.fallback;
|
|
23
44
|
const match = /^\s*(\d+)\s*([kKmM]?)\s*$/.exec(raw);
|
|
24
45
|
if (!match) {
|
|
25
46
|
warn(
|
|
26
|
-
|
|
47
|
+
`${options.name}=${JSON.stringify(raw)} is not a byte count — using the default ${options.fallback}`,
|
|
27
48
|
);
|
|
28
|
-
return
|
|
49
|
+
return options.fallback;
|
|
29
50
|
}
|
|
30
51
|
const unit = match[2]?.toLowerCase();
|
|
31
52
|
const multiplier = unit === 'k' ? 1024 : unit === 'm' ? 1024 * 1024 : 1;
|
|
32
53
|
const bytes = Number(match[1]) * multiplier;
|
|
33
|
-
if (bytes <
|
|
54
|
+
if (bytes < options.min || bytes > options.max) {
|
|
34
55
|
warn(
|
|
35
|
-
|
|
56
|
+
`${options.name}=${raw} is outside ${options.min}..${options.max} — using the default ${options.fallback}`,
|
|
36
57
|
);
|
|
37
|
-
return
|
|
58
|
+
return options.fallback;
|
|
38
59
|
}
|
|
39
60
|
return bytes;
|
|
40
61
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage grain shared by the DO VFS and the R2 page tier. Kept in its
|
|
3
|
+
* own module so `page-tier.ts` can import it without a cycle through
|
|
4
|
+
* `do-vfs.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Bytes per SQLite page. Matches `PRAGMA page_size=4096`. */
|
|
8
|
+
export const VFS_PAGE_SIZE = 4096;
|
|
9
|
+
|
|
10
|
+
/** SQLite pages packed into one stored row (billing grain). */
|
|
11
|
+
export const PAGES_PER_CHUNK = 16;
|
|
12
|
+
|
|
13
|
+
/** Bytes per stored chunk row (64 KiB — well under the 2 MB DO value cap). */
|
|
14
|
+
export const CHUNK_SIZE = VFS_PAGE_SIZE * PAGES_PER_CHUNK;
|
package/src/sqlite/database.ts
CHANGED
|
@@ -31,14 +31,28 @@ import {
|
|
|
31
31
|
VFS_PAGE_SIZE,
|
|
32
32
|
type DoVfsOptions,
|
|
33
33
|
type DoVfsStats,
|
|
34
|
+
type TierFlushResult,
|
|
35
|
+
type TierStatus,
|
|
34
36
|
type VfsSnapshot,
|
|
35
37
|
} from './do-vfs';
|
|
38
|
+
import type { PageTierOptions } from './page-tier';
|
|
36
39
|
import {
|
|
37
40
|
getOrRegisterVfs,
|
|
38
41
|
loadSqlite,
|
|
39
42
|
type SqliteRuntime,
|
|
40
43
|
} from './wa-sqlite-loader';
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Cold-miss retries. A statement outside a transaction re-runs after each
|
|
47
|
+
* resolve; a transaction rolls back, resolves, and re-runs its callback.
|
|
48
|
+
* Every resolve fetches whole 1 MiB segments (plus one of read-ahead) and
|
|
49
|
+
* pins them for the retry, so even a scan across a cold file converges in
|
|
50
|
+
* a handful of rounds; the caps only guard against a bug that keeps
|
|
51
|
+
* recording misses.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_STATEMENT_MISS_RETRIES = 64;
|
|
54
|
+
const MAX_TRANSACTION_MISS_RETRIES = 32;
|
|
55
|
+
|
|
42
56
|
export type SqlValue = null | number | bigint | string | Uint8Array;
|
|
43
57
|
export type SqlParam = SqlValue | boolean | undefined;
|
|
44
58
|
export type SqlParams =
|
|
@@ -136,7 +150,10 @@ export class DoSqliteDatabase {
|
|
|
136
150
|
}
|
|
137
151
|
vfs.attach(ctx.storage);
|
|
138
152
|
}
|
|
139
|
-
if (!created)
|
|
153
|
+
if (!created) {
|
|
154
|
+
vfs.configureCache(options.cachePages);
|
|
155
|
+
vfs.configureTier(options.tier);
|
|
156
|
+
}
|
|
140
157
|
const pragmas = options.pragmas ?? DEFAULT_PRAGMAS;
|
|
141
158
|
const db = await DoSqliteDatabase.openConnection(
|
|
142
159
|
runtime,
|
|
@@ -152,6 +169,38 @@ export class DoSqliteDatabase {
|
|
|
152
169
|
vfs: DoVfs,
|
|
153
170
|
fileName: string,
|
|
154
171
|
pragmas: readonly string[],
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
// Opening reads the file header and the pragmas read page 1 — before
|
|
174
|
+
// any statement of ours could retry a cold miss. Chunk 0 is never
|
|
175
|
+
// evicted, but a pin-less retry here keeps a reopen robust regardless.
|
|
176
|
+
let pinned = false;
|
|
177
|
+
try {
|
|
178
|
+
for (let attempt = 0; ; attempt++) {
|
|
179
|
+
try {
|
|
180
|
+
return await DoSqliteDatabase.openConnectionOnce(
|
|
181
|
+
runtime,
|
|
182
|
+
vfs,
|
|
183
|
+
fileName,
|
|
184
|
+
pragmas,
|
|
185
|
+
);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!vfs.hasMisses() || attempt >= MAX_STATEMENT_MISS_RETRIES)
|
|
188
|
+
throw error;
|
|
189
|
+
await vfs.resolveMisses();
|
|
190
|
+
pinned = true;
|
|
191
|
+
vfs.missRetries++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
if (pinned) vfs.releasePins();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private static async openConnectionOnce(
|
|
200
|
+
runtime: SqliteRuntime,
|
|
201
|
+
vfs: DoVfs,
|
|
202
|
+
fileName: string,
|
|
203
|
+
pragmas: readonly string[],
|
|
155
204
|
): Promise<number> {
|
|
156
205
|
const db = await runtime.sqlite3.open_v2(
|
|
157
206
|
fileName,
|
|
@@ -203,12 +252,54 @@ export class DoSqliteDatabase {
|
|
|
203
252
|
sql: string,
|
|
204
253
|
params?: SqlParams,
|
|
205
254
|
): Promise<T[]> {
|
|
206
|
-
return this.serialized(() =>
|
|
255
|
+
return this.serialized(() =>
|
|
256
|
+
this.retryingColdMisses(() => this.execUnlocked<T>(sql, params)),
|
|
257
|
+
);
|
|
207
258
|
}
|
|
208
259
|
|
|
209
260
|
/** Run a statement that produces no rows of interest; returns the change count. */
|
|
210
261
|
async run(sql: string, params?: SqlParams): Promise<RunResult> {
|
|
211
|
-
return this.serialized(() =>
|
|
262
|
+
return this.serialized(() =>
|
|
263
|
+
this.retryingColdMisses(() => this.runUnlocked(sql, params)),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Run one statement, re-running it after a cold-miss resolve. Only
|
|
269
|
+
* outside a transaction: inside one, SQLite may have put the pager into
|
|
270
|
+
* its error state, so the transaction as a whole is rolled back and
|
|
271
|
+
* retried by `transaction()` instead.
|
|
272
|
+
*/
|
|
273
|
+
private async retryingColdMisses<T>(op: () => Promise<T>): Promise<T> {
|
|
274
|
+
if (this.txContext.getStore() !== undefined || this.txDepth > 0)
|
|
275
|
+
return op();
|
|
276
|
+
let pinned = false;
|
|
277
|
+
try {
|
|
278
|
+
for (let attempt = 0; ; attempt++) {
|
|
279
|
+
try {
|
|
280
|
+
const result = await op();
|
|
281
|
+
// A few statements swallow an I/O error into their result instead
|
|
282
|
+
// of failing (`PRAGMA integrity_check` reports it as corruption):
|
|
283
|
+
// misses left behind by a "successful" statement mean exactly
|
|
284
|
+
// that, so resolve and run it again.
|
|
285
|
+
if (this.vfs.hasMisses() && attempt < MAX_STATEMENT_MISS_RETRIES) {
|
|
286
|
+
await this.vfs.resolveMisses();
|
|
287
|
+
pinned = true;
|
|
288
|
+
this.vfs.missRetries++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!this.vfs.hasMisses() || attempt >= MAX_STATEMENT_MISS_RETRIES)
|
|
294
|
+
throw error;
|
|
295
|
+
await this.vfs.resolveMisses();
|
|
296
|
+
pinned = true;
|
|
297
|
+
this.vfs.missRetries++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
if (pinned) this.vfs.releasePins();
|
|
302
|
+
}
|
|
212
303
|
}
|
|
213
304
|
|
|
214
305
|
/**
|
|
@@ -329,24 +420,50 @@ export class DoSqliteDatabase {
|
|
|
329
420
|
});
|
|
330
421
|
await previous;
|
|
331
422
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
} catch (error) {
|
|
341
|
-
this.txDepth--;
|
|
423
|
+
// A cold page inside `fn` fails a statement with SQLITE_IOERR; the
|
|
424
|
+
// transaction is rolled back, the segment(s) fetched and pinned, and
|
|
425
|
+
// `fn` re-run from the top (it must be re-runnable: the checkpointer's
|
|
426
|
+
// and stores' callbacks only issue statements). The pins are released
|
|
427
|
+
// to the LRU once the transaction ends either way.
|
|
428
|
+
let pinned = false;
|
|
429
|
+
try {
|
|
430
|
+
for (let attempt = 0; ; attempt++) {
|
|
342
431
|
try {
|
|
343
|
-
await this.run(
|
|
344
|
-
|
|
345
|
-
|
|
432
|
+
return await this.txContext.run({ depth: 1 }, async () => {
|
|
433
|
+
await this.run('BEGIN IMMEDIATE');
|
|
434
|
+
this.txDepth++;
|
|
435
|
+
try {
|
|
436
|
+
const result = await fn();
|
|
437
|
+
this.txDepth--;
|
|
438
|
+
await this.run('COMMIT');
|
|
439
|
+
// Misses a statement swallowed (see `retryingColdMisses`)
|
|
440
|
+
// must not trigger a retry of some later, unrelated error.
|
|
441
|
+
this.vfs.clearMisses();
|
|
442
|
+
return result;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
this.txDepth--;
|
|
445
|
+
try {
|
|
446
|
+
await this.run('ROLLBACK');
|
|
447
|
+
} catch {
|
|
448
|
+
// The original error is more useful than a rollback failure.
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (
|
|
455
|
+
!this.vfs.hasMisses() ||
|
|
456
|
+
attempt >= MAX_TRANSACTION_MISS_RETRIES
|
|
457
|
+
)
|
|
458
|
+
throw error;
|
|
459
|
+
await this.vfs.resolveMisses();
|
|
460
|
+
pinned = true;
|
|
461
|
+
this.vfs.missRetries++;
|
|
346
462
|
}
|
|
347
|
-
throw error;
|
|
348
463
|
}
|
|
349
|
-
}
|
|
464
|
+
} finally {
|
|
465
|
+
if (pinned) this.vfs.releasePins();
|
|
466
|
+
}
|
|
350
467
|
} finally {
|
|
351
468
|
release();
|
|
352
469
|
}
|
|
@@ -387,15 +504,17 @@ export class DoSqliteDatabase {
|
|
|
387
504
|
static async wipe(
|
|
388
505
|
ctx: DoSqliteContext,
|
|
389
506
|
fileName = 'oracle.db',
|
|
507
|
+
options: { tier?: PageTierOptions } = {},
|
|
390
508
|
): Promise<void> {
|
|
391
509
|
const runtime = await loadSqlite();
|
|
392
510
|
const vfsName = vfsNameForObject(ctx);
|
|
393
511
|
const vfs = getOrRegisterVfs(
|
|
394
512
|
runtime,
|
|
395
513
|
vfsName,
|
|
396
|
-
() => new DoVfs(vfsName, ctx.storage),
|
|
514
|
+
() => new DoVfs(vfsName, ctx.storage, { tier: options.tier }),
|
|
397
515
|
isDoVfs,
|
|
398
516
|
);
|
|
517
|
+
vfs.configureTier(options.tier);
|
|
399
518
|
for (const handle of vfs.takeConnections()) {
|
|
400
519
|
try {
|
|
401
520
|
await runtime.sqlite3.close(handle);
|
|
@@ -410,6 +529,53 @@ export class DoSqliteDatabase {
|
|
|
410
529
|
// leaked xOpen handle cannot block the delete.
|
|
411
530
|
vfs.attach(ctx.storage);
|
|
412
531
|
vfs.deleteFile(fileName);
|
|
532
|
+
// The R2 side of the working copy goes with it (nothing references it
|
|
533
|
+
// any more; the owner's file upstream is untouched).
|
|
534
|
+
await vfs.deleteTierObjects();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// R2 page tier controls (see do-vfs.ts / page-tier.ts)
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
get tierEnabled(): boolean {
|
|
542
|
+
return this.vfs.tierEnabled;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** One eviction pass; holds the transaction mutex so no write interleaves. */
|
|
546
|
+
async tierFlush(
|
|
547
|
+
opts: { force?: boolean; maxSegments?: number } = {},
|
|
548
|
+
): Promise<TierFlushResult> {
|
|
549
|
+
if (this.txDepth > 0)
|
|
550
|
+
throw new DoVfsError('tierFlush() is not allowed inside a transaction');
|
|
551
|
+
return this.withoutTransactions(() =>
|
|
552
|
+
this.vfs.tierFlush(this.fileName, opts),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Persist the chunks touched since the last call (call at the end of a turn). */
|
|
557
|
+
recordAccess(): void {
|
|
558
|
+
this.vfs.recordAccess(this.fileName);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
tierStatus(): TierStatus {
|
|
562
|
+
return this.vfs.tierStatus(this.fileName);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Delete queued/orphaned R2 objects. */
|
|
566
|
+
async tierMaintenance(
|
|
567
|
+
opts: { sweep?: boolean } = {},
|
|
568
|
+
): Promise<{ deleted: number }> {
|
|
569
|
+
return this.vfs.tierMaintenance(opts);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Pull every cold chunk back into storage (the file is then fully hot). */
|
|
573
|
+
async materializeTier(): Promise<{ chunks: number }> {
|
|
574
|
+
if (this.txDepth > 0)
|
|
575
|
+
throw new DoVfsError(
|
|
576
|
+
'materializeTier() is not allowed inside a transaction',
|
|
577
|
+
);
|
|
578
|
+
return this.withoutTransactions(() => this.vfs.materialize(this.fileName));
|
|
413
579
|
}
|
|
414
580
|
|
|
415
581
|
/** Close the connection. Buffered pages are flushed by the VFS on close. */
|
|
@@ -535,6 +701,9 @@ export class DoSqliteDatabase {
|
|
|
535
701
|
return this.withoutTransactions(async () => {
|
|
536
702
|
const beforeBytes = this.fileSize;
|
|
537
703
|
if (this.vfs.fileExists(target)) this.vfs.deleteFile(target);
|
|
704
|
+
// VACUUM reads the whole file through the synchronous path; a cold
|
|
705
|
+
// chunk would fail and restart it. Bring everything home first.
|
|
706
|
+
if (this.vfs.tierEnabled) await this.vfs.materialize(this.fileName);
|
|
538
707
|
// `fileName` is a constant of the runtime, never user input.
|
|
539
708
|
await this.run(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
540
709
|
await this.close();
|