@sixb/pg 0.1.1 → 0.1.3

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 (112) hide show
  1. package/README.md +7 -6
  2. package/dist/agents/agent-storage.d.ts +7 -4
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/checkpoints.d.ts +12 -0
  5. package/dist/agents/checkpoints.d.ts.map +1 -0
  6. package/dist/agents/index.d.ts +1 -0
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/messages.d.ts +2 -1
  9. package/dist/agents/messages.d.ts.map +1 -1
  10. package/dist/agents/rows.d.ts +22 -12
  11. package/dist/agents/rows.d.ts.map +1 -1
  12. package/dist/agents/runs.d.ts +3 -2
  13. package/dist/agents/runs.d.ts.map +1 -1
  14. package/dist/auth-storage/service-accounts.d.ts.map +1 -1
  15. package/dist/auth-storage/shared.d.ts +0 -4
  16. package/dist/auth-storage/shared.d.ts.map +1 -1
  17. package/dist/connector-connection-storage.d.ts +9 -0
  18. package/dist/connector-connection-storage.d.ts.map +1 -0
  19. package/dist/index.d.ts +9 -5
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5010 -1837
  22. package/dist/index.js.map +31 -26
  23. package/dist/latest-run-query.d.ts.map +1 -1
  24. package/dist/migrations.d.ts.map +1 -1
  25. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  26. package/dist/ontology-storage/materialization-state.d.ts +0 -2
  27. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  28. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  29. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  30. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  31. package/dist/ontology-storage/shared.d.ts +3 -6
  32. package/dist/ontology-storage/shared.d.ts.map +1 -1
  33. package/dist/pg-action-run-storage.d.ts +3 -2
  34. package/dist/pg-action-run-storage.d.ts.map +1 -1
  35. package/dist/pg-ai-cost-storage.d.ts +12 -0
  36. package/dist/pg-ai-cost-storage.d.ts.map +1 -0
  37. package/dist/pg-ai-usage-storage.d.ts +16 -0
  38. package/dist/pg-ai-usage-storage.d.ts.map +1 -0
  39. package/dist/pg-execution-storage.d.ts +14 -0
  40. package/dist/pg-execution-storage.d.ts.map +1 -0
  41. package/dist/pg-pipeline-run-storage.d.ts +5 -2
  42. package/dist/pg-pipeline-run-storage.d.ts.map +1 -1
  43. package/dist/pg-projection-run-storage.d.ts +5 -2
  44. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  45. package/dist/pg-sync-run-storage.d.ts +5 -2
  46. package/dist/pg-sync-run-storage.d.ts.map +1 -1
  47. package/dist/pg-webhook-run-storage.d.ts +5 -2
  48. package/dist/pg-webhook-run-storage.d.ts.map +1 -1
  49. package/dist/pg-workflow-run-storage.d.ts +5 -3
  50. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  51. package/dist/run-list-query.d.ts +1 -1
  52. package/dist/run-list-query.d.ts.map +1 -1
  53. package/dist/testing.d.ts +6 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/package.json +3 -3
  56. package/src/agents/agent-storage.ts +9 -5
  57. package/src/agents/checkpoints.ts +175 -0
  58. package/src/agents/index.ts +1 -0
  59. package/src/agents/messages.ts +71 -13
  60. package/src/agents/rows.ts +57 -43
  61. package/src/agents/runs.ts +29 -15
  62. package/src/auth-storage/service-accounts.ts +14 -15
  63. package/src/auth-storage/shared.ts +0 -8
  64. package/src/connector-connection-storage.ts +760 -0
  65. package/src/index.ts +38 -15
  66. package/src/latest-run-query.ts +7 -1
  67. package/src/migrations/004-executions.sql +140 -0
  68. package/src/migrations/005-workflow-executions.sql +10 -0
  69. package/src/migrations/006-narrow-ontology-source-root-index.sql +4 -0
  70. package/src/migrations/007-split-overrides.sql +153 -0
  71. package/src/migrations/008-action-executions.sql +7 -0
  72. package/src/migrations/009-agent-executions.sql +21 -0
  73. package/src/migrations/010-ai-usage-accounting-foundation.sql +76 -0
  74. package/src/migrations/011-sync-failure-record.sql +23 -0
  75. package/src/migrations/012-pipeline-failure-record.sql +47 -0
  76. package/src/migrations/013-workflow-failure-record.sql +45 -0
  77. package/src/migrations/014-agent-failure-record.sql +56 -0
  78. package/src/migrations/015-projection-failure-record.sql +20 -0
  79. package/src/migrations/016-webhook-run-failure-record.sql +19 -0
  80. package/src/migrations/017-action-failure-record.sql +65 -0
  81. package/src/migrations/018-ontology-outbox-failure-record.sql +29 -0
  82. package/src/migrations/019-webhook-delivery-failure-record.sql +27 -0
  83. package/src/migrations/020-drop-run-usage-projections.sql +8 -0
  84. package/src/migrations/021-sync-pipeline-executions.sql +50 -0
  85. package/src/migrations/022-projection-executions.sql +115 -0
  86. package/src/migrations/023-webhook-executions.sql +30 -0
  87. package/src/migrations/024-ontology-commit-executions.sql +12 -0
  88. package/src/migrations/025-connector-connections.sql +231 -0
  89. package/src/migrations/026-ai-cost-accounting.sql +53 -0
  90. package/src/migrations/027-agent-context-checkpoints.sql +34 -0
  91. package/src/migrations/028-object-override-edit-times.sql +3 -0
  92. package/src/migrations.ts +82 -0
  93. package/src/ontology-storage/materialization-session.ts +25 -28
  94. package/src/ontology-storage/materialization-state.ts +161 -43
  95. package/src/ontology-storage/materialization-writer.ts +149 -76
  96. package/src/ontology-storage/materializations.ts +3 -1
  97. package/src/ontology-storage/outbox.ts +9 -2
  98. package/src/ontology-storage/shared.ts +10 -20
  99. package/src/pg-action-run-storage.ts +43 -67
  100. package/src/pg-ai-cost-storage.ts +707 -0
  101. package/src/pg-ai-usage-storage.ts +290 -0
  102. package/src/pg-execution-storage.ts +147 -0
  103. package/src/pg-pipeline-run-storage.ts +107 -41
  104. package/src/pg-projection-run-storage.ts +159 -91
  105. package/src/pg-sync-run-storage.ts +100 -28
  106. package/src/pg-webhook-run-storage.ts +89 -57
  107. package/src/pg-workflow-run-storage.ts +97 -130
  108. package/src/run-list-query.ts +9 -4
  109. package/src/testing.ts +18 -0
  110. package/dist/pg-webhook-delivery-storage.d.ts +0 -17
  111. package/dist/pg-webhook-delivery-storage.d.ts.map +0 -1
  112. package/src/pg-webhook-delivery-storage.ts +0 -180
