@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.
Files changed (342) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +179 -0
  5. package/LICENSE +21 -0
  6. package/README.md +151 -0
  7. package/dist/api/object-storage.d.ts +69 -0
  8. package/dist/api/object-storage.d.ts.map +1 -0
  9. package/dist/api/object-storage.js +199 -0
  10. package/dist/api/object-storage.js.map +1 -0
  11. package/dist/config.d.ts +20 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +108 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/append-logs.d.ts +28 -0
  16. package/dist/core/append-logs.d.ts.map +1 -0
  17. package/dist/core/append-logs.js +318 -0
  18. package/dist/core/append-logs.js.map +1 -0
  19. package/dist/core/budget.d.ts +12 -0
  20. package/dist/core/budget.d.ts.map +1 -0
  21. package/dist/core/budget.js +9 -0
  22. package/dist/core/budget.js.map +1 -0
  23. package/dist/core/close-stream.d.ts +27 -0
  24. package/dist/core/close-stream.d.ts.map +1 -0
  25. package/dist/core/close-stream.js +59 -0
  26. package/dist/core/close-stream.js.map +1 -0
  27. package/dist/core/compaction.d.ts +28 -0
  28. package/dist/core/compaction.d.ts.map +1 -0
  29. package/dist/core/compaction.js +49 -0
  30. package/dist/core/compaction.js.map +1 -0
  31. package/dist/core/entities/attempt-stream.d.ts +37 -0
  32. package/dist/core/entities/attempt-stream.d.ts.map +1 -0
  33. package/dist/core/entities/attempt-stream.js +15 -0
  34. package/dist/core/entities/attempt-stream.js.map +1 -0
  35. package/dist/core/entities/job-accounting.d.ts +12 -0
  36. package/dist/core/entities/job-accounting.d.ts.map +1 -0
  37. package/dist/core/entities/job-accounting.js +6 -0
  38. package/dist/core/entities/job-accounting.js.map +1 -0
  39. package/dist/core/entities/log-object.d.ts +13 -0
  40. package/dist/core/entities/log-object.d.ts.map +1 -0
  41. package/dist/core/entities/log-object.js +9 -0
  42. package/dist/core/entities/log-object.js.map +1 -0
  43. package/dist/core/errors.d.ts +26 -0
  44. package/dist/core/errors.d.ts.map +1 -0
  45. package/dist/core/errors.js +31 -0
  46. package/dist/core/errors.js.map +1 -0
  47. package/dist/core/finalize-attempt-stream.d.ts +22 -0
  48. package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
  49. package/dist/core/finalize-attempt-stream.js +48 -0
  50. package/dist/core/finalize-attempt-stream.js.map +1 -0
  51. package/dist/core/read-logs.d.ts +33 -0
  52. package/dist/core/read-logs.d.ts.map +1 -0
  53. package/dist/core/read-logs.js +49 -0
  54. package/dist/core/read-logs.js.map +1 -0
  55. package/dist/core/reap-stale-open-streams.d.ts +27 -0
  56. package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
  57. package/dist/core/reap-stale-open-streams.js +50 -0
  58. package/dist/core/reap-stale-open-streams.js.map +1 -0
  59. package/dist/core/retention.d.ts +32 -0
  60. package/dist/core/retention.d.ts.map +1 -0
  61. package/dist/core/retention.js +148 -0
  62. package/dist/core/retention.js.map +1 -0
  63. package/dist/core/session/claude/rows.d.ts +6 -0
  64. package/dist/core/session/claude/rows.d.ts.map +1 -0
  65. package/dist/core/session/claude/rows.js +145 -0
  66. package/dist/core/session/claude/rows.js.map +1 -0
  67. package/dist/core/session/claude-parser.d.ts +4 -0
  68. package/dist/core/session/claude-parser.d.ts.map +1 -0
  69. package/dist/core/session/claude-parser.js +42 -0
  70. package/dist/core/session/claude-parser.js.map +1 -0
  71. package/dist/core/session/entry-schema.d.ts +51 -0
  72. package/dist/core/session/entry-schema.d.ts.map +1 -0
  73. package/dist/core/session/entry-schema.js +61 -0
  74. package/dist/core/session/entry-schema.js.map +1 -0
  75. package/dist/core/session/object.d.ts +12 -0
  76. package/dist/core/session/object.d.ts.map +1 -0
  77. package/dist/core/session/object.js +50 -0
  78. package/dist/core/session/object.js.map +1 -0
  79. package/dist/core/session/parse-session.d.ts +5 -0
  80. package/dist/core/session/parse-session.d.ts.map +1 -0
  81. package/dist/core/session/parse-session.js +18 -0
  82. package/dist/core/session/parse-session.js.map +1 -0
  83. package/dist/core/session/pi/message-rows.d.ts +4 -0
  84. package/dist/core/session/pi/message-rows.d.ts.map +1 -0
  85. package/dist/core/session/pi/message-rows.js +113 -0
  86. package/dist/core/session/pi/message-rows.js.map +1 -0
  87. package/dist/core/session/pi/metadata.d.ts +18 -0
  88. package/dist/core/session/pi/metadata.d.ts.map +1 -0
  89. package/dist/core/session/pi/metadata.js +120 -0
  90. package/dist/core/session/pi/metadata.js.map +1 -0
  91. package/dist/core/session/pi/text.d.ts +12 -0
  92. package/dist/core/session/pi/text.d.ts.map +1 -0
  93. package/dist/core/session/pi/text.js +59 -0
  94. package/dist/core/session/pi/text.js.map +1 -0
  95. package/dist/core/session/pi-parser.d.ts +5 -0
  96. package/dist/core/session/pi-parser.d.ts.map +1 -0
  97. package/dist/core/session/pi-parser.js +73 -0
  98. package/dist/core/session/pi-parser.js.map +1 -0
  99. package/dist/core/session/rows.d.ts +7 -0
  100. package/dist/core/session/rows.d.ts.map +1 -0
  101. package/dist/core/session/rows.js +39 -0
  102. package/dist/core/session/rows.js.map +1 -0
  103. package/dist/core/session/session-record.d.ts +5 -0
  104. package/dist/core/session/session-record.d.ts.map +1 -0
  105. package/dist/core/session/session-record.js +3 -0
  106. package/dist/core/session/session-record.js.map +1 -0
  107. package/dist/db/accounting.d.ts +33 -0
  108. package/dist/db/accounting.d.ts.map +1 -0
  109. package/dist/db/accounting.js +52 -0
  110. package/dist/db/accounting.js.map +1 -0
  111. package/dist/db/chunks.d.ts +62 -0
  112. package/dist/db/chunks.d.ts.map +1 -0
  113. package/dist/db/chunks.js +88 -0
  114. package/dist/db/chunks.js.map +1 -0
  115. package/dist/db/db.d.ts +752 -0
  116. package/dist/db/db.d.ts.map +1 -0
  117. package/dist/db/db.js +24 -0
  118. package/dist/db/db.js.map +1 -0
  119. package/dist/db/index.d.ts +2 -0
  120. package/dist/db/index.d.ts.map +1 -0
  121. package/dist/db/index.js +5 -0
  122. package/dist/db/index.js.map +1 -0
  123. package/dist/db/schema/attempt-streams.d.ts +304 -0
  124. package/dist/db/schema/attempt-streams.d.ts.map +1 -0
  125. package/dist/db/schema/attempt-streams.js +100 -0
  126. package/dist/db/schema/attempt-streams.js.map +1 -0
  127. package/dist/db/schema/chunks.d.ts +166 -0
  128. package/dist/db/schema/chunks.d.ts.map +1 -0
  129. package/dist/db/schema/chunks.js +47 -0
  130. package/dist/db/schema/chunks.js.map +1 -0
  131. package/dist/db/schema/common.d.ts +29 -0
  132. package/dist/db/schema/common.d.ts.map +1 -0
  133. package/dist/db/schema/common.js +11 -0
  134. package/dist/db/schema/common.js.map +1 -0
  135. package/dist/db/schema/job-accounting.d.ts +143 -0
  136. package/dist/db/schema/job-accounting.d.ts.map +1 -0
  137. package/dist/db/schema/job-accounting.js +43 -0
  138. package/dist/db/schema/job-accounting.js.map +1 -0
  139. package/dist/db/schema/outbox.d.ts +195 -0
  140. package/dist/db/schema/outbox.d.ts.map +1 -0
  141. package/dist/db/schema/outbox.js +5 -0
  142. package/dist/db/schema/outbox.js.map +1 -0
  143. package/dist/db/streams.d.ts +141 -0
  144. package/dist/db/streams.d.ts.map +1 -0
  145. package/dist/db/streams.js +197 -0
  146. package/dist/db/streams.js.map +1 -0
  147. package/dist/index.d.ts +4 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +81 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/metrics/index.d.ts +2 -0
  152. package/dist/metrics/index.d.ts.map +1 -0
  153. package/dist/metrics/index.js +3 -0
  154. package/dist/metrics/index.js.map +1 -0
  155. package/dist/metrics/instance.d.ts +14 -0
  156. package/dist/metrics/instance.d.ts.map +1 -0
  157. package/dist/metrics/instance.js +16 -0
  158. package/dist/metrics/instance.js.map +1 -0
  159. package/dist/metrics/service.d.ts +2 -0
  160. package/dist/metrics/service.d.ts.map +1 -0
  161. package/dist/metrics/service.js +20 -0
  162. package/dist/metrics/service.js.map +1 -0
  163. package/dist/presentation/dto/read-logs.d.ts +4 -0
  164. package/dist/presentation/dto/read-logs.d.ts.map +1 -0
  165. package/dist/presentation/dto/read-logs.js +22 -0
  166. package/dist/presentation/dto/read-logs.js.map +1 -0
  167. package/dist/presentation/routes/append-logs.d.ts +2 -0
  168. package/dist/presentation/routes/append-logs.d.ts.map +1 -0
  169. package/dist/presentation/routes/append-logs.js +71 -0
  170. package/dist/presentation/routes/append-logs.js.map +1 -0
  171. package/dist/presentation/routes/index.d.ts +3 -0
  172. package/dist/presentation/routes/index.d.ts.map +1 -0
  173. package/dist/presentation/routes/index.js +32 -0
  174. package/dist/presentation/routes/index.js.map +1 -0
  175. package/dist/presentation/routes/read-logs.d.ts +2 -0
  176. package/dist/presentation/routes/read-logs.d.ts.map +1 -0
  177. package/dist/presentation/routes/read-logs.js +41 -0
  178. package/dist/presentation/routes/read-logs.js.map +1 -0
  179. package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
  180. package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
  181. package/dist/presentation/subscribers/on-job-terminated.js +32 -0
  182. package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
  183. package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
  184. package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
  185. package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
  186. package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
  187. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
  188. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
  189. package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
  190. package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
  191. package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
  192. package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
  193. package/dist/temporal/activities/close-abandoned-streams.js +33 -0
  194. package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
  195. package/dist/temporal/activities/compact-stream.d.ts +33 -0
  196. package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
  197. package/dist/temporal/activities/compact-stream.js +95 -0
  198. package/dist/temporal/activities/compact-stream.js.map +1 -0
  199. package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
  200. package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
  201. package/dist/temporal/activities/compaction-reconcile.js +52 -0
  202. package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
  203. package/dist/temporal/activities/index.d.ts +14 -0
  204. package/dist/temporal/activities/index.d.ts.map +1 -0
  205. package/dist/temporal/activities/index.js +16 -0
  206. package/dist/temporal/activities/index.js.map +1 -0
  207. package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
  208. package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
  209. package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
  210. package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
  211. package/dist/temporal/activities/retention-sweep.d.ts +7 -0
  212. package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
  213. package/dist/temporal/activities/retention-sweep.js +19 -0
  214. package/dist/temporal/activities/retention-sweep.js.map +1 -0
  215. package/dist/temporal/constants.d.ts +7 -0
  216. package/dist/temporal/constants.d.ts.map +1 -0
  217. package/dist/temporal/constants.js +13 -0
  218. package/dist/temporal/constants.js.map +1 -0
  219. package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
  220. package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
  221. package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
  222. package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
  223. package/dist/temporal/workflows/compact-stream.d.ts +6 -0
  224. package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
  225. package/dist/temporal/workflows/compact-stream.js +25 -0
  226. package/dist/temporal/workflows/compact-stream.js.map +1 -0
  227. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
  228. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
  229. package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
  230. package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
  231. package/dist/temporal/workflows/index.d.ts +6 -0
  232. package/dist/temporal/workflows/index.d.ts.map +1 -0
  233. package/dist/temporal/workflows/index.js +7 -0
  234. package/dist/temporal/workflows/index.js.map +1 -0
  235. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
  236. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
  237. package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
  238. package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
  239. package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
  240. package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
  241. package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
  242. package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
  243. package/dist/tsconfig.test.tsbuildinfo +1 -0
  244. package/drizzle/0000_initial.sql +65 -0
  245. package/drizzle/meta/0000_snapshot.json +529 -0
  246. package/drizzle/meta/_journal.json +13 -0
  247. package/drizzle.config.ts +7 -0
  248. package/package.json +84 -0
  249. package/src/api/object-storage-credentials.test.ts +70 -0
  250. package/src/api/object-storage.test.ts +64 -0
  251. package/src/api/object-storage.ts +226 -0
  252. package/src/config.test.ts +118 -0
  253. package/src/config.ts +130 -0
  254. package/src/core/append-logs.test.ts +563 -0
  255. package/src/core/append-logs.ts +384 -0
  256. package/src/core/budget.test.ts +30 -0
  257. package/src/core/budget.ts +19 -0
  258. package/src/core/close-stream.ts +72 -0
  259. package/src/core/compaction.ts +68 -0
  260. package/src/core/entities/attempt-stream.ts +38 -0
  261. package/src/core/entities/job-accounting.ts +11 -0
  262. package/src/core/entities/log-object.test.ts +25 -0
  263. package/src/core/entities/log-object.ts +18 -0
  264. package/src/core/errors.ts +37 -0
  265. package/src/core/finalize-attempt-stream.test.ts +90 -0
  266. package/src/core/finalize-attempt-stream.ts +59 -0
  267. package/src/core/read-logs.ts +77 -0
  268. package/src/core/reap-stale-open-streams.test.ts +137 -0
  269. package/src/core/reap-stale-open-streams.ts +60 -0
  270. package/src/core/retention.test.ts +388 -0
  271. package/src/core/retention.ts +184 -0
  272. package/src/core/session/claude/rows.ts +223 -0
  273. package/src/core/session/claude-parser.test.ts +199 -0
  274. package/src/core/session/claude-parser.ts +38 -0
  275. package/src/core/session/entry-schema.ts +82 -0
  276. package/src/core/session/object.ts +57 -0
  277. package/src/core/session/parse-session.ts +23 -0
  278. package/src/core/session/pi/message-rows.ts +211 -0
  279. package/src/core/session/pi/metadata.ts +148 -0
  280. package/src/core/session/pi/text.ts +83 -0
  281. package/src/core/session/pi-parser.test.ts +451 -0
  282. package/src/core/session/pi-parser.ts +107 -0
  283. package/src/core/session/rows.ts +38 -0
  284. package/src/core/session/session-record.ts +4 -0
  285. package/src/db/accounting.ts +81 -0
  286. package/src/db/chunks.ts +143 -0
  287. package/src/db/db.ts +22 -0
  288. package/src/db/index.ts +4 -0
  289. package/src/db/schema/attempt-streams.ts +106 -0
  290. package/src/db/schema/chunks.ts +43 -0
  291. package/src/db/schema/common.ts +12 -0
  292. package/src/db/schema/job-accounting.ts +38 -0
  293. package/src/db/schema/outbox.ts +4 -0
  294. package/src/db/streams.test.ts +47 -0
  295. package/src/db/streams.ts +368 -0
  296. package/src/index.ts +77 -0
  297. package/src/metrics/index.ts +1 -0
  298. package/src/metrics/instance.ts +33 -0
  299. package/src/metrics/service.ts +23 -0
  300. package/src/presentation/dto/read-logs.ts +24 -0
  301. package/src/presentation/routes/append-logs.test.ts +279 -0
  302. package/src/presentation/routes/append-logs.ts +66 -0
  303. package/src/presentation/routes/index.ts +27 -0
  304. package/src/presentation/routes/read-logs.test.ts +504 -0
  305. package/src/presentation/routes/read-logs.ts +38 -0
  306. package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
  307. package/src/presentation/subscribers/on-job-terminated.ts +26 -0
  308. package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
  309. package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
  310. package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
  311. package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
  312. package/src/temporal/activities/close-abandoned-streams.ts +30 -0
  313. package/src/temporal/activities/compact-stream.test.ts +221 -0
  314. package/src/temporal/activities/compact-stream.ts +123 -0
  315. package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
  316. package/src/temporal/activities/compaction-reconcile.ts +48 -0
  317. package/src/temporal/activities/index.ts +15 -0
  318. package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
  319. package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
  320. package/src/temporal/activities/retention-sweep.test.ts +38 -0
  321. package/src/temporal/activities/retention-sweep.ts +23 -0
  322. package/src/temporal/constants.ts +14 -0
  323. package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
  324. package/src/temporal/workflows/compact-stream.ts +26 -0
  325. package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
  326. package/src/temporal/workflows/index.ts +5 -0
  327. package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
  328. package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
  329. package/test/env.ts +37 -0
  330. package/test/factories/job-accounting.ts +38 -0
  331. package/test/fixtures/closed-stream.ts +53 -0
  332. package/test/fixtures/lease-token.ts +42 -0
  333. package/test/fixtures/ndjson.ts +34 -0
  334. package/test/globalSetup.ts +44 -0
  335. package/test/index.ts +9 -0
  336. package/test/queries.ts +54 -0
  337. package/test/setup.ts +17 -0
  338. package/tsconfig.build.json +9 -0
  339. package/tsconfig.build.tsbuildinfo +1 -0
  340. package/tsconfig.json +3 -0
  341. package/tsconfig.test.json +8 -0
  342. package/vitest.config.ts +13 -0
