@shipfox/api-logs 2.0.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/api/object-storage.d.ts +69 -0
- package/dist/api/object-storage.d.ts.map +1 -0
- package/dist/api/object-storage.js +199 -0
- package/dist/api/object-storage.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -0
- package/dist/core/append-logs.d.ts +28 -0
- package/dist/core/append-logs.d.ts.map +1 -0
- package/dist/core/append-logs.js +318 -0
- package/dist/core/append-logs.js.map +1 -0
- package/dist/core/budget.d.ts +12 -0
- package/dist/core/budget.d.ts.map +1 -0
- package/dist/core/budget.js +9 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/close-stream.d.ts +27 -0
- package/dist/core/close-stream.d.ts.map +1 -0
- package/dist/core/close-stream.js +59 -0
- package/dist/core/close-stream.js.map +1 -0
- package/dist/core/compaction.d.ts +28 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +49 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/entities/attempt-stream.d.ts +37 -0
- package/dist/core/entities/attempt-stream.d.ts.map +1 -0
- package/dist/core/entities/attempt-stream.js +15 -0
- package/dist/core/entities/attempt-stream.js.map +1 -0
- package/dist/core/entities/job-accounting.d.ts +12 -0
- package/dist/core/entities/job-accounting.d.ts.map +1 -0
- package/dist/core/entities/job-accounting.js +6 -0
- package/dist/core/entities/job-accounting.js.map +1 -0
- package/dist/core/entities/log-object.d.ts +13 -0
- package/dist/core/entities/log-object.d.ts.map +1 -0
- package/dist/core/entities/log-object.js +9 -0
- package/dist/core/entities/log-object.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +31 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/finalize-attempt-stream.d.ts +22 -0
- package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
- package/dist/core/finalize-attempt-stream.js +48 -0
- package/dist/core/finalize-attempt-stream.js.map +1 -0
- package/dist/core/read-logs.d.ts +33 -0
- package/dist/core/read-logs.d.ts.map +1 -0
- package/dist/core/read-logs.js +49 -0
- package/dist/core/read-logs.js.map +1 -0
- package/dist/core/reap-stale-open-streams.d.ts +27 -0
- package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/core/reap-stale-open-streams.js +50 -0
- package/dist/core/reap-stale-open-streams.js.map +1 -0
- package/dist/core/retention.d.ts +32 -0
- package/dist/core/retention.d.ts.map +1 -0
- package/dist/core/retention.js +148 -0
- package/dist/core/retention.js.map +1 -0
- package/dist/core/session/claude/rows.d.ts +6 -0
- package/dist/core/session/claude/rows.d.ts.map +1 -0
- package/dist/core/session/claude/rows.js +145 -0
- package/dist/core/session/claude/rows.js.map +1 -0
- package/dist/core/session/claude-parser.d.ts +4 -0
- package/dist/core/session/claude-parser.d.ts.map +1 -0
- package/dist/core/session/claude-parser.js +42 -0
- package/dist/core/session/claude-parser.js.map +1 -0
- package/dist/core/session/entry-schema.d.ts +51 -0
- package/dist/core/session/entry-schema.d.ts.map +1 -0
- package/dist/core/session/entry-schema.js +61 -0
- package/dist/core/session/entry-schema.js.map +1 -0
- package/dist/core/session/object.d.ts +12 -0
- package/dist/core/session/object.d.ts.map +1 -0
- package/dist/core/session/object.js +50 -0
- package/dist/core/session/object.js.map +1 -0
- package/dist/core/session/parse-session.d.ts +5 -0
- package/dist/core/session/parse-session.d.ts.map +1 -0
- package/dist/core/session/parse-session.js +18 -0
- package/dist/core/session/parse-session.js.map +1 -0
- package/dist/core/session/pi/message-rows.d.ts +4 -0
- package/dist/core/session/pi/message-rows.d.ts.map +1 -0
- package/dist/core/session/pi/message-rows.js +113 -0
- package/dist/core/session/pi/message-rows.js.map +1 -0
- package/dist/core/session/pi/metadata.d.ts +18 -0
- package/dist/core/session/pi/metadata.d.ts.map +1 -0
- package/dist/core/session/pi/metadata.js +120 -0
- package/dist/core/session/pi/metadata.js.map +1 -0
- package/dist/core/session/pi/text.d.ts +12 -0
- package/dist/core/session/pi/text.d.ts.map +1 -0
- package/dist/core/session/pi/text.js +59 -0
- package/dist/core/session/pi/text.js.map +1 -0
- package/dist/core/session/pi-parser.d.ts +5 -0
- package/dist/core/session/pi-parser.d.ts.map +1 -0
- package/dist/core/session/pi-parser.js +73 -0
- package/dist/core/session/pi-parser.js.map +1 -0
- package/dist/core/session/rows.d.ts +7 -0
- package/dist/core/session/rows.d.ts.map +1 -0
- package/dist/core/session/rows.js +39 -0
- package/dist/core/session/rows.js.map +1 -0
- package/dist/core/session/session-record.d.ts +5 -0
- package/dist/core/session/session-record.d.ts.map +1 -0
- package/dist/core/session/session-record.js +3 -0
- package/dist/core/session/session-record.js.map +1 -0
- package/dist/db/accounting.d.ts +33 -0
- package/dist/db/accounting.d.ts.map +1 -0
- package/dist/db/accounting.js +52 -0
- package/dist/db/accounting.js.map +1 -0
- package/dist/db/chunks.d.ts +62 -0
- package/dist/db/chunks.d.ts.map +1 -0
- package/dist/db/chunks.js +88 -0
- package/dist/db/chunks.js.map +1 -0
- package/dist/db/db.d.ts +752 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +5 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/attempt-streams.d.ts +304 -0
- package/dist/db/schema/attempt-streams.d.ts.map +1 -0
- package/dist/db/schema/attempt-streams.js +100 -0
- package/dist/db/schema/attempt-streams.js.map +1 -0
- package/dist/db/schema/chunks.d.ts +166 -0
- package/dist/db/schema/chunks.d.ts.map +1 -0
- package/dist/db/schema/chunks.js +47 -0
- package/dist/db/schema/chunks.js.map +1 -0
- package/dist/db/schema/common.d.ts +29 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +11 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/job-accounting.d.ts +143 -0
- package/dist/db/schema/job-accounting.d.ts.map +1 -0
- package/dist/db/schema/job-accounting.js +43 -0
- package/dist/db/schema/job-accounting.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/streams.d.ts +141 -0
- package/dist/db/streams.d.ts.map +1 -0
- package/dist/db/streams.js +197 -0
- package/dist/db/streams.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +16 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +20 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/read-logs.d.ts +4 -0
- package/dist/presentation/dto/read-logs.d.ts.map +1 -0
- package/dist/presentation/dto/read-logs.js +22 -0
- package/dist/presentation/dto/read-logs.js.map +1 -0
- package/dist/presentation/routes/append-logs.d.ts +2 -0
- package/dist/presentation/routes/append-logs.d.ts.map +1 -0
- package/dist/presentation/routes/append-logs.js +71 -0
- package/dist/presentation/routes/append-logs.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +32 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/read-logs.d.ts +2 -0
- package/dist/presentation/routes/read-logs.d.ts.map +1 -0
- package/dist/presentation/routes/read-logs.js +41 -0
- package/dist/presentation/routes/read-logs.js.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
- package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-job-terminated.js +32 -0
- package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
- package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
- package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
- package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
- package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/activities/close-abandoned-streams.js +33 -0
- package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/activities/compact-stream.d.ts +33 -0
- package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
- package/dist/temporal/activities/compact-stream.js +95 -0
- package/dist/temporal/activities/compact-stream.js.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
- package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
- package/dist/temporal/activities/compaction-reconcile.js +52 -0
- package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +14 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +16 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
- package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
- package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
- package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
- package/dist/temporal/activities/retention-sweep.d.ts +7 -0
- package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
- package/dist/temporal/activities/retention-sweep.js +19 -0
- package/dist/temporal/activities/retention-sweep.js.map +1 -0
- package/dist/temporal/constants.d.ts +7 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +13 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
- package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
- package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
- package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
- package/dist/temporal/workflows/compact-stream.d.ts +6 -0
- package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
- package/dist/temporal/workflows/compact-stream.js +25 -0
- package/dist/temporal/workflows/compact-stream.js.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
- package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +6 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +7 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
- package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
- package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
- package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +65 -0
- package/drizzle/meta/0000_snapshot.json +529 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +84 -0
- package/src/api/object-storage-credentials.test.ts +70 -0
- package/src/api/object-storage.test.ts +64 -0
- package/src/api/object-storage.ts +226 -0
- package/src/config.test.ts +118 -0
- package/src/config.ts +130 -0
- package/src/core/append-logs.test.ts +563 -0
- package/src/core/append-logs.ts +384 -0
- package/src/core/budget.test.ts +30 -0
- package/src/core/budget.ts +19 -0
- package/src/core/close-stream.ts +72 -0
- package/src/core/compaction.ts +68 -0
- package/src/core/entities/attempt-stream.ts +38 -0
- package/src/core/entities/job-accounting.ts +11 -0
- package/src/core/entities/log-object.test.ts +25 -0
- package/src/core/entities/log-object.ts +18 -0
- package/src/core/errors.ts +37 -0
- package/src/core/finalize-attempt-stream.test.ts +90 -0
- package/src/core/finalize-attempt-stream.ts +59 -0
- package/src/core/read-logs.ts +77 -0
- package/src/core/reap-stale-open-streams.test.ts +137 -0
- package/src/core/reap-stale-open-streams.ts +60 -0
- package/src/core/retention.test.ts +388 -0
- package/src/core/retention.ts +184 -0
- package/src/core/session/claude/rows.ts +223 -0
- package/src/core/session/claude-parser.test.ts +199 -0
- package/src/core/session/claude-parser.ts +38 -0
- package/src/core/session/entry-schema.ts +82 -0
- package/src/core/session/object.ts +57 -0
- package/src/core/session/parse-session.ts +23 -0
- package/src/core/session/pi/message-rows.ts +211 -0
- package/src/core/session/pi/metadata.ts +148 -0
- package/src/core/session/pi/text.ts +83 -0
- package/src/core/session/pi-parser.test.ts +451 -0
- package/src/core/session/pi-parser.ts +107 -0
- package/src/core/session/rows.ts +38 -0
- package/src/core/session/session-record.ts +4 -0
- package/src/db/accounting.ts +81 -0
- package/src/db/chunks.ts +143 -0
- package/src/db/db.ts +22 -0
- package/src/db/index.ts +4 -0
- package/src/db/schema/attempt-streams.ts +106 -0
- package/src/db/schema/chunks.ts +43 -0
- package/src/db/schema/common.ts +12 -0
- package/src/db/schema/job-accounting.ts +38 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/streams.test.ts +47 -0
- package/src/db/streams.ts +368 -0
- package/src/index.ts +77 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +33 -0
- package/src/metrics/service.ts +23 -0
- package/src/presentation/dto/read-logs.ts +24 -0
- package/src/presentation/routes/append-logs.test.ts +279 -0
- package/src/presentation/routes/append-logs.ts +66 -0
- package/src/presentation/routes/index.ts +27 -0
- package/src/presentation/routes/read-logs.test.ts +504 -0
- package/src/presentation/routes/read-logs.ts +38 -0
- package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
- package/src/presentation/subscribers/on-job-terminated.ts +26 -0
- package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
- package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
- package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
- package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
- package/src/temporal/activities/close-abandoned-streams.ts +30 -0
- package/src/temporal/activities/compact-stream.test.ts +221 -0
- package/src/temporal/activities/compact-stream.ts +123 -0
- package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
- package/src/temporal/activities/compaction-reconcile.ts +48 -0
- package/src/temporal/activities/index.ts +15 -0
- package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
- package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
- package/src/temporal/activities/retention-sweep.test.ts +38 -0
- package/src/temporal/activities/retention-sweep.ts +23 -0
- package/src/temporal/constants.ts +14 -0
- package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
- package/src/temporal/workflows/compact-stream.ts +26 -0
- package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
- package/src/temporal/workflows/index.ts +5 -0
- package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
- package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
- package/test/env.ts +37 -0
- package/test/factories/job-accounting.ts +38 -0
- package/test/fixtures/closed-stream.ts +53 -0
- package/test/fixtures/lease-token.ts +42 -0
- package/test/fixtures/ndjson.ts +34 -0
- package/test/globalSetup.ts +44 -0
- package/test/index.ts +9 -0
- package/test/queries.ts +54 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +13 -0
package/src/db/chunks.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {and, asc, eq, gt, lte, sql} from 'drizzle-orm';
|
|
3
|
+
import {db, type Transaction} from './db.js';
|
|
4
|
+
import {type ChunkOrigin, logChunks} from './schema/chunks.js';
|
|
5
|
+
|
|
6
|
+
export interface InsertChunkParams {
|
|
7
|
+
streamId: string;
|
|
8
|
+
streamOffset: number;
|
|
9
|
+
byteLen: number;
|
|
10
|
+
data: Buffer;
|
|
11
|
+
origin: ChunkOrigin;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function insertChunk(tx: Transaction, params: InsertChunkParams): Promise<void> {
|
|
15
|
+
await tx.insert(logChunks).values({
|
|
16
|
+
streamId: params.streamId,
|
|
17
|
+
streamOffset: params.streamOffset,
|
|
18
|
+
byteLen: params.byteLen,
|
|
19
|
+
data: params.data,
|
|
20
|
+
origin: params.origin,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChunkPageRow {
|
|
25
|
+
seq: number;
|
|
26
|
+
data: Buffer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One keyset page of a stream's chunk bytes in `seq` order. Compaction walks pages
|
|
31
|
+
* with `afterSeq = lastSeqOfPreviousPage`, so memory stays flat regardless of stream
|
|
32
|
+
* size, and reads run outside any transaction (no tx is pinned for the long upload).
|
|
33
|
+
* Safe against late writers because a closed stream never accepts another chunk.
|
|
34
|
+
*/
|
|
35
|
+
export async function readChunksKeyset(params: {
|
|
36
|
+
streamId: string;
|
|
37
|
+
afterSeq: number;
|
|
38
|
+
limit: number;
|
|
39
|
+
}): Promise<ChunkPageRow[]> {
|
|
40
|
+
const rows = await db()
|
|
41
|
+
.select({seq: logChunks.seq, data: logChunks.data})
|
|
42
|
+
.from(logChunks)
|
|
43
|
+
.where(and(eq(logChunks.streamId, params.streamId), gt(logChunks.seq, params.afterSeq)))
|
|
44
|
+
.orderBy(asc(logChunks.seq))
|
|
45
|
+
.limit(params.limit);
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChunkPage {
|
|
50
|
+
/** Concatenated chunk bytes for the page, in `seq` order: ready-to-serve NDJSON. */
|
|
51
|
+
data: Buffer;
|
|
52
|
+
/** The last `seq` included; the next read passes this as `afterSeq`. */
|
|
53
|
+
nextSeq: number;
|
|
54
|
+
/** Whether at least one more chunk exists past `nextSeq`. */
|
|
55
|
+
hasMore: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Caps the per-read metadata scan so a stream with pathologically many tiny chunks
|
|
59
|
+
// can't make one read walk the whole stream; the client re-polls (hasMore) past it.
|
|
60
|
+
const CHUNK_PAGE_SCAN_CAP = 4096;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A byte-bounded page of a stream's chunk bytes in `seq` order, for the inline read path.
|
|
64
|
+
* Walking by `seq` (not the runner byte offset) is what makes server-injected control tombstones
|
|
65
|
+
* interleave with normalized runner records exactly as compaction concatenates them, so the inline
|
|
66
|
+
* NDJSON is byte-identical to the compacted object.
|
|
67
|
+
*
|
|
68
|
+
* Returns at most ~`maxBytes`, but always at least one whole chunk so the cursor advances
|
|
69
|
+
* even when a single chunk exceeds `maxBytes`. The cheap `(seq, byte_len)` scan picks the
|
|
70
|
+
* prefix first, so the heavy `data` column is only materialized for the chunks actually
|
|
71
|
+
* returned.
|
|
72
|
+
*/
|
|
73
|
+
export async function readChunkPageBySeq(params: {
|
|
74
|
+
streamId: string;
|
|
75
|
+
afterSeq: number;
|
|
76
|
+
maxBytes: number;
|
|
77
|
+
}): Promise<ChunkPage> {
|
|
78
|
+
const meta = await db()
|
|
79
|
+
.select({seq: logChunks.seq, byteLen: logChunks.byteLen})
|
|
80
|
+
.from(logChunks)
|
|
81
|
+
.where(and(eq(logChunks.streamId, params.streamId), gt(logChunks.seq, params.afterSeq)))
|
|
82
|
+
.orderBy(asc(logChunks.seq))
|
|
83
|
+
.limit(CHUNK_PAGE_SCAN_CAP + 1);
|
|
84
|
+
|
|
85
|
+
if (meta.length === 0) {
|
|
86
|
+
return {data: Buffer.alloc(0), nextSeq: params.afterSeq, hasMore: false};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let accumulatedBytes = 0;
|
|
90
|
+
let includedCount = 0;
|
|
91
|
+
let nextSeq = params.afterSeq;
|
|
92
|
+
for (const row of meta) {
|
|
93
|
+
if (includedCount > 0 && accumulatedBytes + row.byteLen > params.maxBytes) break;
|
|
94
|
+
accumulatedBytes += row.byteLen;
|
|
95
|
+
nextSeq = row.seq;
|
|
96
|
+
includedCount += 1;
|
|
97
|
+
if (includedCount >= CHUNK_PAGE_SCAN_CAP) break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasMore = includedCount < meta.length;
|
|
101
|
+
|
|
102
|
+
const rows = await db()
|
|
103
|
+
.select({data: logChunks.data})
|
|
104
|
+
.from(logChunks)
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
eq(logChunks.streamId, params.streamId),
|
|
108
|
+
gt(logChunks.seq, params.afterSeq),
|
|
109
|
+
lte(logChunks.seq, nextSeq),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
.orderBy(asc(logChunks.seq));
|
|
113
|
+
|
|
114
|
+
return {data: Buffer.concat(rows.map((row) => row.data)), nextSeq, hasMore};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ChunkStats {
|
|
118
|
+
count: number;
|
|
119
|
+
maxSeq: number;
|
|
120
|
+
uncompressedBytes: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Aggregate of a stream's chunks: row count, highest `seq`, and total stored bytes.
|
|
125
|
+
* Compaction reads this once before uploading, both to stamp object metadata and as the
|
|
126
|
+
* baseline its streamed totals are checked against before the chunk rows are deleted.
|
|
127
|
+
*/
|
|
128
|
+
export async function chunkStats(streamId: string): Promise<ChunkStats> {
|
|
129
|
+
const [row] = await db()
|
|
130
|
+
.select({
|
|
131
|
+
count: sql<number>`count(*)::int`,
|
|
132
|
+
maxSeq: sql<number>`coalesce(max(${logChunks.seq}), 0)::int`,
|
|
133
|
+
uncompressedBytes: sql<string>`coalesce(sum(${logChunks.byteLen}), 0)::text`,
|
|
134
|
+
})
|
|
135
|
+
.from(logChunks)
|
|
136
|
+
.where(eq(logChunks.streamId, streamId));
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
count: row?.count ?? 0,
|
|
140
|
+
maxSeq: row?.maxSeq ?? 0,
|
|
141
|
+
uncompressedBytes: Number(row?.uncompressedBytes ?? '0'),
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/db/db.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {attemptStreams} from './schema/attempt-streams.js';
|
|
4
|
+
import {logChunks} from './schema/chunks.js';
|
|
5
|
+
import {jobAccounting} from './schema/job-accounting.js';
|
|
6
|
+
import {logsOutbox} from './schema/outbox.js';
|
|
7
|
+
|
|
8
|
+
export const schema = {jobAccounting, attemptStreams, logChunks, logsOutbox};
|
|
9
|
+
|
|
10
|
+
export type Database = NodePgDatabase<typeof schema>;
|
|
11
|
+
export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
|
|
12
|
+
|
|
13
|
+
let _db: Database | undefined;
|
|
14
|
+
|
|
15
|
+
export function db() {
|
|
16
|
+
if (!_db) _db = drizzle(pgClient(), {schema});
|
|
17
|
+
return _db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function closeDb(): void {
|
|
21
|
+
_db = undefined;
|
|
22
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
3
|
+
import {
|
|
4
|
+
bigint,
|
|
5
|
+
boolean,
|
|
6
|
+
index,
|
|
7
|
+
integer,
|
|
8
|
+
text,
|
|
9
|
+
timestamp,
|
|
10
|
+
uniqueIndex,
|
|
11
|
+
uuid,
|
|
12
|
+
} from 'drizzle-orm/pg-core';
|
|
13
|
+
import type {AttemptStream} from '#core/entities/attempt-stream.js';
|
|
14
|
+
import {pgTable} from './common.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One stream per (job, step, attempt). Identity is scoped by `job_id` (from the
|
|
18
|
+
* lease), so a lease can only ever reach its own job's streams — cross-job log
|
|
19
|
+
* injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`
|
|
20
|
+
* are stamped from the lease at create time; they are functionally determined by
|
|
21
|
+
* `job_id` via workflows FKs, so they are denormalized here for self-contained
|
|
22
|
+
* authorization (per-project read filtering, audit) without joining back to
|
|
23
|
+
* workflows. `committed_length` is the offset-CAS axis (raw NDJSON spool bytes the
|
|
24
|
+
* server has durably accepted from the runner).
|
|
25
|
+
*
|
|
26
|
+
* `truncated` is an out-of-band terminal flag set when the timeout sweep
|
|
27
|
+
* force-closes a stream the runner never ended; the `AttemptStream` entity is the
|
|
28
|
+
* canonical reference for what it means.
|
|
29
|
+
*
|
|
30
|
+
* Per-row `committed_length` and `declared_total_bytes` are bounded by the
|
|
31
|
+
* per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row
|
|
32
|
+
* aggregate (workspace or system-wide totals) MUST read as bigint at the
|
|
33
|
+
* query site — the global sum is unbounded and would silently lose precision
|
|
34
|
+
* past 2^53 as a JS number.
|
|
35
|
+
*/
|
|
36
|
+
export const attemptStreams = pgTable(
|
|
37
|
+
'attempt_streams',
|
|
38
|
+
{
|
|
39
|
+
id: uuidv7PrimaryKey(),
|
|
40
|
+
jobId: uuid('job_id').notNull(),
|
|
41
|
+
stepId: uuid('step_id').notNull(),
|
|
42
|
+
attempt: integer('attempt').notNull(),
|
|
43
|
+
workspaceId: uuid('workspace_id').notNull(),
|
|
44
|
+
projectId: uuid('project_id').notNull(),
|
|
45
|
+
workflowRunAttemptId: uuid('workflow_run_attempt_id').notNull(),
|
|
46
|
+
committedLength: bigint('committed_length', {mode: 'number'}).notNull().default(0),
|
|
47
|
+
state: text('state', {enum: ['open', 'closed']})
|
|
48
|
+
.notNull()
|
|
49
|
+
.default('open'),
|
|
50
|
+
closeReason: text('close_reason', {enum: ['declared', 'timeout']}),
|
|
51
|
+
declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),
|
|
52
|
+
truncated: boolean('truncated').notNull().default(false),
|
|
53
|
+
objectKey: text('object_key'),
|
|
54
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
55
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
56
|
+
closedAt: timestamp('closed_at', {withTimezone: true}),
|
|
57
|
+
},
|
|
58
|
+
(table) => [
|
|
59
|
+
uniqueIndex('logs_attempt_streams_identity_unique').on(
|
|
60
|
+
table.jobId,
|
|
61
|
+
table.stepId,
|
|
62
|
+
table.attempt,
|
|
63
|
+
),
|
|
64
|
+
// The session read path looks up a stream by (step, attempt) without the job id, so the
|
|
65
|
+
// identity unique (which leads with job_id) can't serve it. This index keeps that lookup
|
|
66
|
+
// off a sequential scan as the table grows.
|
|
67
|
+
index('logs_attempt_streams_step_attempt_idx').on(table.stepId, table.attempt),
|
|
68
|
+
// Timeout-close sweeps an open job's streams by job_id; the partial index keeps
|
|
69
|
+
// it off the closed tail.
|
|
70
|
+
index('logs_attempt_streams_open_by_job_idx').on(table.jobId).where(sql`"state" = 'open'`),
|
|
71
|
+
// Open-age reaping needs an index that stays off the closed tail; created_at is
|
|
72
|
+
// immutable, so appends do not churn it.
|
|
73
|
+
index('logs_attempt_streams_open_age_idx').on(table.createdAt).where(sql`"state" = 'open'`),
|
|
74
|
+
// Retention scans closed streams by close age; partial so it never carries the
|
|
75
|
+
// open (in-flight) set.
|
|
76
|
+
index('logs_attempt_streams_retention_idx').on(table.closedAt).where(sql`"state" = 'closed'`),
|
|
77
|
+
// Reconcile re-drives closed streams that never got an object key.
|
|
78
|
+
index('logs_attempt_streams_uncompacted_idx')
|
|
79
|
+
.on(table.closedAt)
|
|
80
|
+
.where(sql`"state" = 'closed' and "object_key" is null`),
|
|
81
|
+
],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
export type AttemptStreamDb = typeof attemptStreams.$inferSelect;
|
|
85
|
+
export type AttemptStreamInsertDb = typeof attemptStreams.$inferInsert;
|
|
86
|
+
|
|
87
|
+
export function toAttemptStream(row: AttemptStreamDb): AttemptStream {
|
|
88
|
+
return {
|
|
89
|
+
id: row.id,
|
|
90
|
+
jobId: row.jobId,
|
|
91
|
+
stepId: row.stepId,
|
|
92
|
+
attempt: row.attempt,
|
|
93
|
+
workspaceId: row.workspaceId,
|
|
94
|
+
projectId: row.projectId,
|
|
95
|
+
workflowRunAttemptId: row.workflowRunAttemptId,
|
|
96
|
+
committedLength: row.committedLength,
|
|
97
|
+
state: row.state,
|
|
98
|
+
closeReason: row.closeReason,
|
|
99
|
+
declaredTotalBytes: row.declaredTotalBytes,
|
|
100
|
+
truncated: row.truncated,
|
|
101
|
+
objectKey: row.objectKey,
|
|
102
|
+
createdAt: row.createdAt,
|
|
103
|
+
updatedAt: row.updatedAt,
|
|
104
|
+
closedAt: row.closedAt,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {bigint, bigserial, index, integer, text, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import {attemptStreams} from './attempt-streams.js';
|
|
4
|
+
import {bytea, pgTable} from './common.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hot, append-only log bytes for open streams, pending compaction.
|
|
8
|
+
*
|
|
9
|
+
* Two byte axes meet here and MUST stay distinct:
|
|
10
|
+
*
|
|
11
|
+
* Runner spool (CAS axis) Stored chunk stream (read axis, by seq)
|
|
12
|
+
* offset 0 ┌────────────┐ ───► seq 1 │ normalized chunk A │ origin=runner
|
|
13
|
+
* 100 ├─ chunk A ──┤ ───► seq 2 │ normalized chunk B │ origin=runner
|
|
14
|
+
* 250 ├─ chunk B ──┤ ───► seq 3 │ {capped} │ origin=control ← server-injected;
|
|
15
|
+
* └────────────┘ does NOT advance committed_length
|
|
16
|
+
*
|
|
17
|
+
* `origin` is `runner` for bytes accepted from a runner append after ingest normalization, and
|
|
18
|
+
* `control` for a server-injected tombstone. `stream_offset` is the runner-axis position of a
|
|
19
|
+
* runner chunk; for a server `control` chunk it is informational. `seq` (insertion order) is the
|
|
20
|
+
* read axis the reader walks, so server records interleave correctly with normalized runner
|
|
21
|
+
* records.
|
|
22
|
+
*/
|
|
23
|
+
export const logChunks = pgTable(
|
|
24
|
+
'chunks',
|
|
25
|
+
{
|
|
26
|
+
id: uuidv7PrimaryKey(),
|
|
27
|
+
streamId: uuid('stream_id')
|
|
28
|
+
.notNull()
|
|
29
|
+
.references(() => attemptStreams.id, {onDelete: 'cascade'}),
|
|
30
|
+
seq: bigserial('seq', {mode: 'number'}).notNull(),
|
|
31
|
+
streamOffset: bigint('stream_offset', {mode: 'number'}).notNull(),
|
|
32
|
+
byteLen: integer('byte_len').notNull(),
|
|
33
|
+
data: bytea('data').notNull(),
|
|
34
|
+
origin: text('origin', {enum: ['runner', 'control']}).notNull(),
|
|
35
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
36
|
+
},
|
|
37
|
+
(table) => [index('logs_chunks_stream_seq_idx').on(table.streamId, table.seq)],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export type LogChunkDb = typeof logChunks.$inferSelect;
|
|
41
|
+
export type LogChunkInsertDb = typeof logChunks.$inferInsert;
|
|
42
|
+
|
|
43
|
+
export type ChunkOrigin = 'runner' | 'control';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {Buffer} from 'node:buffer';
|
|
2
|
+
import {customType, pgTableCreator} from 'drizzle-orm/pg-core';
|
|
3
|
+
|
|
4
|
+
export const pgTable = pgTableCreator((name) => `logs_${name}`);
|
|
5
|
+
|
|
6
|
+
// drizzle-orm 0.45 has no native bytea column; the node-postgres driver returns
|
|
7
|
+
// and accepts Buffer for bytea, so a customType is the supported path.
|
|
8
|
+
export const bytea = customType<{data: Buffer; driverData: Buffer}>({
|
|
9
|
+
dataType() {
|
|
10
|
+
return 'bytea';
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {bigint, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
2
|
+
import type {JobAccounting} from '#core/entities/job-accounting.js';
|
|
3
|
+
import {pgTable} from './common.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Accrual-budget state per job run, keyed by the job id carried in the lease.
|
|
7
|
+
* `stored_bytes_used` counts the normalized NDJSON bytes the server has stored for the job
|
|
8
|
+
* (envelope and control records included), so the budget bounds exactly what lands in Postgres.
|
|
9
|
+
* `started_at` is the budget clock origin (first append).
|
|
10
|
+
* `capped_at`, once set, makes every later append a no-op drop.
|
|
11
|
+
*
|
|
12
|
+
* Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:
|
|
13
|
+
* 'number'` is safe on the hot path. Any cross-row aggregate (workspace or
|
|
14
|
+
* system-wide totals) MUST read as bigint at the query site — the global sum
|
|
15
|
+
* is unbounded and would silently lose precision past 2^53 as a JS number.
|
|
16
|
+
*/
|
|
17
|
+
export const jobAccounting = pgTable('job_accounting', {
|
|
18
|
+
jobId: uuid('job_id').primaryKey(),
|
|
19
|
+
workspaceId: uuid('workspace_id').notNull(),
|
|
20
|
+
storedBytesUsed: bigint('stored_bytes_used', {mode: 'number'}).notNull().default(0),
|
|
21
|
+
startedAt: timestamp('started_at', {withTimezone: true}).notNull().defaultNow(),
|
|
22
|
+
cappedAt: timestamp('capped_at', {withTimezone: true}),
|
|
23
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
24
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type JobAccountingDb = typeof jobAccounting.$inferSelect;
|
|
28
|
+
export type JobAccountingInsertDb = typeof jobAccounting.$inferInsert;
|
|
29
|
+
|
|
30
|
+
export function toJobAccounting(row: JobAccountingDb): JobAccounting {
|
|
31
|
+
return {
|
|
32
|
+
jobId: row.jobId,
|
|
33
|
+
workspaceId: row.workspaceId,
|
|
34
|
+
storedBytesUsed: row.storedBytesUsed,
|
|
35
|
+
startedAt: row.startedAt,
|
|
36
|
+
cappedAt: row.cappedAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {appendLogs} from '#core/append-logs.js';
|
|
2
|
+
import {endLine, ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
|
|
3
|
+
import {getOpenStreamCount} from './streams.js';
|
|
4
|
+
|
|
5
|
+
interface Ctx {
|
|
6
|
+
jobId: string;
|
|
7
|
+
stepId: string;
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
workflowRunAttemptId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function newCtx(): Ctx {
|
|
14
|
+
return {
|
|
15
|
+
jobId: crypto.randomUUID(),
|
|
16
|
+
stepId: crypto.randomUUID(),
|
|
17
|
+
workspaceId: crypto.randomUUID(),
|
|
18
|
+
projectId: crypto.randomUUID(),
|
|
19
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('getOpenStreamCount', () => {
|
|
24
|
+
it('reports the current open stream count', async () => {
|
|
25
|
+
const before = await getOpenStreamCount();
|
|
26
|
+
|
|
27
|
+
const after = await getOpenStreamCount();
|
|
28
|
+
expect(after - before).toBe(0n);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('counts newly opened streams without counting streams closed in the same test', async () => {
|
|
32
|
+
const before = await getOpenStreamCount();
|
|
33
|
+
const open = newCtx();
|
|
34
|
+
const closed = newCtx();
|
|
35
|
+
await appendLogs({...open, attempt: 1, offset: 0, body: ndjsonBody(outputLine('open\n'))});
|
|
36
|
+
await appendLogs({
|
|
37
|
+
...closed,
|
|
38
|
+
attempt: 1,
|
|
39
|
+
offset: 0,
|
|
40
|
+
body: ndjsonBody(outputLine('closed\n'), endLine(7)),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const after = await getOpenStreamCount();
|
|
44
|
+
|
|
45
|
+
expect(after - before).toBe(1n);
|
|
46
|
+
});
|
|
47
|
+
});
|