@@ -1,5 +1,9 @@
1
+ import type { JsonValue } from "@sixb/core"
2
+ import { parseSixbFailure, serializeSixbFailure } from "@sixb/core/internal/errors"
3
+ import { assertPipelineRunExecution } from "@sixb/core/internal/pipeline-run-storage-provider"
1
4
  import type { DatasetVersionRef } from "@sixb/core/lake-storage"
2
5
  import type {
6
+ ExecutionStorage,
3
7
  FinishPipelineRunInput,
4
8
  FinishPipelineStepRunInput,
5
9
  ListLatestPipelineRunsInput,
@@ -8,14 +12,18 @@ import type {
8
12
  ListPipelineRunsResult,
9
13
  ListPipelineStepRunsInput,
10
14
  ListPipelineStepRunsResult,
11
- PipelineRunFailure,
12
15
  PipelineRunRecord,
13
16
  PipelineRunStorage,
14
17
  PipelineStepRunRecord,
18
+ QueuePipelineRunInput,
15
19
  StartPipelineRunInput,
16
20
  StartPipelineStepRunInput,
17
21
  } from "@sixb/core/storage"
18
- import { PipelineRunError } from "@sixb/core/storage"
22
+ import {
23
+ canRequeuePipelineRunAfterEnqueueFailure,
24
+ PIPELINE_RUN_FAILURE_CODES,
25
+ PipelineRunError,
26
+ } from "@sixb/core/storage"
19
27
  import { queryLatestRunsByOwnerId } from "./latest-run-query"
20
28
  import type { SqlParameter } from "./pg-client"
21
29
  import { appendRunListFilters, hasEmptyStatuses, queryRunList } from "./run-list-query"
@@ -23,23 +31,37 @@ import { isUniqueViolation } from "./storage-errors"
23
31
  import { type PgStoreClient, runPgTransaction } from "./transactions"
24
32
 
25
33
  export class PgPipelineRunStorage implements PipelineRunStorage {
26
- constructor(private readonly sql: PgStoreClient) {}
27
-
28
- async start(input: StartPipelineRunInput): Promise<PipelineRunRecord> {
34
+ constructor(
35
+ private readonly sql: PgStoreClient,
36
+ private readonly executions: ExecutionStorage
37
+ ) {}
38
+
39
+ async queue(input: QueuePipelineRunInput): Promise<PipelineRunRecord> {
40
+ await assertPipelineRunExecution({
41
+ executions: this.executions,
42
+ projectId: input.projectId,
43
+ executionId: input.executionId,
44
+ runId: input.id,
45
+ pipelineId: input.pipelineId,
46
+ })
29
47
  try {
30
48
  const [row] = await this.sql<PipelineRunDatabaseRow[]>`
31
49
  INSERT INTO pipeline_runs (
32
50
  project_id,
33
51
  id,
52
+ execution_id,
34
53
  pipeline_id,
35
54
  status,
55
+ queued_at,
36
56
  started_at
37
57
  ) VALUES (
38
58
  ${input.projectId},
39
59
  ${input.id},
60
+ ${input.executionId},
40
61
  ${input.pipelineId},
41
- ${"running"},
42
- ${input.startedAt ?? new Date()}
62
+ ${"queued"},
63
+ ${input.queuedAt ?? new Date()},
64
+ ${null}
43
65
  )
44
66
  RETURNING *
45
67
  `
@@ -47,13 +69,62 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
47
69
  return rowToPipelineRunRecord(row)
48
70
  } catch (error) {
49
71
  if (isUniqueViolation(error)) {
72
+ return this.requeueAfterEnqueueFailure(input)
73
+ }
74
+
75
+ throw error
76
+ }
77
+ }
78
+
79
+ private async requeueAfterEnqueueFailure(
80
+ input: QueuePipelineRunInput
81
+ ): Promise<PipelineRunRecord> {
82
+ return runPgTransaction(this.sql, async (tx) => {
83
+ const [existing] = await tx<PipelineRunDatabaseRow[]>`
84
+ SELECT * FROM pipeline_runs
85
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
86
+ FOR UPDATE
87
+ `
88
+ if (
89
+ !existing ||
90
+ !canRequeuePipelineRunAfterEnqueueFailure(rowToPipelineRunRecord(existing), input)
91
+ ) {
50
92
  throw new PipelineRunError(
51
93
  `[SixbPg] Pipeline run '${input.id}' already exists for project '${input.projectId}'.`
52
94
  )
53
95
  }
54
96
 
55
- throw error
97
+ const [updated] = await tx<PipelineRunDatabaseRow[]>`
98
+ UPDATE pipeline_runs
99
+ SET status = ${"queued"}, queued_at = ${input.queuedAt ?? new Date()},
100
+ started_at = ${null}, finished_at = ${null}, output_dataset_id = ${null},
101
+ output_version_id = ${null}, error = ${null}
102
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
103
+ RETURNING *
104
+ `
105
+ return rowToPipelineRunRecord(updated)
106
+ })
107
+ }
108
+
109
+ async start(input: StartPipelineRunInput): Promise<PipelineRunRecord> {
110
+ const [updated] = await this.sql<PipelineRunDatabaseRow[]>`
111
+ UPDATE pipeline_runs
112
+ SET status = ${"running"}, started_at = ${input.startedAt ?? new Date()},
113
+ error = ${null}
114
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = ${"queued"}
115
+ RETURNING *
116
+ `
117
+ if (updated) return rowToPipelineRunRecord(updated)
118
+
119
+ const existing = await this.getById({ projectId: input.projectId, id: input.id })
120
+ if (!existing) {
121
+ throw new PipelineRunError(
122
+ `[SixbPg] Pipeline run '${input.id}' not found for project '${input.projectId}'.`
123
+ )
56
124
  }
125
+ throw new PipelineRunError(
126
+ `[SixbPg] Pipeline run '${input.id}' cannot start from status '${existing.status}'.`
127
+ )
57
128
  }
58
129
 
59
130
  async finish(input: FinishPipelineRunInput): Promise<PipelineRunRecord> {
@@ -69,9 +140,14 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
69
140
  )
70
141
  }
71
142
 
72
- if (existing.status !== "running") {
143
+ const enqueueFailure =
144
+ input.status === "failed" && input.error.code === "queue.enqueue_failed"
145
+ if (
146
+ (enqueueFailure && existing.status !== "queued") ||
147
+ (!enqueueFailure && existing.status !== "running")
148
+ ) {
73
149
  throw new PipelineRunError(
74
- `[SixbPg] Pipeline run '${input.id}' for project '${input.projectId}' is already terminal.`
150
+ `[SixbPg] Pipeline run '${input.id}' cannot finish from status '${existing.status}'.`
75
151
  )
76
152
  }
77
153
 
@@ -84,8 +160,7 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
84
160
  finished_at = ${input.finishedAt ?? new Date()},
85
161
  output_dataset_id = ${input.output?.datasetId ?? null},
86
162
  output_version_id = ${input.output?.versionId ?? null},
87
- error_name = ${null},
88
- error_message = ${null}
163
+ error = ${null}
89
164
  WHERE project_id = ${input.projectId} AND id = ${input.id}
90
165
  RETURNING *
91
166
  `
@@ -96,8 +171,7 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
96
171
  finished_at = ${input.finishedAt ?? new Date()},
97
172
  output_dataset_id = ${null},
98
173
  output_version_id = ${null},
99
- error_name = ${input.error?.name ?? null},
100
- error_message = ${input.error?.message ?? null}
174
+ error = ${input.error === undefined ? null : serializeSixbFailure(input.error, PIPELINE_RUN_FAILURE_CODES)}::text::jsonb
101
175
  WHERE project_id = ${input.projectId} AND id = ${input.id}
102
176
  RETURNING *
103
177
  `
@@ -208,8 +282,7 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
208
282
  finished_at = ${input.finishedAt ?? new Date()},
209
283
  output_version_id = ${input.output.versionId},
210
284
  rows_written = ${input.rowsWritten ?? existing.rows_written ?? null},
211
- error_name = ${null},
212
- error_message = ${null}
285
+ error = ${null}
213
286
  WHERE project_id = ${input.projectId} AND id = ${input.id}
214
287
  RETURNING *
215
288
  `
@@ -220,8 +293,7 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
220
293
  finished_at = ${input.finishedAt ?? new Date()},
221
294
  output_version_id = ${null},
222
295
  rows_written = ${input.rowsWritten ?? existing.rows_written ?? null},
223
- error_name = ${input.error?.name ?? null},
224
- error_message = ${input.error?.message ?? null}
296
+ error = ${input.error === undefined ? null : serializeSixbFailure(input.error, PIPELINE_RUN_FAILURE_CODES)}::text::jsonb
225
297
  WHERE project_id = ${input.projectId} AND id = ${input.id}
226
298
  RETURNING *
227
299
  `
@@ -263,7 +335,13 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
263
335
  params.push(...input.pipelineIds)
264
336
  }
265
337
 
266
- index = appendRunListFilters(whereClauses, params, index, input)
338
+ index = appendRunListFilters(
339
+ whereClauses,
340
+ params,
341
+ index,
342
+ input,
343
+ "COALESCE(started_at, queued_at)"
344
+ )
267
345
 
268
346
  const { rows, total, hasMore } = await queryRunList<PipelineRunDatabaseRow>({
269
347
  sql: this.sql,
@@ -356,27 +434,15 @@ export class PgPipelineRunStorage implements PipelineRunStorage {
356
434
  }
357
435
  }
358
436
 
359
- function toFailure(row: {
360
- readonly error_name: string | null
361
- readonly error_message: string | null
362
- }): PipelineRunFailure | undefined {
363
- if (!row.error_message) {
364
- return undefined
365
- }
366
-
367
- return {
368
- name: row.error_name ?? undefined,
369
- message: row.error_message,
370
- }
371
- }
372
-
373
437
  function rowToPipelineRunRecord(row: PipelineRunDatabaseRow): PipelineRunRecord {
374
438
  return {
375
439
  id: row.id,
376
440
  projectId: row.project_id,
441
+ executionId: row.execution_id,
377
442
  pipelineId: row.pipeline_id,
378
443
  status: row.status,
379
- startedAt: new Date(row.started_at),
444
+ queuedAt: new Date(row.queued_at),
445
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
380
446
  finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
381
447
  output:
382
448
  row.output_dataset_id && row.output_version_id
@@ -385,7 +451,7 @@ function rowToPipelineRunRecord(row: PipelineRunDatabaseRow): PipelineRunRecord
385
451
  versionId: row.output_version_id,
386
452
  }
387
453
  : undefined,
388
- error: toFailure(row),
454
+ error: row.error === null ? undefined : parseSixbFailure(row.error, PIPELINE_RUN_FAILURE_CODES),
389
455
  }
390
456
  }
391
457
 
@@ -409,7 +475,7 @@ function rowToPipelineStepRunRecord(row: PipelineStepRunDatabaseRow): PipelineSt
409
475
  }
410
476
  : undefined,
411
477
  rowsWritten: row.rows_written != null ? Number(row.rows_written) : undefined,
412
- error: toFailure(row),
478
+ error: row.error === null ? undefined : parseSixbFailure(row.error, PIPELINE_RUN_FAILURE_CODES),
413
479
  }