@@ -0,0 +1,26 @@
1
+ import type {LogStreamClosedEvent} from '@shipfox/api-logs-dto';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import {temporalClient} from '@shipfox/node-temporal';
4
+ import {LOGS_COMPACTION_TASK_QUEUE} from '#temporal/constants.js';
5
+
6
+ /**
7
+ * A stream reached `closed` (declared end or timeout sweep). Start its compaction workflow
8
+ * and return; no S3 work runs in the dispatcher. Deduped by `logs-compact:{streamId}`, so a
9
+ * redelivered event is a no-op. A start that fails for any other reason is left to the
10
+ * reconcile cron, which re-drives any closed-but-uncompacted stream.
11
+ */
12
+ export async function onLogStreamClosed(payload: LogStreamClosedEvent): Promise<void> {
13
+ try {
14
+ await temporalClient().workflow.start('compactStream', {
15
+ taskQueue: LOGS_COMPACTION_TASK_QUEUE,
16
+ workflowId: `logs-compact:${payload.streamId}`,
17
+ args: [{streamId: payload.streamId}],
18
+ });
19
+ } catch (error) {
20
+ if (error instanceof Error && error.name === 'WorkflowExecutionAlreadyStartedError') {
21
+ logger().debug({streamId: payload.streamId}, 'Compaction workflow already started');
22
+ return;
23
+ }
24
+ throw error;
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ import type {WorkflowsStepAttemptTerminatedEventDto} from '@shipfox/api-workflows-dto';
2
+ import {finalizeAttemptLogStream} from '#core/finalize-attempt-stream.js';
3
+
4
+ export async function onStepAttemptTerminated(
5
+ payload: WorkflowsStepAttemptTerminatedEventDto,
6
+ ): Promise<void> {
7
+ await finalizeAttemptLogStream({
8
+ jobId: payload.jobId,
9
+ workflowRunAttemptId: payload.workflowRunAttemptId,
10
+ workspaceId: payload.workspaceId,
11
+ projectId: payload.projectId,
12
+ stepId: payload.stepId,
13
+ attempt: payload.attempt,
14
+ logOutcome: payload.logOutcome,
15
+ });
16
+ }
@@ -0,0 +1,84 @@
1
+ import {appendLogs} from '#core/append-logs.js';
2
+ import {closeAbandonedStreamsActivity} from '#temporal/activities/close-abandoned-streams.js';
3
+ import {endLine, ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
4
+ import {findStream, listChunks, listStreamClosedEvents} from '#test/queries.js';
5
+
6
+ interface Ctx {
7
+ jobId: string;
8
+ stepId: string;
9
+ workspaceId: string;
10
+ projectId: string;
11
+ workflowRunAttemptId: string;
12
+ }
13
+
14
+ function newCtx(): Ctx {
15
+ return {
16
+ jobId: crypto.randomUUID(),
17
+ stepId: crypto.randomUUID(),
18
+ workspaceId: crypto.randomUUID(),
19
+ projectId: crypto.randomUUID(),
20
+ workflowRunAttemptId: crypto.randomUUID(),
21
+ };
22
+ }
23
+
24
+ describe('closeAbandonedStreamsActivity', () => {
25
+ it('force-closes an open stream with a runner_lost tombstone and one event', async () => {
26
+ const ctx = newCtx();
27
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: ndjsonBody(outputLine('partial\n'))});
28
+ const open = await findStream({...ctx, attempt: 1});
29
+
30
+ const {closed} = await closeAbandonedStreamsActivity({jobId: ctx.jobId});
31
+
32
+ expect(closed).toBe(1);
33
+ const after = await findStream({...ctx, attempt: 1});
34
+ expect(after?.state).toBe('closed');
35
+ expect(after?.closeReason).toBe('timeout');
36
+ expect(after?.truncated).toBe(true);
37
+ expect(after?.committedLength).toBe(open?.committedLength);
38
+ expect((await listChunks(after?.id as string)).map((c) => c.origin)).toEqual([
39
+ 'runner',
40
+ 'control',
41
+ ]);
42
+ expect(await listStreamClosedEvents(after?.id as string)).toHaveLength(1);
43
+ });
44
+
45
+ it('closes only the still-open streams, skipping ones already declared-closed', async () => {
46
+ const ctx = newCtx();
47
+ const stepOpen = crypto.randomUUID();
48
+ const stepDone = crypto.randomUUID();
49
+ await appendLogs({
50
+ ...ctx,
51
+ stepId: stepDone,
52
+ attempt: 1,
53
+ offset: 0,
54
+ body: ndjsonBody(outputLine('done\n'), endLine(4)),
55
+ });
56
+ await appendLogs({
57
+ ...ctx,
58
+ stepId: stepOpen,
59
+ attempt: 1,
60
+ offset: 0,
61
+ body: ndjsonBody(outputLine('partial\n')),
62
+ });
63
+ const doneStream = await findStream({jobId: ctx.jobId, stepId: stepDone, attempt: 1});
64
+
65
+ const {closed} = await closeAbandonedStreamsActivity({jobId: ctx.jobId});
66
+
67
+ expect(closed).toBe(1);
68
+ const openAfter = await findStream({jobId: ctx.jobId, stepId: stepOpen, attempt: 1});
69
+ expect(openAfter?.closeReason).toBe('timeout');
70
+ expect(openAfter?.truncated).toBe(true);
71
+ const doneAfter = await findStream({jobId: ctx.jobId, stepId: stepDone, attempt: 1});
72
+ expect(doneAfter?.closeReason).toBe('declared');
73
+ expect(doneAfter?.truncated).toBe(false);
74
+ expect(await listStreamClosedEvents(doneStream?.id as string)).toHaveLength(1);
75
+ });
76
+
77
+ it('is a no-op for a job with no open streams', async () => {
78
+ const ctx = newCtx();
79
+
80
+ const {closed} = await closeAbandonedStreamsActivity({jobId: ctx.jobId});
81
+
82
+ expect(closed).toBe(0);
83
+ });
84
+ });
@@ -0,0 +1,30 @@
1
+ import {closeStream} from '#core/close-stream.js';
2
+ import {db} from '#db/db.js';
3
+ import {listOpenStreamsByJob} from '#db/streams.js';
4
+ import {recordAppendedCount, streamClosedCount} from '#metrics/instance.js';
5
+
6
+ /**
7
+ * Job termination does not guarantee the runner flushed an end record: it may have
8
+ * died, been capped, or lost its spool. Each stream closes in its own transaction
9
+ * through the guarded `closeStream`, so a declared-close race is skipped instead of
10
+ * writing a duplicate event or tombstone.
11
+ */
12
+ export async function closeAbandonedStreamsActivity(params: {
13
+ jobId: string;
14
+ }): Promise<{closed: number}> {
15
+ const open = await listOpenStreamsByJob(params.jobId);
16
+
17
+ let closed = 0;
18
+ for (const stream of open) {
19
+ const result = await db().transaction((tx) =>
20
+ closeStream(tx, {streamId: stream.id, reason: 'timeout'}),
21
+ );
22
+ if (result) {
23
+ closed += 1;
24
+ recordAppendedCount.add(1, {kind: 'runner_lost'});
25
+ streamClosedCount.add(1, {reason: 'timeout'});
26
+ }
27
+ }
28
+
29
+ return {closed};
30
+ }
@@ -0,0 +1,221 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {Readable} from 'node:stream';
3
+ import {createGzip, gunzipSync} from 'node:zlib';
4
+ import {GetObjectCommand, HeadObjectCommand, ListObjectsV2Command} from '@aws-sdk/client-s3';
5
+ import {MockActivityEnvironment} from '@temporalio/testing';
6
+ import {eq} from 'drizzle-orm';
7
+ import {deleteObject, s3Client} from '#api/object-storage.js';
8
+ import {config} from '#config.js';
9
+ import {compactedGzipStream} from '#core/compaction.js';
10
+ import {logObjectKey} from '#core/entities/log-object.js';
11
+ import {db} from '#db/db.js';
12
+ import {attemptStreams} from '#db/schema/attempt-streams.js';
13
+ import {getAttemptStreamById, setObjectKeyAndDeleteChunks} from '#db/streams.js';
14
+ import {arrangeClosedStream, type ClosedStreamIdentity} from '#test/fixtures/closed-stream.js';
15
+ import {ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
16
+ import {listChunks} from '#test/queries.js';
17
+ import {
18
+ type CompactStreamResult,
19
+ compactStreamActivity,
20
+ createCompactStreamActivity,
21
+ } from './compact-stream.js';
22
+
23
+ const compactedGzipStreamMock = vi.fn<typeof compactedGzipStream>();
24
+ const setObjectKeyAndDeleteChunksMock = vi.fn<typeof setObjectKeyAndDeleteChunks>();
25
+ const compactStreamActivityWithMocks = createCompactStreamActivity({
26
+ compactedGzipStream: compactedGzipStreamMock,
27
+ setObjectKeyAndDeleteChunks: setObjectKeyAndDeleteChunksMock,
28
+ });
29
+
30
+ function newIdentity(): ClosedStreamIdentity {
31
+ return {
32
+ jobId: crypto.randomUUID(),
33
+ stepId: crypto.randomUUID(),
34
+ attempt: 1,
35
+ workspaceId: crypto.randomUUID(),
36
+ projectId: crypto.randomUUID(),
37
+ workflowRunAttemptId: crypto.randomUUID(),
38
+ };
39
+ }
40
+
41
+ function runCompaction(
42
+ streamId: string,
43
+ activity = compactStreamActivity,
44
+ ): Promise<CompactStreamResult> {
45
+ return new MockActivityEnvironment().run(activity, {streamId});
46
+ }
47
+
48
+ async function getObjectBytes(key: string): Promise<Buffer> {
49
+ const res = await s3Client().send(
50
+ new GetObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key}),
51
+ );
52
+ if (!res.Body) throw new Error('object has no body');
53
+ return Buffer.from(await res.Body.transformToByteArray());
54
+ }
55
+
56
+ function headObject(key: string) {
57
+ return s3Client().send(new HeadObjectCommand({Bucket: config.LOG_STORAGE_S3_BUCKET, Key: key}));
58
+ }
59
+
60
+ // Every attempt's object lives under the stream's stable prefix; listing it proves both the
61
+ // winner's object and that losing/failed attempts left nothing behind.
62
+ async function listKeysUnderStream(identity: ClosedStreamIdentity): Promise<string[]> {
63
+ const res = await s3Client().send(
64
+ new ListObjectsV2Command({
65
+ Bucket: config.LOG_STORAGE_S3_BUCKET,
66
+ Prefix: `${logObjectKey(config.LOG_STORAGE_S3_PREFIX, identity)}/`,
67
+ }),
68
+ );
69
+ return (res.Contents ?? []).map((object) => object.Key ?? '');
70
+ }
71
+
72
+ function compactedKey(result: CompactStreamResult): string {
73
+ if (result.outcome !== 'compacted') throw new Error(`expected compacted, got ${result.outcome}`);
74
+ return result.objectKey;
75
+ }
76
+
77
+ describe('compactStreamActivity', () => {
78
+ beforeEach(() => {
79
+ compactedGzipStreamMock.mockReset();
80
+ setObjectKeyAndDeleteChunksMock.mockReset();
81
+ compactedGzipStreamMock.mockImplementation(compactedGzipStream);
82
+ setObjectKeyAndDeleteChunksMock.mockImplementation(setObjectKeyAndDeleteChunks);
83
+ });
84
+
85
+ it('compacts many chunks into one gzip object and deletes the chunk rows', async () => {
86
+ const chunks = [outputLine('one\n'), outputLine('two\n'), outputLine('three\n')].map((l) =>
87
+ ndjsonBody(l),
88
+ );
89
+ const identity = newIdentity();
90
+ const stream = await arrangeClosedStream(identity, {chunks});
91
+
92
+ const result = await runCompaction(stream.id);
93
+
94
+ const key = compactedKey(result);
95
+ expect(key.startsWith(`${logObjectKey(config.LOG_STORAGE_S3_PREFIX, identity)}/`)).toBe(true);
96
+ const after = await getAttemptStreamById(stream.id);
97
+ expect(after?.objectKey).toBe(key);
98
+ expect(await listChunks(stream.id)).toHaveLength(0);
99
+ expect(await listKeysUnderStream(identity)).toEqual([key]);
100
+
101
+ const head = await headObject(key);
102
+ expect(head.ContentEncoding).toBe('gzip');
103
+ expect(head.ContentType).toBe('application/x-ndjson');
104
+ expect(head.Metadata?.stream_id).toBe(stream.id);
105
+ expect(head.Metadata?.chunk_count).toBe('3');
106
+
107
+ expect(gunzipSync(await getObjectBytes(key))).toEqual(Buffer.concat(chunks));
108
+
109
+ await deleteObject(key);
110
+ });
111
+
112
+ it('preserves seq order across many keyset pages (more chunks than one page)', async () => {
113
+ // CHUNK_PAGE_SIZE is 64; 150 chunks forces the keyset loop across three page seams, so a
114
+ // wrong afterSeq advance or page-boundary off-by-one would drop, dup, or reorder bytes.
115
+ const chunks = Array.from({length: 150}, (_, i) => ndjsonBody(outputLine(`line-${i}\n`)));
116
+ const identity = newIdentity();
117
+ const stream = await arrangeClosedStream(identity, {chunks});
118
+
119
+ const result = await runCompaction(stream.id);
120
+
121
+ const key = compactedKey(result);
122
+ expect(result.outcome === 'compacted' && result.chunkCount).toBe(150);
123
+ expect(gunzipSync(await getObjectBytes(key))).toEqual(Buffer.concat(chunks));
124
+ expect((await headObject(key)).Metadata?.chunk_count).toBe('150');
125
+ expect(await listChunks(stream.id)).toHaveLength(0);
126
+
127
+ await deleteObject(key);
128
+ });
129
+
130
+ it('is a no-op on re-run once the object key is set (idempotent / crash-safe)', async () => {
131
+ const stream = await arrangeClosedStream(newIdentity(), {
132
+ chunks: [ndjsonBody(outputLine('x\n'))],
133
+ });
134
+ const key = compactedKey(await runCompaction(stream.id));
135
+
136
+ const result = await runCompaction(stream.id);
137
+
138
+ expect(result.outcome).toBe('already-compacted');
139
+ await deleteObject(key);
140
+ });
141
+
142
+ it('produces a valid empty object for a stream with no chunks', async () => {
143
+ const stream = await arrangeClosedStream(newIdentity(), {chunks: []});
144
+
145
+ const result = await runCompaction(stream.id);
146
+
147
+ const key = compactedKey(result);
148
+ expect(gunzipSync(await getObjectBytes(key))).toHaveLength(0);
149
+ expect((await headObject(key)).Metadata?.chunk_count).toBe('0');
150
+
151
+ await deleteObject(key);
152
+ });
153
+
154
+ it('compacts a tombstone-only (timeout-closed) stream', async () => {
155
+ const stream = await arrangeClosedStream(newIdentity(), {tombstone: true});
156
+
157
+ const result = await runCompaction(stream.id);
158
+
159
+ const key = compactedKey(result);
160
+ expect(gunzipSync(await getObjectBytes(key)).toString('utf8')).toContain(
161
+ '"type":"runner_lost"',
162
+ );
163
+
164
+ await deleteObject(key);
165
+ });
166
+
167
+ it('returns gone when the stream row no longer exists', async () => {
168
+ const result = await runCompaction(crypto.randomUUID());
169
+
170
+ expect(result.outcome).toBe('gone');
171
+ });
172
+
173
+ it('throws, deletes its upload, and keeps the chunks when streamed totals disagree', async () => {
174
+ const identity = newIdentity();
175
+ const stream = await arrangeClosedStream(identity, {
176
+ chunks: [ndjsonBody(outputLine('a\n')), ndjsonBody(outputLine('b\n'))],
177
+ });
178
+ // Upload a (wrong) empty body whose stats claim zero chunks; the table has two.
179
+ compactedGzipStreamMock.mockReturnValueOnce({
180
+ body: Readable.from([]).pipe(createGzip()),
181
+ stats: {chunkCount: 0, lastSeq: 0, uncompressedBytes: 0},
182
+ });
183
+
184
+ await expect(runCompaction(stream.id, compactStreamActivityWithMocks)).rejects.toThrow(
185
+ 'integrity check',
186
+ );
187
+
188
+ const after = await getAttemptStreamById(stream.id);
189
+ expect(after?.objectKey).toBeNull();
190
+ expect(await listChunks(stream.id)).toHaveLength(2);
191
+ expect(await listKeysUnderStream(identity)).toEqual([]);
192
+ });
193
+
194
+ it('deletes its own upload and reports superseded when another attempt won the publish', async () => {
195
+ const identity = newIdentity();
196
+ const stream = await arrangeClosedStream(identity, {chunks: [ndjsonBody(outputLine('x\n'))]});
197
+ // Simulate a concurrent attempt publishing first: the guarded update matches 0 rows while
198
+ // the row still exists.
199
+ setObjectKeyAndDeleteChunksMock.mockResolvedValueOnce({updated: false});
200
+
201
+ const result = await runCompaction(stream.id, compactStreamActivityWithMocks);
202
+
203
+ expect(result.outcome).toBe('superseded');
204
+ expect(await listKeysUnderStream(identity)).toEqual([]);
205
+ });
206
+
207
+ it('deletes the orphaned object and reports retention-raced when the row vanished mid-upload', async () => {
208
+ const identity = newIdentity();
209
+ const stream = await arrangeClosedStream(identity, {chunks: [ndjsonBody(outputLine('x\n'))]});
210
+ // Simulate retention hard-deleting the row mid-upload: the guarded update finds 0 rows.
211
+ setObjectKeyAndDeleteChunksMock.mockImplementationOnce(async (_tx, params) => {
212
+ await db().delete(attemptStreams).where(eq(attemptStreams.id, params.streamId));
213
+ return {updated: false};
214
+ });
215
+
216
+ const result = await runCompaction(stream.id, compactStreamActivityWithMocks);
217
+
218
+ expect(result.outcome).toBe('retention-raced');
219
+ expect(await listKeysUnderStream(identity)).toEqual([]);
220
+ });
221
+ });
@@ -0,0 +1,123 @@
1
+ import {Context} from '@temporalio/activity';
2
+ import {compactedObjectKey, deleteObject, putCompactedObject} from '#api/object-storage.js';
3
+ import {compactedGzipStream} from '#core/compaction.js';
4
+ import {chunkStats} from '#db/chunks.js';
5
+ import {db, type Transaction} from '#db/db.js';
6
+ import {getAttemptStreamById, setObjectKeyAndDeleteChunks} from '#db/streams.js';
7
+ import {type CompactionMetricOutcome, compactionCount} from '#metrics/instance.js';
8
+
9
+ export type CompactStreamResult =
10
+ | {outcome: 'gone'}
11
+ | {outcome: 'already-compacted'}
12
+ | {outcome: 'superseded'}
13
+ | {outcome: 'retention-raced'}
14
+ | {outcome: 'compacted'; objectKey: string; chunkCount: number; uncompressedBytes: number};
15
+
16
+ interface CompactStreamDependencies {
17
+ compactedGzipStream: typeof compactedGzipStream;
18
+ setObjectKeyAndDeleteChunks: (
19
+ tx: Transaction,
20
+ params: {streamId: string; objectKey: string},
21
+ ) => Promise<{updated: boolean}>;
22
+ }
23
+
24
+ const defaultDependencies: CompactStreamDependencies = {
25
+ compactedGzipStream,
26
+ setObjectKeyAndDeleteChunks,
27
+ };
28
+
29
+ /**
30
+ * Compacts one closed stream into a single gzip NDJSON object, then deletes its chunk rows.
31
+ *
32
+ * load stream ──┬─ gone ─────────────► no-op (retention raced)
33
+ * ├─ object_key set ───► no-op (idempotent re-run)
34
+ * └─ else: upload to a per-attempt key ─► gzip ─► multipart (abort-aware, heartbeat)
35
+ * │
36
+ * verify streamed count/maxSeq/bytes == table (mismatch ─► delete upload, throw, retry)
37
+ * │
38
+ * tx: set object_key (state='closed' AND object_key IS NULL) + delete chunks
39
+ * └─ 0 rows ─► delete this attempt's upload, then re-read the row:
40
+ * gone ─► retention raced · keyed ─► superseded by another attempt
41
+ *
42
+ * Each attempt uploads to its own `compactedObjectKey(stream, uuid)`, so a slow or zombie
43
+ * attempt can never overwrite a published object. The single-winner publish (object_key set
44
+ * + chunk delete, atomic) drops chunks only once a complete object is durable; the integrity
45
+ * check (count, maxSeq, and byte total) guards a read bug from publishing a truncated object
46
+ * before the only copy of the source is gone (S3 part checksums cover byte transfer).
47
+ */
48
+ async function compactStream(
49
+ params: {streamId: string},
50
+ dependencies: CompactStreamDependencies,
51
+ ): Promise<CompactStreamResult> {
52
+ const stream = await getAttemptStreamById(params.streamId);
53
+ if (!stream) return {outcome: 'gone'};
54
+ if (stream.objectKey) return {outcome: 'already-compacted'};
55
+
56
+ const ctx = Context.current();
57
+ const uploadKey = compactedObjectKey(stream, crypto.randomUUID());
58
+ const expected = await chunkStats(stream.id);
59
+ const {body, stats} = dependencies.compactedGzipStream({
60
+ streamId: stream.id,
61
+ onPage: () => ctx.heartbeat(),
62
+ });
63
+
64
+ await putCompactedObject({
65
+ key: uploadKey,
66
+ body,
67
+ signal: ctx.cancellationSignal,
68
+ onProgress: () => ctx.heartbeat(),
69
+ metadata: {
70
+ stream_id: stream.id,
71
+ chunk_count: String(expected.count),
72
+ uncompressed_bytes: String(expected.uncompressedBytes),
73
+ last_seq: String(expected.maxSeq),
74
+ },
75
+ });
76
+
77
+ if (
78
+ stats.chunkCount !== expected.count ||
79
+ stats.lastSeq !== expected.maxSeq ||
80
+ stats.uncompressedBytes !== expected.uncompressedBytes
81
+ ) {
82
+ await deleteObject(uploadKey).catch(() => undefined);
83
+ throw new Error(
84
+ `Compaction integrity check failed for stream ${stream.id}: streamed ${stats.chunkCount} chunks / ${stats.uncompressedBytes} bytes up to seq ${stats.lastSeq}, table holds ${expected.count} / ${expected.uncompressedBytes} bytes up to seq ${expected.maxSeq}`,
85
+ );
86
+ }
87
+
88
+ const {updated} = await db().transaction((tx) =>
89
+ dependencies.setObjectKeyAndDeleteChunks(tx, {
90
+ streamId: stream.id,
91
+ objectKey: uploadKey,
92
+ }),
93
+ );
94
+ if (!updated) {
95
+ await deleteObject(uploadKey);
96
+ const current = await getAttemptStreamById(stream.id);
97
+ return {outcome: current ? 'superseded' : 'retention-raced'};
98
+ }
99
+
100
+ return {
101
+ outcome: 'compacted',
102
+ objectKey: uploadKey,
103
+ chunkCount: stats.chunkCount,
104
+ uncompressedBytes: stats.uncompressedBytes,
105
+ };
106
+ }
107
+
108
+ export function createCompactStreamActivity(
109
+ dependencies: CompactStreamDependencies = defaultDependencies,
110
+ ): (params: {streamId: string}) => Promise<CompactStreamResult> {
111
+ return async (params) => {
112
+ let outcome: CompactionMetricOutcome = 'failed';
113
+ try {
114
+ const result = await compactStream(params, dependencies);
115
+ outcome = result.outcome;
116
+ return result;
117
+ } finally {
118
+ compactionCount.add(1, {outcome});
119
+ }
120
+ };
121
+ }
122
+
123
+ export const compactStreamActivity = createCompactStreamActivity();
@@ -0,0 +1,143 @@
1
+ import {eq, sql} from 'drizzle-orm';
2
+ import {db} from '#db/db.js';
3
+ import {attemptStreams} from '#db/schema/attempt-streams.js';
4
+ import {LOGS_COMPACTION_TASK_QUEUE} from '#temporal/constants.js';
5
+ import {arrangeClosedStream, type ClosedStreamIdentity} from '#test/fixtures/closed-stream.js';
6
+ import {ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
7
+ import {compactionReconcileActivity} from './compaction-reconcile.js';
8
+
9
+ const startMock = vi.fn();
10
+
11
+ vi.mock('@shipfox/node-temporal', () => ({
12
+ temporalClient: () => ({
13
+ workflow: {
14
+ start: startMock,
15
+ },
16
+ }),
17
+ }));
18
+
19
+ function newIdentity(): ClosedStreamIdentity {
20
+ return {
21
+ jobId: crypto.randomUUID(),
22
+ stepId: crypto.randomUUID(),
23
+ attempt: 1,
24
+ workspaceId: crypto.randomUUID(),
25
+ projectId: crypto.randomUUID(),
26
+ workflowRunAttemptId: crypto.randomUUID(),
27
+ };
28
+ }
29
+
30
+ async function backdateClosedAt(streamId: string): Promise<void> {
31
+ await db()
32
+ .update(attemptStreams)
33
+ .set({closedAt: sql`now() - interval '1 hour'`})
34
+ .where(eq(attemptStreams.id, streamId));
35
+ }
36
+
37
+ async function markCompacted(streamId: string): Promise<void> {
38
+ await db()
39
+ .update(attemptStreams)
40
+ .set({objectKey: `logs/test/${streamId}`})
41
+ .where(eq(attemptStreams.id, streamId));
42
+ }
43
+
44
+ function alreadyStartedError(): Error {
45
+ const error = new Error('Workflow execution already started');
46
+ error.name = 'WorkflowExecutionAlreadyStartedError';
47
+ return error;
48
+ }
49
+
50
+ describe('compactionReconcileActivity', () => {
51
+ beforeEach(() => {
52
+ startMock.mockReset();
53
+ startMock.mockResolvedValue({});
54
+ });
55
+
56
+ it('re-starts compaction for a closed, uncompacted stream past the stale window', async () => {
57
+ const stream = await arrangeClosedStream(newIdentity(), {
58
+ chunks: [ndjsonBody(outputLine('x\n'))],
59
+ });
60
+ await backdateClosedAt(stream.id);
61
+
62
+ await compactionReconcileActivity();
63
+
64
+ expect(startMock).toHaveBeenCalledWith('compactStream', {
65
+ taskQueue: LOGS_COMPACTION_TASK_QUEUE,
66
+ workflowId: `logs-compact:${stream.id}`,
67
+ args: [{streamId: stream.id}],
68
+ });
69
+ });
70
+
71
+ it('does not re-start a stream closed too recently to be stale', async () => {
72
+ const stream = await arrangeClosedStream(newIdentity(), {
73
+ chunks: [ndjsonBody(outputLine('x\n'))],
74
+ });
75
+
76
+ await compactionReconcileActivity();
77
+
78
+ expect(startMock).not.toHaveBeenCalledWith(
79
+ 'compactStream',
80
+ expect.objectContaining({workflowId: `logs-compact:${stream.id}`}),
81
+ );
82
+ });
83
+
84
+ it('does not re-start an already-compacted stream', async () => {
85
+ const stream = await arrangeClosedStream(newIdentity(), {
86
+ chunks: [ndjsonBody(outputLine('x\n'))],
87
+ });
88
+ await backdateClosedAt(stream.id);
89
+ await markCompacted(stream.id);
90
+
91
+ await compactionReconcileActivity();
92
+
93
+ expect(startMock).not.toHaveBeenCalledWith(
94
+ 'compactStream',
95
+ expect.objectContaining({workflowId: `logs-compact:${stream.id}`}),
96
+ );
97
+ });
98
+
99
+ it('swallows an already-started workflow so a still-running compaction is left alone', async () => {
100
+ const stream = await arrangeClosedStream(newIdentity(), {
101
+ chunks: [ndjsonBody(outputLine('x\n'))],
102
+ });
103
+ await backdateClosedAt(stream.id);
104
+ startMock.mockRejectedValue(alreadyStartedError());
105
+
106
+ await expect(compactionReconcileActivity()).resolves.toEqual(
107
+ expect.objectContaining({restarted: expect.any(Number)}),
108
+ );
109
+ });
110
+
111
+ it('logs and skips one stream whose start fails, still re-driving the rest of the batch', async () => {
112
+ const poison = await arrangeClosedStream(newIdentity(), {
113
+ chunks: [ndjsonBody(outputLine('a\n'))],
114
+ });
115
+ const healthy = await arrangeClosedStream(newIdentity(), {
116
+ chunks: [ndjsonBody(outputLine('b\n'))],
117
+ });
118
+ // Poison sorts first (oldest closed_at); if the loop aborted on its failure, healthy would
119
+ // never be attempted, which is exactly the regression this guards.
120
+ await db()
121
+ .update(attemptStreams)
122
+ .set({closedAt: sql`now() - interval '2 hours'`})
123
+ .where(eq(attemptStreams.id, poison.id));
124
+ await backdateClosedAt(healthy.id);
125
+ startMock.mockImplementation((_name, opts) =>
126
+ opts.workflowId === `logs-compact:${poison.id}`
127
+ ? Promise.reject(new Error('temporal namespace rate-limited'))
128
+ : Promise.resolve({}),
129
+ );
130
+
131
+ const result = await compactionReconcileActivity();
132
+
133
+ expect(startMock).toHaveBeenCalledWith(
134
+ 'compactStream',
135
+ expect.objectContaining({workflowId: `logs-compact:${poison.id}`}),
136
+ );
137
+ expect(startMock).toHaveBeenCalledWith(
138
+ 'compactStream',
139
+ expect.objectContaining({workflowId: `logs-compact:${healthy.id}`}),
140
+ );
141
+ expect(result.failed).toBeGreaterThanOrEqual(1);
142
+ });
143
+ });