@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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The append offset is ahead of the committed length: the runner must rewind its spool cursor. */
|
|
2
|
+
export class OffsetGapError extends Error {
|
|
3
|
+
constructor(public readonly committedLength: number) {
|
|
4
|
+
super(`Append offset is ahead of the committed length (${committedLength})`);
|
|
5
|
+
this.name = 'OffsetGapError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The append body is not whole, newline-terminated records of the raw log
|
|
11
|
+
* record contract. `forgedType` is set only for the detectable forgery case — a
|
|
12
|
+
* line that is a valid server-only record (`capped`/`runner_lost`) under the read
|
|
13
|
+
* union but is not valid on the raw write path — so the append path can emit a narrowed audit warn
|
|
14
|
+
* without logging the payload.
|
|
15
|
+
*/
|
|
16
|
+
export class MalformedLogChunkError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
public readonly forgedType?: string,
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'MalformedLogChunkError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lease's `(workspaceId, projectId, workflowRunAttemptId)` does not match the values
|
|
28
|
+
* stamped on the existing stream row. Since these are functionally determined
|
|
29
|
+
* by `jobId` via workflows FKs, a mismatch implies a forged token or a
|
|
30
|
+
* cross-job lease confusion — never a legitimate request.
|
|
31
|
+
*/
|
|
32
|
+
export class LeaseStreamMismatchError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('Lease identity does not match the existing stream row');
|
|
35
|
+
this.name = 'LeaseStreamMismatchError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {parseLogRecordLine} from '@shipfox/api-logs-dto';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {getOrCreateAttemptStream} from '#db/streams.js';
|
|
4
|
+
import {listChunks, listStreamClosedEvents} from '#test/queries.js';
|
|
5
|
+
import {
|
|
6
|
+
createFinalizeAttemptLogStream,
|
|
7
|
+
type FinalizeAttemptLogStreamParams,
|
|
8
|
+
} from './finalize-attempt-stream.js';
|
|
9
|
+
|
|
10
|
+
const metrics = {
|
|
11
|
+
recordAppendedAdd: vi.fn(),
|
|
12
|
+
streamClosedAdd: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const finalizeAttemptLogStream = createFinalizeAttemptLogStream({
|
|
16
|
+
recordAppendedCount: {add: metrics.recordAppendedAdd},
|
|
17
|
+
streamClosedCount: {add: metrics.streamClosedAdd},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function newIdentity(
|
|
21
|
+
overrides: Partial<FinalizeAttemptLogStreamParams> = {},
|
|
22
|
+
): FinalizeAttemptLogStreamParams {
|
|
23
|
+
return {
|
|
24
|
+
jobId: crypto.randomUUID(),
|
|
25
|
+
stepId: crypto.randomUUID(),
|
|
26
|
+
attempt: 1,
|
|
27
|
+
workspaceId: crypto.randomUUID(),
|
|
28
|
+
projectId: crypto.randomUUID(),
|
|
29
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
30
|
+
logOutcome: 'drained',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('finalizeAttemptLogStream', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('creates a missing drained stream and closes it as declared', async () => {
|
|
41
|
+
const identity = newIdentity({logOutcome: 'drained'});
|
|
42
|
+
|
|
43
|
+
const stream = await finalizeAttemptLogStream(identity);
|
|
44
|
+
|
|
45
|
+
expect(stream.state).toBe('closed');
|
|
46
|
+
expect(stream.closeReason).toBe('declared');
|
|
47
|
+
expect(stream.truncated).toBe(false);
|
|
48
|
+
expect(await listChunks(stream.id)).toHaveLength(0);
|
|
49
|
+
expect(await listStreamClosedEvents(stream.id)).toHaveLength(1);
|
|
50
|
+
expect(metrics.streamClosedAdd).toHaveBeenCalledWith(1, {reason: 'declared'});
|
|
51
|
+
expect(metrics.recordAppendedAdd).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('closes an abandoned open stream with a runner_lost tombstone', async () => {
|
|
55
|
+
const identity = newIdentity({logOutcome: 'abandoned'});
|
|
56
|
+
await db().transaction((tx) => getOrCreateAttemptStream(tx, identity));
|
|
57
|
+
|
|
58
|
+
const stream = await finalizeAttemptLogStream(identity);
|
|
59
|
+
|
|
60
|
+
expect(stream.state).toBe('closed');
|
|
61
|
+
expect(stream.closeReason).toBe('timeout');
|
|
62
|
+
expect(stream.truncated).toBe(true);
|
|
63
|
+
const chunks = await listChunks(stream.id);
|
|
64
|
+
expect(chunks).toHaveLength(1);
|
|
65
|
+
expect(chunks[0]?.origin).toBe('control');
|
|
66
|
+
const records = chunks[0]?.data
|
|
67
|
+
.toString('utf8')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map(parseLogRecordLine);
|
|
71
|
+
expect(records).toMatchObject([{type: 'runner_lost'}]);
|
|
72
|
+
expect(await listStreamClosedEvents(stream.id)).toHaveLength(1);
|
|
73
|
+
expect(metrics.streamClosedAdd).toHaveBeenCalledWith(1, {reason: 'timeout'});
|
|
74
|
+
expect(metrics.recordAppendedAdd).toHaveBeenCalledWith(1, {kind: 'runner_lost'});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does not emit another tombstone or close event when finalized again', async () => {
|
|
78
|
+
const identity = newIdentity({logOutcome: 'abandoned'});
|
|
79
|
+
|
|
80
|
+
const first = await finalizeAttemptLogStream(identity);
|
|
81
|
+
const second = await finalizeAttemptLogStream(identity);
|
|
82
|
+
|
|
83
|
+
expect(second.id).toBe(first.id);
|
|
84
|
+
expect(second.state).toBe('closed');
|
|
85
|
+
expect(await listChunks(first.id)).toHaveLength(1);
|
|
86
|
+
expect(await listStreamClosedEvents(first.id)).toHaveLength(1);
|
|
87
|
+
expect(metrics.streamClosedAdd).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(metrics.recordAppendedAdd).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {LogOutcomeDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import type {AttemptStream} from '#core/entities/attempt-stream.js';
|
|
3
|
+
import {db} from '#db/db.js';
|
|
4
|
+
import {
|
|
5
|
+
type AttemptStreamIdentity,
|
|
6
|
+
getAttemptStreamByIdInTransaction,
|
|
7
|
+
getOrCreateAttemptStreamWithStatus,
|
|
8
|
+
} from '#db/streams.js';
|
|
9
|
+
import {recordAppendedCount, streamClosedCount} from '#metrics/instance.js';
|
|
10
|
+
import {closeStream} from './close-stream.js';
|
|
11
|
+
|
|
12
|
+
export interface FinalizeAttemptLogStreamParams extends AttemptStreamIdentity {
|
|
13
|
+
logOutcome: LogOutcomeDto;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FinalizeAttemptLogStreamResult {
|
|
17
|
+
stream: AttemptStream;
|
|
18
|
+
closedReason: 'declared' | 'timeout' | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FinalizeMetrics {
|
|
22
|
+
recordAppendedCount: {add: (value: number, attributes: {kind: 'runner_lost'}) => void};
|
|
23
|
+
streamClosedCount: {
|
|
24
|
+
add: (value: number, attributes: {reason: 'declared' | 'timeout'}) => void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultMetrics: FinalizeMetrics = {
|
|
29
|
+
recordAppendedCount,
|
|
30
|
+
streamClosedCount,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createFinalizeAttemptLogStream(metrics: FinalizeMetrics = defaultMetrics) {
|
|
34
|
+
return async (params: FinalizeAttemptLogStreamParams): Promise<AttemptStream> => {
|
|
35
|
+
const result: FinalizeAttemptLogStreamResult = await db().transaction(async (tx) => {
|
|
36
|
+
const {stream} = await getOrCreateAttemptStreamWithStatus(tx, params);
|
|
37
|
+
if (stream.state === 'closed') return {stream, closedReason: null};
|
|
38
|
+
|
|
39
|
+
const reason = params.logOutcome === 'abandoned' ? 'timeout' : 'declared';
|
|
40
|
+
const closed = await closeStream(tx, {streamId: stream.id, reason});
|
|
41
|
+
if (closed) return {stream: closed, closedReason: reason};
|
|
42
|
+
|
|
43
|
+
const current = await getAttemptStreamByIdInTransaction(tx, stream.id);
|
|
44
|
+
if (!current) throw new Error(`Log stream disappeared during finalization: ${stream.id}`);
|
|
45
|
+
return {stream: current, closedReason: null};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (result.closedReason) {
|
|
49
|
+
if (result.closedReason === 'timeout') {
|
|
50
|
+
metrics.recordAppendedCount.add(1, {kind: 'runner_lost'});
|
|
51
|
+
}
|
|
52
|
+
metrics.streamClosedCount.add(1, {reason: result.closedReason});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result.stream;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const finalizeAttemptLogStream = createFinalizeAttemptLogStream();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {presignedGetUrl} from '#api/object-storage.js';
|
|
2
|
+
import {config} from '#config.js';
|
|
3
|
+
import type {AttemptStream, StreamState} from '#core/entities/attempt-stream.js';
|
|
4
|
+
import {readChunkPageBySeq} from '#db/chunks.js';
|
|
5
|
+
import {getAttemptStreamById} from '#db/streams.js';
|
|
6
|
+
|
|
7
|
+
export interface InlineLogRead {
|
|
8
|
+
mode: 'inline';
|
|
9
|
+
ndjson: string;
|
|
10
|
+
nextCursor: number;
|
|
11
|
+
hasMore: boolean;
|
|
12
|
+
state: StreamState;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PresignedLogRead {
|
|
17
|
+
mode: 'presigned';
|
|
18
|
+
url: string;
|
|
19
|
+
state: 'closed';
|
|
20
|
+
expiresAt: Date;
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
truncated: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LogReadResult = InlineLogRead | PresignedLogRead;
|
|
26
|
+
|
|
27
|
+
async function presignedRead(stream: AttemptStream, objectKey: string): Promise<PresignedLogRead> {
|
|
28
|
+
const {url, expiresAt} = await presignedGetUrl(objectKey);
|
|
29
|
+
return {
|
|
30
|
+
mode: 'presigned',
|
|
31
|
+
url,
|
|
32
|
+
state: 'closed',
|
|
33
|
+
expiresAt,
|
|
34
|
+
totalBytes: stream.committedLength,
|
|
35
|
+
truncated: stream.truncated,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compaction-boundary guard: a stream can be loaded with no `objectKey`, then compaction
|
|
41
|
+
* publishes the key and deletes the chunks before the chunk read runs, which would yield a
|
|
42
|
+
* spurious empty inline frame on a closed stream. On that exact shape (closed stream, empty
|
|
43
|
+
* page) the row is re-read once; if it has since compacted, the presigned object is served
|
|
44
|
+
* instead.
|
|
45
|
+
*
|
|
46
|
+
* The re-read is scoped to a closed snapshot on purpose. An open stream returns empty pages
|
|
47
|
+
* on every idle tail poll, so re-reading each would add a row read to the hot path; and an
|
|
48
|
+
* open snapshot cannot have compacted (compaction runs only after close, seconds later via
|
|
49
|
+
* Temporal), so the open-empty-compacted window is too small to hit. If it ever did, the
|
|
50
|
+
* client's next poll reloads the now-compacted row and self-corrects.
|
|
51
|
+
*/
|
|
52
|
+
export async function buildLogReadResult(
|
|
53
|
+
stream: AttemptStream,
|
|
54
|
+
cursor: number,
|
|
55
|
+
): Promise<LogReadResult> {
|
|
56
|
+
if (stream.objectKey) return presignedRead(stream, stream.objectKey);
|
|
57
|
+
|
|
58
|
+
const page = await readChunkPageBySeq({
|
|
59
|
+
streamId: stream.id,
|
|
60
|
+
afterSeq: cursor,
|
|
61
|
+
maxBytes: config.LOG_READ_INLINE_MAX_BYTES,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (page.data.length === 0 && stream.state === 'closed') {
|
|
65
|
+
const refreshed = await getAttemptStreamById(stream.id);
|
|
66
|
+
if (refreshed?.objectKey) return presignedRead(refreshed, refreshed.objectKey);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
mode: 'inline',
|
|
71
|
+
ndjson: page.data.toString('utf8'),
|
|
72
|
+
nextCursor: page.nextSeq,
|
|
73
|
+
hasMore: page.hasMore,
|
|
74
|
+
state: stream.state,
|
|
75
|
+
truncated: stream.truncated,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {eq, sql} from 'drizzle-orm';
|
|
2
|
+
import {appendLogs} from '#core/append-logs.js';
|
|
3
|
+
import {db, type Transaction} from '#db/db.js';
|
|
4
|
+
import {attemptStreams} from '#db/schema/attempt-streams.js';
|
|
5
|
+
import {endLine, ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
|
|
6
|
+
import {findStream, listChunks, listStreamClosedEvents} from '#test/queries.js';
|
|
7
|
+
import type {CloseStreamParams} from './close-stream.js';
|
|
8
|
+
import * as closeStreamModule from './close-stream.js';
|
|
9
|
+
import {reapStaleOpenStreams} from './reap-stale-open-streams.js';
|
|
10
|
+
|
|
11
|
+
interface Ctx {
|
|
12
|
+
jobId: string;
|
|
13
|
+
stepId: string;
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
projectId: string;
|
|
16
|
+
workflowRunAttemptId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function newCtx(): Ctx {
|
|
20
|
+
return {
|
|
21
|
+
jobId: crypto.randomUUID(),
|
|
22
|
+
stepId: crypto.randomUUID(),
|
|
23
|
+
workspaceId: crypto.randomUUID(),
|
|
24
|
+
projectId: crypto.randomUUID(),
|
|
25
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Default LOG_STREAM_REAP_AFTER_SECONDS is 7200 (2h); a 3h backdate makes a stream stale.
|
|
30
|
+
function reap(overrides: Partial<Parameters<typeof reapStaleOpenStreams>[0]> = {}) {
|
|
31
|
+
return reapStaleOpenStreams({olderThanSeconds: 7200, batchLimit: 100, ...overrides});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function backdateCreatedAtPastReapWindow(streamId: string): Promise<void> {
|
|
35
|
+
await db()
|
|
36
|
+
.update(attemptStreams)
|
|
37
|
+
.set({createdAt: sql`now() - interval '3 hours'`})
|
|
38
|
+
.where(eq(attemptStreams.id, streamId));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('reapStaleOpenStreams', () => {
|
|
42
|
+
it('force-closes a stale open stream with a runner_lost tombstone and one event', async () => {
|
|
43
|
+
const ctx = newCtx();
|
|
44
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body: ndjsonBody(outputLine('partial\n'))});
|
|
45
|
+
const open = await findStream({...ctx, attempt: 1});
|
|
46
|
+
await backdateCreatedAtPastReapWindow(open?.id as string);
|
|
47
|
+
|
|
48
|
+
const {reaped} = await reap();
|
|
49
|
+
|
|
50
|
+
// Counters are whole-table tallies; row state isolates this test from unrelated stale rows
|
|
51
|
+
// in the shared test DB.
|
|
52
|
+
expect(reaped).toBeGreaterThanOrEqual(1);
|
|
53
|
+
const after = await findStream({...ctx, attempt: 1});
|
|
54
|
+
expect(after?.state).toBe('closed');
|
|
55
|
+
expect(after?.closeReason).toBe('timeout');
|
|
56
|
+
expect(after?.truncated).toBe(true);
|
|
57
|
+
expect(after?.committedLength).toBe(open?.committedLength);
|
|
58
|
+
expect((await listChunks(after?.id as string)).map((c) => c.origin)).toEqual([
|
|
59
|
+
'runner',
|
|
60
|
+
'control',
|
|
61
|
+
]);
|
|
62
|
+
expect(await listStreamClosedEvents(after?.id as string)).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('leaves a recently-created open stream untouched', async () => {
|
|
66
|
+
const ctx = newCtx();
|
|
67
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body: ndjsonBody(outputLine('fresh\n'))});
|
|
68
|
+
|
|
69
|
+
await reap();
|
|
70
|
+
|
|
71
|
+
const after = await findStream({...ctx, attempt: 1});
|
|
72
|
+
expect(after?.state).toBe('open');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not select an already declared-closed stream, even an old one', async () => {
|
|
76
|
+
const ctx = newCtx();
|
|
77
|
+
await appendLogs({
|
|
78
|
+
...ctx,
|
|
79
|
+
attempt: 1,
|
|
80
|
+
offset: 0,
|
|
81
|
+
body: ndjsonBody(outputLine('done\n'), endLine(5)),
|
|
82
|
+
});
|
|
83
|
+
const closed = await findStream({...ctx, attempt: 1});
|
|
84
|
+
await backdateCreatedAtPastReapWindow(closed?.id as string);
|
|
85
|
+
|
|
86
|
+
await reap();
|
|
87
|
+
|
|
88
|
+
const after = await findStream({...ctx, attempt: 1});
|
|
89
|
+
expect(after?.closeReason).toBe('declared');
|
|
90
|
+
expect(after?.truncated).toBe(false);
|
|
91
|
+
expect(await listStreamClosedEvents(after?.id as string)).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('logs and skips one stream whose close fails, still reaping the rest of the batch', async () => {
|
|
95
|
+
const poisonCtx = newCtx();
|
|
96
|
+
const healthyCtx = newCtx();
|
|
97
|
+
await appendLogs({
|
|
98
|
+
...poisonCtx,
|
|
99
|
+
attempt: 1,
|
|
100
|
+
offset: 0,
|
|
101
|
+
body: ndjsonBody(outputLine('poison\n')),
|
|
102
|
+
});
|
|
103
|
+
await appendLogs({
|
|
104
|
+
...healthyCtx,
|
|
105
|
+
attempt: 1,
|
|
106
|
+
offset: 0,
|
|
107
|
+
body: ndjsonBody(outputLine('healthy\n')),
|
|
108
|
+
});
|
|
109
|
+
const poison = await findStream({...poisonCtx, attempt: 1});
|
|
110
|
+
const healthy = await findStream({...healthyCtx, attempt: 1});
|
|
111
|
+
await backdateCreatedAtPastReapWindow(poison?.id as string);
|
|
112
|
+
await backdateCreatedAtPastReapWindow(healthy?.id as string);
|
|
113
|
+
const realCloseStream = closeStreamModule.closeStream;
|
|
114
|
+
vi.spyOn(closeStreamModule, 'closeStream').mockImplementation(
|
|
115
|
+
(tx: Transaction, params: CloseStreamParams) =>
|
|
116
|
+
params.streamId === poison?.id
|
|
117
|
+
? Promise.reject(new Error('induced close failure'))
|
|
118
|
+
: realCloseStream(tx, params),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const {reaped, failed} = await reap();
|
|
122
|
+
|
|
123
|
+
expect(failed).toBeGreaterThanOrEqual(1);
|
|
124
|
+
expect(reaped).toBeGreaterThanOrEqual(1);
|
|
125
|
+
const healthyAfter = await findStream({...healthyCtx, attempt: 1});
|
|
126
|
+
expect(healthyAfter?.state).toBe('closed');
|
|
127
|
+
const poisonAfter = await findStream({...poisonCtx, attempt: 1});
|
|
128
|
+
expect(poisonAfter?.state).toBe('open');
|
|
129
|
+
|
|
130
|
+
// The mocked failure leaves this row open by design. Mark it closed through the DB so later
|
|
131
|
+
// reaper tests do not inherit a stale open row.
|
|
132
|
+
await db()
|
|
133
|
+
.update(attemptStreams)
|
|
134
|
+
.set({state: 'closed'})
|
|
135
|
+
.where(eq(attemptStreams.id, poison?.id as string));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {listStaleOpenStreams} from '#db/streams.js';
|
|
4
|
+
import {recordAppendedCount, streamClosedCount} from '#metrics/instance.js';
|
|
5
|
+
import {closeStream} from './close-stream.js';
|
|
6
|
+
|
|
7
|
+
export interface ReapStaleOpenStreamsResult {
|
|
8
|
+
/** Streams force-closed by this run. */
|
|
9
|
+
reaped: number;
|
|
10
|
+
/** Streams whose guarded close threw; logged, skipped, retried next run. */
|
|
11
|
+
failed: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReapStaleOpenStreamsParams {
|
|
15
|
+
/**
|
|
16
|
+
* A stream open longer than this since `created_at` can no longer be appended to: its job
|
|
17
|
+
* holds one lease minted at-or-before the stream's creation, so once the lease TTL has
|
|
18
|
+
* elapsed no append can land and the close is safe. Set above `AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`.
|
|
19
|
+
*/
|
|
20
|
+
olderThanSeconds: number;
|
|
21
|
+
batchLimit: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Force-closes open streams the one-shot job-terminal sweep missed: a stream whose first append
|
|
26
|
+
* landed after that sweep's snapshot would otherwise stay `open` forever, invisible to
|
|
27
|
+
* compaction and retention (both partial on `state = 'closed'`). Reusing the guarded
|
|
28
|
+
* `closeStream` re-drives each into the normal closed -> compact -> retention lifecycle.
|
|
29
|
+
*
|
|
30
|
+
* Each row closes in its own transaction, so a tick/retry overlap is idempotent (the guarded
|
|
31
|
+
* `markStreamClosed` lets only one close win) and one failed row is logged and skipped instead
|
|
32
|
+
* of aborting the batch.
|
|
33
|
+
*/
|
|
34
|
+
export async function reapStaleOpenStreams(
|
|
35
|
+
params: ReapStaleOpenStreamsParams,
|
|
36
|
+
): Promise<ReapStaleOpenStreamsResult> {
|
|
37
|
+
const stale = await listStaleOpenStreams({
|
|
38
|
+
olderThanSeconds: params.olderThanSeconds,
|
|
39
|
+
limit: params.batchLimit,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result: ReapStaleOpenStreamsResult = {reaped: 0, failed: 0};
|
|
43
|
+
for (const stream of stale) {
|
|
44
|
+
try {
|
|
45
|
+
const closed = await db().transaction((tx) =>
|
|
46
|
+
closeStream(tx, {streamId: stream.id, reason: 'timeout'}),
|
|
47
|
+
);
|
|
48
|
+
if (closed) {
|
|
49
|
+
result.reaped += 1;
|
|
50
|
+
recordAppendedCount.add(1, {kind: 'runner_lost'});
|
|
51
|
+
streamClosedCount.add(1, {reason: 'timeout'});
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
result.failed += 1;
|
|
55
|
+
logger().error({err: error, streamId: stream.id}, 'Failed to reap stale open log stream');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|