414
480
  }
415
481
 
@@ -428,14 +494,15 @@ function assertOptionalNonNegativeInteger(value: number | undefined, fieldName:
428
494
  interface PipelineRunDatabaseRow {
429
495
  project_id: string
430
496
  id: string
497
+ execution_id: string
431
498
  pipeline_id: string
432
499
  status: PipelineRunRecord["status"]
433
- started_at: Date | string
500
+ queued_at: Date | string
501
+ started_at: Date | string | null
434
502
  finished_at: Date | string | null
435
503
  output_dataset_id: string | null
436
504
  output_version_id: string | null
437
- error_name: string | null
438
- error_message: string | null
505
+ error: JsonValue | null
439
506
  }
440
507
 
441
508
  interface PipelineStepRunDatabaseRow {
@@ -452,6 +519,5 @@ interface PipelineStepRunDatabaseRow {
452
519
  inputs: readonly DatasetVersionRef[] | string
453
520
  output_version_id: string | null
454
521
  rows_written: number | string | null
455
- error_name: string | null
456
- error_message: string | null
522
+ error: JsonValue | null
457
523
  }
@@ -1,14 +1,21 @@
1
1
  import { randomUUID } from "node:crypto"
2
+ import type { JsonValue } from "@sixb/core"
3
+ import { serializeSixbFailure } from "@sixb/core/internal/errors"
2
4
  import {
3
5
  advanceProjectionTelemetry,
4
6
  assertGenericProgressDoesNotAdvanceTelemetry,
5
7
  assertProjectionMissingTarget,
6
- assertProjectionRunExecution,
8
+ assertProjectionRunAttempt,
9
+ assertProjectionRunDurableExecution,
7
10
  assertProjectionRunListWindow,
8
11
  assertProjectionRunNonEmpty,
12
+ assertProjectionRunQueueInput,
9
13
  assertProjectionRunRunning,
10
14
  assertProjectionRunStartInput,
15
+ canRequeueProjectionRunAfterEnqueueFailure,
11
16
  createProjectionRunClaim,
17
+ createProjectionRunRecord,
18
+ failProjectionRunEnqueue,
12
19
  immutableDatasetVersionConflict,
13
20
  mergeProjectionRunProgress,
14
21
  type PersistedProjectionRunRecord,
@@ -16,7 +23,6 @@ import {
16
23
  planProjectionRunReclaim,
17
24
  projectionRunNotFound,
18
25
  publicProjectionRunRecord,
19
- requireProjectionRunExecutionToken,
20
26
  requireTelemetryProjectionRun,
21
27
  restoreProjectionRun,
22
28
  type StoredProjectionRunRecord,
@@ -24,6 +30,8 @@ import {
24
30
  } from "@sixb/core/internal/projection-run-storage-provider"
25
31
  import type {
26
32
  AdvanceProjectionTelemetryCheckpointInput,
33
+ ExecutionStorage,
34
+ FailProjectionRunEnqueueInput,
27
35
  FinishProjectionRunInput,
28
36
  ListLatestProjectionRunsInput,
29
37
  ListLatestProjectionRunsResult,
@@ -35,21 +43,34 @@ import type {
35
43
  ProjectionRunRecord,
36
44
  ProjectionRunStatus,
37
45
  ProjectionRunStorage,
46
+ QueueProjectionRunInput,
38
47
  RecordProjectionMissingTargetInput,
39
48
  StartOrReclaimProjectionRunInput,
40
49
  TelemetryProjectionRunRecord,
41
50
  UpdateProjectionRunInput,
42
51
  } from "@sixb/core/storage"
43
- import { ProjectionRunError } from "@sixb/core/storage"
52
+ import { PROJECTION_RUN_FAILURE_CODES, ProjectionRunError } from "@sixb/core/storage"
44
53
  import { queryLatestRunsByOwnerId } from "./latest-run-query"
45
54
  import type { SQLClient, SqlParameter } from "./pg-client"
46
55
  import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "./transactions"
47
56
 
48
57
  export class PgProjectionRunStorage implements ProjectionRunStorage {
49
- constructor(private readonly sql: PgStoreClient) {}
50
-
51
- async startOrReclaim(input: StartOrReclaimProjectionRunInput): Promise<ProjectionRunClaim> {
52
- assertProjectionRunStartInput(input)
58
+ constructor(
59
+ private readonly sql: PgStoreClient,
60
+ private readonly executions: ExecutionStorage
61
+ ) {}
62
+
63
+ async queue(input: QueueProjectionRunInput): Promise<ProjectionRunRecord> {
64
+ assertProjectionRunQueueInput(input)
65
+ await assertProjectionRunDurableExecution({
66
+ executions: this.executions,
67
+ projectId: input.projectId,
68
+ executionId: input.executionId,
69
+ runId: input.id,
70
+ projectionId: input.identity.projectionId,
71
+ datasetId: input.identity.datasetVersion.datasetId,
72
+ datasetVersionId: input.identity.datasetVersion.versionId,
73
+ })
53
74
 
54
75
  return runPgTransaction(this.sql, async (tx) => {
55
76
  await lockAdvisoryKeys(tx, [
@@ -60,98 +81,119 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
60
81
  input.identity.datasetVersion.versionId
61
82
  ),
62
83
  ])
63
- const [metadataConflict] = await tx<{ readonly id: string }[]>`
64
- SELECT id FROM projection_runs
65
- WHERE project_id = ${input.projectId}
66
- AND id <> ${input.id}
67
- AND dataset_id = ${input.identity.datasetVersion.datasetId}
68
- AND dataset_version_id = ${input.identity.datasetVersion.versionId}
69
- AND dataset_version_created_at IS NOT NULL
70
- AND dataset_version_created_at <> ${input.identity.datasetVersion.createdAt}
71
- LIMIT 1
72
- `
73
- if (metadataConflict) {
74
- throw immutableDatasetVersionConflict(input.identity)
75
- }
76
-
84
+ await assertDatasetVersionIsImmutable(tx, input)
77
85
  const [existingRow] = await tx<DatabaseRow[]>`
78
86
  SELECT * FROM projection_runs
79
87
  WHERE project_id = ${input.projectId} AND id = ${input.id}
80
88
  FOR UPDATE
81
89
  `
82
-
83
- const existing = existingRow ? restoreProjectionRunRow(existingRow) : undefined
84
- const executionToken = createFreshExecutionToken(existing?.executionToken)
85
-
86
- if (existingRow && existing) {
87
- const { attempt } = planProjectionRunReclaim(existing, input)
88
- const previousExecutionToken = requireProjectionRunExecutionToken(existing)
89
-
90
- const [updated] = await tx<DatabaseRow[]>`
90
+ if (existingRow) {
91
+ if (
92
+ !canRequeueProjectionRunAfterEnqueueFailure(rowToProjectionRunRecord(existingRow), input)
93
+ ) {
94
+ throw duplicateProjectionRun(input)
95
+ }
96
+ const [requeued] = await tx<DatabaseRow[]>`
91
97
  UPDATE projection_runs
92
- SET attempt = ${attempt}, execution_token = ${executionToken}
93
- WHERE project_id = ${input.projectId}
94
- AND id = ${input.id}
95
- AND status = ${"running"}
96
- AND execution_token = ${previousExecutionToken}
98
+ SET status = ${"queued"}, queued_at = ${input.queuedAt ?? new Date()},
99
+ started_at = ${null}, finished_at = ${null}, attempt = ${0}, execution_token = ${null},
100
+ next_batch_ordinal = ${input.fixedBatchSize === undefined ? null : 0},
101
+ next_row_offset = ${input.fixedBatchSize === undefined ? null : 0},
102
+ input_exhausted = ${input.fixedBatchSize === undefined ? null : false},
103
+ missing_target_object_type_id = ${null}, missing_target_object_id = ${null},
104
+ missing_target_batch_ordinal = ${null}, missing_target_first_seen_at = ${null},
105
+ source_rows_read = ${0}, source_rows_skipped = ${0}, error = ${null}
106
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
107
+ AND status = ${"failed"} AND error->>'code' = ${"queue.enqueue_failed"}
97
108
  RETURNING *
98
109
  `
99
- if (!updated) throw staleProjectionRunExecution(input.id)
100
- return projectionRunClaim(updated)
110
+ if (!requeued) throw duplicateProjectionRun(input)
111
+ return rowToProjectionRunRecord(requeued)
101
112
  }
102
113
 
114
+ const record = createProjectionRunRecord(input)
115
+ const checkpoint = record.telemetryCheckpoint
103
116
  const [inserted] = await tx<DatabaseRow[]>`
104
117
  INSERT INTO projection_runs (
105
- project_id,
106
- id,
107
- projection_id,
108
- projection_kind,
109
- dataset_id,
110
- dataset_version_id,
111
- object_type_id,
112
- source_object_type_id,
113
- target_object_type_id,
114
- status,
115
- started_at,
116
- attempt,
117
- execution_token,
118
- materialization_protocol,
119
- dataset_version_created_at,
120
- ontology_revision,
121
- projection_revision,
122
- ownership_hash,
123
- fixed_batch_size,
124
- next_batch_ordinal,
125
- next_row_offset,
126
- input_exhausted
118
+ project_id, id, execution_id, projection_id, projection_kind,
119
+ materialization_protocol, dataset_id, dataset_version_id,
120
+ dataset_version_created_at, ontology_revision, projection_revision, ownership_hash,
121
+ object_type_id, source_object_type_id, target_object_type_id, status, queued_at,
122
+ started_at, finished_at, attempt, execution_token, fixed_batch_size,
123
+ next_batch_ordinal, next_row_offset, input_exhausted, source_rows_read,
124
+ source_rows_skipped
127
125
  ) VALUES (
128
- ${input.projectId},
129
- ${input.id},
130
- ${input.identity.projectionId},
131
- ${input.identity.projectionKind},
132
- ${input.identity.datasetVersion.datasetId},
133
- ${input.identity.datasetVersion.versionId},
134
- ${"objectTypeId" in input.target ? input.target.objectTypeId : null},
135
- ${"sourceObjectTypeId" in input.target ? input.target.sourceObjectTypeId : null},
136
- ${"sourceObjectTypeId" in input.target ? input.target.targetObjectTypeId : null},
137
- ${"running"},
138
- ${input.startedAt ?? new Date()},
139
- ${1},
140
- ${executionToken},
141
- ${input.identity.protocol},
142
- ${input.identity.datasetVersion.createdAt},
143
- ${input.identity.ontologyRevision},
144
- ${input.identity.projectionRevision},
145
- ${input.identity.ownershipHash},
146
- ${input.fixedBatchSize ?? null},
147
- ${input.fixedBatchSize === undefined ? null : 0},
148
- ${input.fixedBatchSize === undefined ? null : 0},
149
- ${input.fixedBatchSize === undefined ? null : false}
126
+ ${record.projectId}, ${record.id}, ${record.executionId},
127
+ ${record.identity.projectionId}, ${record.identity.projectionKind},
128
+ ${record.identity.protocol}, ${record.identity.datasetVersion.datasetId},
129
+ ${record.identity.datasetVersion.versionId}, ${record.identity.datasetVersion.createdAt},
130
+ ${record.identity.ontologyRevision}, ${record.identity.projectionRevision},
131
+ ${record.identity.ownershipHash},
132
+ ${"objectTypeId" in record.target ? record.target.objectTypeId : null},
133
+ ${"sourceObjectTypeId" in record.target ? record.target.sourceObjectTypeId : null},
134
+ ${"sourceObjectTypeId" in record.target ? record.target.targetObjectTypeId : null},
135
+ ${"queued"}, ${record.queuedAt}, ${null}, ${null}, ${0}, ${null},
136
+ ${checkpoint?.fixedBatchSize ?? null}, ${checkpoint?.nextBatchOrdinal ?? null},
137
+ ${checkpoint?.nextRowOffset ?? null}, ${checkpoint?.inputExhausted ?? null}, ${0}, ${0}
150
138
  )
151
139
  RETURNING *
152
140
  `
141
+ return rowToProjectionRunRecord(inserted)
142
+ })
143
+ }
144
+
145
+ async startOrReclaim(input: StartOrReclaimProjectionRunInput): Promise<ProjectionRunClaim> {
146
+ assertProjectionRunStartInput(input)
153
147
 
154
- return projectionRunClaim(inserted)
148
+ return runPgTransaction(this.sql, async (tx) => {
149
+ await lockAdvisoryKeys(tx, [projectionRunLockKey(input.projectId, input.id)])
150
+ const [existingRow] = await tx<DatabaseRow[]>`
151
+ SELECT * FROM projection_runs
152
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
153
+ FOR UPDATE
154
+ `
155
+ if (!existingRow) throw projectionRunNotFound(input.projectId, input.id)
156
+ const existing = restoreProjectionRunRow(existingRow)
157
+ const { attempt } = planProjectionRunReclaim(existing, input)
158
+ const executionToken = createFreshExecutionToken(existing.executionToken)
159
+ const [updated] = await tx<DatabaseRow[]>`
160
+ UPDATE projection_runs
161
+ SET status = ${"running"}, started_at = COALESCE(started_at, ${input.startedAt ?? new Date()}),
162
+ finished_at = ${null}, attempt = ${attempt}, execution_token = ${executionToken},
163
+ error = ${null}
164
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
165
+ AND (
166
+ (status = ${"queued"} AND execution_token IS NULL)
167
+ OR (status = ${"running"} AND execution_token = ${existing.executionToken ?? null})
168
+ )
169
+ RETURNING *
170
+ `
171
+ if (!updated) throw staleProjectionRunExecution(input.id)
172
+ return projectionRunClaim(updated)
173
+ })
174
+ }
175
+
176
+ async failEnqueue(input: FailProjectionRunEnqueueInput): Promise<ProjectionRunRecord> {
177
+ return runPgTransaction(this.sql, async (tx) => {
178
+ const [existingRow] = await tx<DatabaseRow[]>`
179
+ SELECT * FROM projection_runs
180
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
181
+ FOR UPDATE
182
+ `
183
+ if (!existingRow) throw projectionRunNotFound(input.projectId, input.id)
184
+ const failed = failProjectionRunEnqueue(restoreProjectionRunRow(existingRow), input)
185
+ if (!failed.error) {
186
+ throw new ProjectionRunError(`[SixbPg] Projection run '${input.id}' has no failure.`)
187
+ }
188
+ const [updated] = await tx<DatabaseRow[]>`
189
+ UPDATE projection_runs
190
+ SET status = ${"failed"}, finished_at = ${failed.finishedAt ?? new Date()},
191
+ error = ${serializeSixbFailure(failed.error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
192
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = ${"queued"}
193
+ RETURNING *
194
+ `
195
+ if (!updated) throw duplicateProjectionRun(input)
196
+ return rowToProjectionRunRecord(updated)
155
197
  })
156
198
  }
157
199
 
@@ -200,7 +242,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
200
242
  source_rows_read = ${plan.progress.sourceRowsRead},
201
243
  source_rows_skipped = ${plan.progress.sourceRowsSkipped},
202
244
  input_exhausted = ${plan.inputExhausted ?? existingRow.input_exhausted},
203
- error_message = ${plan.errorMessage ?? null}
245
+ error = ${plan.error === undefined ? null : serializeSixbFailure(plan.error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
204
246
  WHERE project_id = ${input.projectId}
205
247
  AND id = ${input.id}
206
248
  AND status = ${"running"}
@@ -328,11 +370,11 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
328
370
  params.push(...input.statuses)
329
371
  }
330
372
  if (input.startedAfter) {
331
- whereClauses.push(`started_at >= $${index++}`)
373
+ whereClauses.push(`COALESCE(started_at, queued_at) >= $${index++}`)
332
374
  params.push(input.startedAfter)
333
375
  }
334
376
  if (input.startedBefore) {
335
- whereClauses.push(`started_at <= $${index++}`)
377
+ whereClauses.push(`COALESCE(started_at, queued_at) <= $${index++}`)
336
378
  params.push(input.startedBefore)
337
379
  }
338
380
 
@@ -351,7 +393,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
351
393
  let query = `
352
394
  SELECT * FROM projection_runs
353
395
  ${where}
354
- ORDER BY started_at ${order}, id ${order}
396
+ ORDER BY COALESCE(started_at, queued_at) ${order}, id ${order}
355
397
  `
356
398
  if (input.limit !== undefined) {
357
399
  query += ` LIMIT $${index++} OFFSET $${index++}`
@@ -388,7 +430,7 @@ async function requireMaterializationExecution(
388
430
  input: LockProjectionRunForMaterializationInput
389
431
  ): Promise<DatabaseRow> {
390
432
  const row = await requireRunning(sql, input.projectId, input.id)
391
- assertProjectionRunExecution(restoreProjectionRunRow(row), input)
433
+ assertProjectionRunAttempt(restoreProjectionRunRow(row), input)
392
434
  return row
393
435
  }
394
436
 
@@ -406,6 +448,22 @@ async function requireRunning(sql: SQLClient, projectId: string, id: string): Pr
406
448
  return row
407
449
  }
408
450
 
451
+ async function assertDatasetVersionIsImmutable(
452
+ sql: SQLClient,
453
+ input: QueueProjectionRunInput
454
+ ): Promise<void> {
455
+ const [conflict] = await sql<{ readonly id: string }[]>`
456
+ SELECT id FROM projection_runs
457
+ WHERE project_id = ${input.projectId}
458
+ AND id <> ${input.id}
459
+ AND dataset_id = ${input.identity.datasetVersion.datasetId}
460
+ AND dataset_version_id = ${input.identity.datasetVersion.versionId}
461
+ AND dataset_version_created_at <> ${input.identity.datasetVersion.createdAt}
462
+ LIMIT 1
463
+ `
464
+ if (conflict) throw immutableDatasetVersionConflict(input.identity)
465
+ }
466
+
409
467
  function createFreshExecutionToken(previous: string | undefined): string {
410
468
  let token = randomUUID()
411
469
  while (token === previous) token = randomUUID()
@@ -436,6 +494,7 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
436
494
  const persisted: PersistedProjectionRunRecord = {
437
495
  id: row.id,
438
496
  projectId: row.project_id,
497
+ executionId: row.execution_id,
439
498
  projectionId: row.projection_id,
440
499
  projectionKind: row.projection_kind,
441
500
  protocol: row.materialization_protocol ?? undefined,
@@ -449,7 +508,8 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
449
508
  sourceObjectTypeId: row.source_object_type_id ?? undefined,
450
509
  targetObjectTypeId: row.target_object_type_id ?? undefined,
451
510
  status: row.status,
452
- startedAt: new Date(row.started_at),
511
+ queuedAt: new Date(row.queued_at),
512
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
453
513
  finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
454
514
  attempt: databaseSafeInteger(row.attempt, "attempt"),
455
515
  executionToken: row.execution_token ?? undefined,
@@ -470,7 +530,7 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
470
530
  sourceRowsRead: databaseSafeInteger(row.source_rows_read, "sourceRowsRead"),
471
531
  sourceRowsSkipped: databaseSafeInteger(row.source_rows_skipped, "sourceRowsSkipped"),
472
532
  },
473
- errorMessage: row.error_message ?? undefined,
533
+ error: row.error ?? undefined,
474
534
  }
475
535
  return restoreProjectionRun(persisted)
476
536
  }
@@ -495,6 +555,7 @@ function databaseSafeInteger(value: number | string | null, fieldName: string):
495
555
  interface DatabaseRow {
496
556
  project_id: string
497
557
  id: string
558
+ execution_id: string
498
559
  projection_id: string
499
560
  projection_kind: ProjectionKind
500
561
  dataset_id: string
@@ -503,7 +564,8 @@ interface DatabaseRow {
503
564
  source_object_type_id: string | null
504
565
  target_object_type_id: string | null
505
566
  status: ProjectionRunStatus
506
- started_at: Date | string
567
+ queued_at: Date | string
568
+ started_at: Date | string | null
507
569
  finished_at: Date | string | null
508
570
  attempt: number | string
509
571
  execution_token: string | null
@@ -522,5 +584,11 @@ interface DatabaseRow {
522
584
  missing_target_first_seen_at: Date | string | null
523
585
  source_rows_read: number | string
524
586
  source_rows_skipped: number | string
525
- error_message: string | null
587
+ error: JsonValue | null
588
+ }
589
+
590
+ function duplicateProjectionRun(input: { readonly projectId: string; readonly id: string }) {
591
+ return new ProjectionRunError(
592
+ `[SixbPg] Projection run '${input.id}' already exists for project '${input.projectId}'.`
593
+ )
526
594
  }