@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,388 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {HeadObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3';
|
|
3
|
+
import {eq, sql} from 'drizzle-orm';
|
|
4
|
+
import * as objectStorage from '#api/object-storage.js';
|
|
5
|
+
import {s3Client} from '#api/object-storage.js';
|
|
6
|
+
import {config} from '#config.js';
|
|
7
|
+
import {logObjectKey} from '#core/entities/log-object.js';
|
|
8
|
+
import {db} from '#db/db.js';
|
|
9
|
+
import {attemptStreams} from '#db/schema/attempt-streams.js';
|
|
10
|
+
import {jobAccounting} from '#db/schema/job-accounting.js';
|
|
11
|
+
import * as streams from '#db/streams.js';
|
|
12
|
+
import {jobAccountingFactory} from '#test/factories/job-accounting.js';
|
|
13
|
+
import {arrangeClosedStream, type ClosedStreamIdentity} from '#test/fixtures/closed-stream.js';
|
|
14
|
+
import {ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
|
|
15
|
+
import {findAccounting, findStream, listChunks} from '#test/queries.js';
|
|
16
|
+
import {runRetentionSweep} from './retention.js';
|
|
17
|
+
|
|
18
|
+
function newIdentity(overrides: Partial<ClosedStreamIdentity> = {}): ClosedStreamIdentity {
|
|
19
|
+
return {
|
|
20
|
+
jobId: crypto.randomUUID(),
|
|
21
|
+
stepId: crypto.randomUUID(),
|
|
22
|
+
attempt: 1,
|
|
23
|
+
workspaceId: crypto.randomUUID(),
|
|
24
|
+
projectId: crypto.randomUUID(),
|
|
25
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sweep(overrides: Partial<Parameters<typeof runRetentionSweep>[0]> = {}) {
|
|
31
|
+
return runRetentionSweep({
|
|
32
|
+
retentionDays: 90,
|
|
33
|
+
batchLimit: 100,
|
|
34
|
+
timeBudgetMs: 60_000,
|
|
35
|
+
maxIterations: 100,
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function attemptPrefix(identity: ClosedStreamIdentity): string {
|
|
41
|
+
return logObjectKey(config.LOG_STORAGE_S3_PREFIX, identity);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function backdateClosedAt(streamId: string, interval: string): Promise<void> {
|
|
45
|
+
await db()
|
|
46
|
+
.update(attemptStreams)
|
|
47
|
+
.set({closedAt: sql`now() - ${interval}::interval`})
|
|
48
|
+
.where(eq(attemptStreams.id, streamId));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function backdateAccountingUpdatedAt(jobId: string, interval: string): Promise<void> {
|
|
52
|
+
await db()
|
|
53
|
+
.update(jobAccounting)
|
|
54
|
+
.set({updatedAt: sql`now() - ${interval}::interval`})
|
|
55
|
+
.where(eq(jobAccounting.jobId, jobId));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function setObjectKey(streamId: string, objectKey: string): Promise<void> {
|
|
59
|
+
await db().update(attemptStreams).set({objectKey}).where(eq(attemptStreams.id, streamId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function putObject(key: string, body: Buffer): Promise<void> {
|
|
63
|
+
await s3Client().send(
|
|
64
|
+
new PutObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key, Body: body}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function objectExists(key: string): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
await s3Client().send(new HeadObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key}));
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('runRetentionSweep', () => {
|
|
78
|
+
it('deletes a past-horizon compacted stream: object, row, chunks, and accounting all gone', async () => {
|
|
79
|
+
const id = newIdentity();
|
|
80
|
+
const stream = await arrangeClosedStream(id);
|
|
81
|
+
const key = `${attemptPrefix(id)}/${crypto.randomUUID()}`;
|
|
82
|
+
await putObject(key, Buffer.from('compacted'));
|
|
83
|
+
await setObjectKey(stream.id, key);
|
|
84
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
85
|
+
await jobAccountingFactory.create({jobId: id.jobId, workspaceId: id.workspaceId});
|
|
86
|
+
await backdateAccountingUpdatedAt(id.jobId, '100 days');
|
|
87
|
+
|
|
88
|
+
await sweep();
|
|
89
|
+
|
|
90
|
+
expect(await findStream(id)).toBeNull();
|
|
91
|
+
expect(await listChunks(stream.id)).toHaveLength(0);
|
|
92
|
+
expect(await objectExists(key)).toBe(false);
|
|
93
|
+
expect(await findAccounting(id.jobId)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('is idempotent: a second run over the same data deletes nothing and does not throw', async () => {
|
|
97
|
+
const id = newIdentity();
|
|
98
|
+
const stream = await arrangeClosedStream(id);
|
|
99
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
100
|
+
await sweep();
|
|
101
|
+
|
|
102
|
+
const second = await sweep();
|
|
103
|
+
|
|
104
|
+
expect(await findStream(id)).toBeNull();
|
|
105
|
+
expect(second.failed).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('tolerates a recorded object that was never uploaded (NoSuchKey) and still deletes the row', async () => {
|
|
109
|
+
const id = newIdentity();
|
|
110
|
+
const stream = await arrangeClosedStream(id);
|
|
111
|
+
await setObjectKey(stream.id, `${attemptPrefix(id)}/never-uploaded`);
|
|
112
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
113
|
+
|
|
114
|
+
const result = await sweep();
|
|
115
|
+
|
|
116
|
+
expect(await findStream(id)).toBeNull();
|
|
117
|
+
expect(result.failed).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('never touches an open stream, even an ancient one', async () => {
|
|
121
|
+
const id = newIdentity();
|
|
122
|
+
await db().transaction((tx) => streams.getOrCreateAttemptStream(tx, id));
|
|
123
|
+
|
|
124
|
+
await sweep();
|
|
125
|
+
|
|
126
|
+
expect(await findStream(id)).not.toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('prunes accounting only once the job has no remaining streams', async () => {
|
|
130
|
+
const shared = {
|
|
131
|
+
jobId: crypto.randomUUID(),
|
|
132
|
+
workspaceId: crypto.randomUUID(),
|
|
133
|
+
projectId: crypto.randomUUID(),
|
|
134
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
135
|
+
};
|
|
136
|
+
const expired = newIdentity({...shared, stepId: crypto.randomUUID()});
|
|
137
|
+
const fresh = newIdentity({...shared, stepId: crypto.randomUUID()});
|
|
138
|
+
const expiredStream = await arrangeClosedStream(expired);
|
|
139
|
+
const freshStream = await arrangeClosedStream(fresh);
|
|
140
|
+
await backdateClosedAt(expiredStream.id, '100 days');
|
|
141
|
+
await jobAccountingFactory.create({jobId: shared.jobId, workspaceId: shared.workspaceId});
|
|
142
|
+
await backdateAccountingUpdatedAt(shared.jobId, '100 days');
|
|
143
|
+
|
|
144
|
+
await sweep();
|
|
145
|
+
|
|
146
|
+
expect(await findStream(expired)).toBeNull();
|
|
147
|
+
expect(await findStream(fresh)).not.toBeNull();
|
|
148
|
+
expect(await findAccounting(shared.jobId)).not.toBeNull();
|
|
149
|
+
|
|
150
|
+
await backdateClosedAt(freshStream.id, '100 days');
|
|
151
|
+
await sweep();
|
|
152
|
+
|
|
153
|
+
expect(await findStream(fresh)).toBeNull();
|
|
154
|
+
expect(await findAccounting(shared.jobId)).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('deletes a closed-but-never-compacted stream (object_key null)', async () => {
|
|
158
|
+
const id = newIdentity();
|
|
159
|
+
const stream = await arrangeClosedStream(id, {chunks: [ndjsonBody(outputLine('x'))]});
|
|
160
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
161
|
+
|
|
162
|
+
const result = await sweep();
|
|
163
|
+
|
|
164
|
+
expect(await findStream(id)).toBeNull();
|
|
165
|
+
expect(await listChunks(stream.id)).toHaveLength(0);
|
|
166
|
+
expect(result.failed).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps the row when prefix deletion fails, so a later sweep can rediscover the object', async () => {
|
|
170
|
+
const poison = newIdentity();
|
|
171
|
+
const healthy = newIdentity();
|
|
172
|
+
const poisonStream = await arrangeClosedStream(poison);
|
|
173
|
+
const healthyStream = await arrangeClosedStream(healthy);
|
|
174
|
+
const poisonKey = `${attemptPrefix(poison)}/${crypto.randomUUID()}`;
|
|
175
|
+
await putObject(poisonKey, Buffer.from('poison'));
|
|
176
|
+
await setObjectKey(poisonStream.id, poisonKey);
|
|
177
|
+
await backdateClosedAt(poisonStream.id, '200 days');
|
|
178
|
+
await backdateClosedAt(healthyStream.id, '100 days');
|
|
179
|
+
const poisonPrefix = `${attemptPrefix(poison)}/`;
|
|
180
|
+
const realDeleteObjectsByPrefix = objectStorage.deleteObjectsByPrefix;
|
|
181
|
+
vi.spyOn(objectStorage, 'deleteObjectsByPrefix').mockImplementation((prefix) =>
|
|
182
|
+
prefix === poisonPrefix
|
|
183
|
+
? Promise.reject(new Error('object delete failed'))
|
|
184
|
+
: realDeleteObjectsByPrefix(prefix),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const result = await sweep({batchLimit: 1});
|
|
188
|
+
|
|
189
|
+
expect(await findStream(poison)).not.toBeNull();
|
|
190
|
+
expect(await objectExists(poisonKey)).toBe(true);
|
|
191
|
+
expect(await findStream(healthy)).toBeNull();
|
|
192
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('deletes the whole attempt prefix, reclaiming a lost compaction attempt orphan leaf', async () => {
|
|
196
|
+
const id = newIdentity();
|
|
197
|
+
const stream = await arrangeClosedStream(id);
|
|
198
|
+
const winner = `${attemptPrefix(id)}/${crypto.randomUUID()}`;
|
|
199
|
+
const orphan = `${attemptPrefix(id)}/${crypto.randomUUID()}`;
|
|
200
|
+
await putObject(winner, Buffer.from('winner'));
|
|
201
|
+
await putObject(orphan, Buffer.from('orphan-leaf'));
|
|
202
|
+
await setObjectKey(stream.id, winner);
|
|
203
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
204
|
+
|
|
205
|
+
await sweep();
|
|
206
|
+
|
|
207
|
+
expect(await findStream(id)).toBeNull();
|
|
208
|
+
expect(await objectExists(winner)).toBe(false);
|
|
209
|
+
expect(await objectExists(orphan)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('does not starve a younger healthy stream behind a row whose delete keeps failing', async () => {
|
|
213
|
+
const poison = newIdentity();
|
|
214
|
+
const healthy = newIdentity();
|
|
215
|
+
const poisonStream = await arrangeClosedStream(poison);
|
|
216
|
+
const healthyStream = await arrangeClosedStream(healthy);
|
|
217
|
+
const poisonKey = `${attemptPrefix(poison)}/${crypto.randomUUID()}`;
|
|
218
|
+
await putObject(poisonKey, Buffer.from('poison'));
|
|
219
|
+
await setObjectKey(poisonStream.id, poisonKey);
|
|
220
|
+
await backdateClosedAt(poisonStream.id, '200 days');
|
|
221
|
+
await backdateClosedAt(healthyStream.id, '100 days');
|
|
222
|
+
const realDeleteExpiredStream = streams.deleteExpiredStream;
|
|
223
|
+
vi.spyOn(streams, 'deleteExpiredStream').mockImplementation((tx, params) =>
|
|
224
|
+
params.streamId === poisonStream.id
|
|
225
|
+
? Promise.reject(new Error('poison row delete'))
|
|
226
|
+
: realDeleteExpiredStream(tx, params),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const result = await sweep({batchLimit: 1});
|
|
230
|
+
|
|
231
|
+
expect(await findStream(healthy)).toBeNull();
|
|
232
|
+
expect(await findStream(poison)).not.toBeNull();
|
|
233
|
+
expect(await objectExists(poisonKey)).toBe(false);
|
|
234
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('self-heals a row delete failure after the object prefix was already deleted', async () => {
|
|
238
|
+
const id = newIdentity();
|
|
239
|
+
const stream = await arrangeClosedStream(id);
|
|
240
|
+
const key = `${attemptPrefix(id)}/${crypto.randomUUID()}`;
|
|
241
|
+
await putObject(key, Buffer.from('compacted'));
|
|
242
|
+
await setObjectKey(stream.id, key);
|
|
243
|
+
await backdateClosedAt(stream.id, '300 days');
|
|
244
|
+
vi.spyOn(streams, 'deleteExpiredStream').mockImplementationOnce(() =>
|
|
245
|
+
Promise.reject(new Error('row delete failed')),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const failed = await sweep({batchLimit: 1});
|
|
249
|
+
|
|
250
|
+
expect(await objectExists(key)).toBe(false);
|
|
251
|
+
expect(await findStream(id)).not.toBeNull();
|
|
252
|
+
expect(failed.failed).toBe(1);
|
|
253
|
+
|
|
254
|
+
const recovered = await sweep({batchLimit: 1});
|
|
255
|
+
|
|
256
|
+
expect(await findStream(id)).toBeNull();
|
|
257
|
+
expect(recovered.failed).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('re-cleans and deletes a stream when compaction publishes after the first prefix cleanup', async () => {
|
|
261
|
+
const id = newIdentity();
|
|
262
|
+
const stream = await arrangeClosedStream(id, {chunks: [ndjsonBody(outputLine('x'))]});
|
|
263
|
+
const key = `${attemptPrefix(id)}/${crypto.randomUUID()}`;
|
|
264
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
265
|
+
const realDeleteExpiredStream = streams.deleteExpiredStream;
|
|
266
|
+
let calls = 0;
|
|
267
|
+
vi.spyOn(streams, 'deleteExpiredStream').mockImplementation(async (tx, params) => {
|
|
268
|
+
calls += 1;
|
|
269
|
+
if (calls === 1 && params.streamId === stream.id) {
|
|
270
|
+
await tx
|
|
271
|
+
.update(attemptStreams)
|
|
272
|
+
.set({objectKey: key})
|
|
273
|
+
.where(eq(attemptStreams.id, stream.id));
|
|
274
|
+
await putObject(key, Buffer.from('compacted'));
|
|
275
|
+
}
|
|
276
|
+
return realDeleteExpiredStream(tx, params);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const result = await sweep();
|
|
280
|
+
|
|
281
|
+
expect(await findStream(id)).toBeNull();
|
|
282
|
+
expect(await objectExists(key)).toBe(false);
|
|
283
|
+
expect(result.deleted).toBe(1);
|
|
284
|
+
expect(result.raced).toBe(0);
|
|
285
|
+
expect(result.failed).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('does not starve a younger stream when reloading a raced row fails', async () => {
|
|
289
|
+
const poison = newIdentity();
|
|
290
|
+
const healthy = newIdentity();
|
|
291
|
+
const poisonStream = await arrangeClosedStream(poison);
|
|
292
|
+
const healthyStream = await arrangeClosedStream(healthy);
|
|
293
|
+
await backdateClosedAt(poisonStream.id, '200 days');
|
|
294
|
+
await backdateClosedAt(healthyStream.id, '100 days');
|
|
295
|
+
const realDeleteExpiredStream = streams.deleteExpiredStream;
|
|
296
|
+
vi.spyOn(streams, 'deleteExpiredStream').mockImplementation((tx, params) =>
|
|
297
|
+
params.streamId === poisonStream.id
|
|
298
|
+
? Promise.resolve({deleted: false, jobId: null})
|
|
299
|
+
: realDeleteExpiredStream(tx, params),
|
|
300
|
+
);
|
|
301
|
+
const realGetAttemptStreamById = streams.getAttemptStreamById;
|
|
302
|
+
vi.spyOn(streams, 'getAttemptStreamById').mockImplementation((streamId) =>
|
|
303
|
+
streamId === poisonStream.id
|
|
304
|
+
? Promise.reject(new Error('reload failed'))
|
|
305
|
+
: realGetAttemptStreamById(streamId),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const result = await sweep({batchLimit: 1});
|
|
309
|
+
|
|
310
|
+
expect(await findStream(poison)).not.toBeNull();
|
|
311
|
+
expect(await findStream(healthy)).toBeNull();
|
|
312
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('does not reset a live job budget: deletes its expired streams but keeps fresh accounting', async () => {
|
|
316
|
+
const id = newIdentity();
|
|
317
|
+
const stream = await arrangeClosedStream(id);
|
|
318
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
319
|
+
await jobAccountingFactory.create({jobId: id.jobId, workspaceId: id.workspaceId});
|
|
320
|
+
|
|
321
|
+
await sweep();
|
|
322
|
+
|
|
323
|
+
expect(await findStream(id)).toBeNull();
|
|
324
|
+
expect(await findAccounting(id.jobId)).not.toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('stops immediately and reports timedOut when the time budget is already spent', async () => {
|
|
328
|
+
const id = newIdentity();
|
|
329
|
+
const stream = await arrangeClosedStream(id);
|
|
330
|
+
await backdateClosedAt(stream.id, '100 days');
|
|
331
|
+
|
|
332
|
+
const result = await sweep({timeBudgetMs: 0, now: () => 1_000});
|
|
333
|
+
|
|
334
|
+
expect(result.timedOut).toBe(true);
|
|
335
|
+
expect(await findStream(id)).not.toBeNull();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('stops mid-batch on the budget, deleting what it reached and leaving the rest', async () => {
|
|
339
|
+
const drained = newIdentity();
|
|
340
|
+
const remaining = newIdentity();
|
|
341
|
+
const drainedStream = await arrangeClosedStream(drained);
|
|
342
|
+
const remainingStream = await arrangeClosedStream(remaining);
|
|
343
|
+
await backdateClosedAt(drainedStream.id, '200 days');
|
|
344
|
+
await backdateClosedAt(remainingStream.id, '100 days');
|
|
345
|
+
// Keep the first stream inside the budget, then make the per-stream check stop the second.
|
|
346
|
+
let calls = 0;
|
|
347
|
+
const clock = () => (++calls <= 3 ? 0 : 1_000);
|
|
348
|
+
|
|
349
|
+
const result = await sweep({timeBudgetMs: 1, now: clock});
|
|
350
|
+
|
|
351
|
+
expect(result.timedOut).toBe(true);
|
|
352
|
+
expect(result.deleted).toBe(1);
|
|
353
|
+
expect(await findStream(drained)).toBeNull();
|
|
354
|
+
expect(await findStream(remaining)).not.toBeNull();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('deleteExpiredStream (compaction-race guard)', () => {
|
|
359
|
+
it('skips the row when object_key changed after observation, so the published object is not orphaned', async () => {
|
|
360
|
+
const id = newIdentity();
|
|
361
|
+
const stream = await arrangeClosedStream(id, {chunks: [ndjsonBody(outputLine('x'))]});
|
|
362
|
+
await setObjectKey(stream.id, 'logs/compaction/published');
|
|
363
|
+
|
|
364
|
+
const outcome = await db().transaction((tx) =>
|
|
365
|
+
streams.deleteExpiredStream(tx, {streamId: stream.id, observedObjectKey: null}),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(outcome.deleted).toBe(false);
|
|
369
|
+
expect(await findStream(id)).not.toBeNull();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('deletes the row when the observed object_key still matches', async () => {
|
|
373
|
+
const id = newIdentity();
|
|
374
|
+
const stream = await arrangeClosedStream(id);
|
|
375
|
+
await setObjectKey(stream.id, 'logs/compaction/key');
|
|
376
|
+
|
|
377
|
+
const outcome = await db().transaction((tx) =>
|
|
378
|
+
streams.deleteExpiredStream(tx, {
|
|
379
|
+
streamId: stream.id,
|
|
380
|
+
observedObjectKey: 'logs/compaction/key',
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
expect(outcome.deleted).toBe(true);
|
|
385
|
+
expect(outcome.jobId).toBe(id.jobId);
|
|
386
|
+
expect(await findStream(id)).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {deleteObjectsByPrefix} from '#api/object-storage.js';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import type {AttemptStream} from '#core/entities/attempt-stream.js';
|
|
5
|
+
import {logObjectKey} from '#core/entities/log-object.js';
|
|
6
|
+
import {deleteJobAccounting} from '#db/accounting.js';
|
|
7
|
+
import {db} from '#db/db.js';
|
|
8
|
+
import {
|
|
9
|
+
accountingHasNoStreams,
|
|
10
|
+
deleteExpiredStream,
|
|
11
|
+
getAttemptStreamById,
|
|
12
|
+
listExpiredClosedStreams,
|
|
13
|
+
} from '#db/streams.js';
|
|
14
|
+
|
|
15
|
+
export interface RetentionSweepResult {
|
|
16
|
+
/** Streams whose objects and row were deleted. */
|
|
17
|
+
deleted: number;
|
|
18
|
+
/** Streams skipped because compaction changed `object_key` after we read it (retried next run). */
|
|
19
|
+
raced: number;
|
|
20
|
+
/** Streams whose object cleanup or guarded row delete threw; logged, skipped, and retried next run. */
|
|
21
|
+
failed: number;
|
|
22
|
+
accountingPruned: number;
|
|
23
|
+
iterations: number;
|
|
24
|
+
/** True when the sweep stopped on its wall-clock budget with backlog likely remaining. */
|
|
25
|
+
timedOut: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunRetentionSweepParams {
|
|
29
|
+
retentionDays: number;
|
|
30
|
+
batchLimit: number;
|
|
31
|
+
timeBudgetMs: number;
|
|
32
|
+
maxIterations: number;
|
|
33
|
+
/** Wall clock; injectable so tests can drive the time budget deterministically. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** Liveness signal (e.g. the activity heartbeat); invoked once per processed stream. */
|
|
36
|
+
onProgress?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deleteExpiredStreamObjects(stream: AttemptStream): Promise<void> {
|
|
40
|
+
return deleteObjectsByPrefix(`${logObjectKey(config.LOG_STORAGE_S3_PREFIX, stream)}/`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deleteExpiredStreamRow(params: {
|
|
44
|
+
stream: AttemptStream;
|
|
45
|
+
retentionDays: number;
|
|
46
|
+
}): Promise<{deleted: boolean; prunedAccounting: boolean}> {
|
|
47
|
+
return db().transaction(async (tx) => {
|
|
48
|
+
const {deleted, jobId} = await deleteExpiredStream(tx, {
|
|
49
|
+
streamId: params.stream.id,
|
|
50
|
+
observedObjectKey: params.stream.objectKey,
|
|
51
|
+
});
|
|
52
|
+
if (deleted && jobId && (await accountingHasNoStreams(tx, jobId))) {
|
|
53
|
+
const pruned = await deleteJobAccounting(tx, {
|
|
54
|
+
jobId,
|
|
55
|
+
retentionDays: params.retentionDays,
|
|
56
|
+
});
|
|
57
|
+
return {deleted, prunedAccounting: pruned.deleted};
|
|
58
|
+
}
|
|
59
|
+
return {deleted, prunedAccounting: false};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deletes expired closed streams and prunes accounting for emptied jobs.
|
|
65
|
+
*
|
|
66
|
+
* The loop self-bounds because a Temporal `startToCloseTimeout` marks the activity failed but
|
|
67
|
+
* does not stop already-running JS. Objects are deleted before rows so a cleanup failure leaves
|
|
68
|
+
* the row discoverable for the next sweep; the row delete stays guarded on the observed
|
|
69
|
+
* `object_key`, so a racing compaction publish is re-read before the row is removed.
|
|
70
|
+
*/
|
|
71
|
+
export async function runRetentionSweep(
|
|
72
|
+
params: RunRetentionSweepParams,
|
|
73
|
+
): Promise<RetentionSweepResult> {
|
|
74
|
+
const now = params.now ?? Date.now;
|
|
75
|
+
const deadline = now() + params.timeBudgetMs;
|
|
76
|
+
const result: RetentionSweepResult = {
|
|
77
|
+
deleted: 0,
|
|
78
|
+
raced: 0,
|
|
79
|
+
failed: 0,
|
|
80
|
+
accountingPruned: 0,
|
|
81
|
+
iterations: 0,
|
|
82
|
+
timedOut: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Failed or raced rows retry next run; skipping them here keeps the rest of the backlog moving.
|
|
86
|
+
const skip = new Set<string>();
|
|
87
|
+
while (result.iterations < params.maxIterations) {
|
|
88
|
+
if (now() >= deadline) {
|
|
89
|
+
result.timedOut = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const batch = await listExpiredClosedStreams({
|
|
94
|
+
retentionDays: params.retentionDays,
|
|
95
|
+
limit: params.batchLimit,
|
|
96
|
+
excludeIds: skip.size > 0 ? [...skip] : undefined,
|
|
97
|
+
});
|
|
98
|
+
if (batch.length === 0) break;
|
|
99
|
+
|
|
100
|
+
let timedOutMidBatch = false;
|
|
101
|
+
for (const stream of batch) {
|
|
102
|
+
// Per-stream checks keep long batches within the heartbeat and wall-clock budgets.
|
|
103
|
+
params.onProgress?.();
|
|
104
|
+
if (now() >= deadline) {
|
|
105
|
+
result.timedOut = true;
|
|
106
|
+
timedOutMidBatch = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await deleteExpiredStreamObjects(stream);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
result.failed += 1;
|
|
114
|
+
skip.add(stream.id);
|
|
115
|
+
logger().error(
|
|
116
|
+
{err: error, streamId: stream.id},
|
|
117
|
+
'Failed to delete expired log stream objects',
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let outcome: {deleted: boolean; prunedAccounting: boolean};
|
|
123
|
+
try {
|
|
124
|
+
outcome = await deleteExpiredStreamRow({stream, retentionDays: params.retentionDays});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
result.failed += 1;
|
|
127
|
+
skip.add(stream.id);
|
|
128
|
+
logger().error(
|
|
129
|
+
{err: error, streamId: stream.id},
|
|
130
|
+
'Failed to delete expired log stream row',
|
|
131
|
+
);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!outcome.deleted) {
|
|
136
|
+
let current: AttemptStream | null;
|
|
137
|
+
try {
|
|
138
|
+
current = await getAttemptStreamById(stream.id);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
result.failed += 1;
|
|
141
|
+
skip.add(stream.id);
|
|
142
|
+
logger().error(
|
|
143
|
+
{err: error, streamId: stream.id},
|
|
144
|
+
'Failed to reload raced expired log stream',
|
|
145
|
+
);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (current) {
|
|
149
|
+
try {
|
|
150
|
+
await deleteExpiredStreamObjects(current);
|
|
151
|
+
outcome = await deleteExpiredStreamRow({
|
|
152
|
+
stream: current,
|
|
153
|
+
retentionDays: params.retentionDays,
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
result.failed += 1;
|
|
157
|
+
skip.add(stream.id);
|
|
158
|
+
logger().error(
|
|
159
|
+
{err: error, streamId: stream.id},
|
|
160
|
+
'Failed to delete raced expired log stream',
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!current || !outcome.deleted) {
|
|
166
|
+
// `object_key` changed again or the row disappeared; the next sweep will re-read it.
|
|
167
|
+
result.raced += 1;
|
|
168
|
+
skip.add(stream.id);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
result.deleted += 1;
|
|
174
|
+
if (outcome.prunedAccounting) result.accountingPruned += 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (timedOutMidBatch) break;
|
|
178
|
+
|
|
179
|
+
result.iterations += 1;
|
|
180
|
+
if (batch.length < params.batchLimit) break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
}
|