relay-flow 0.2.4-alpha → 0.2.5-alpha
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/README.md +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/config-reference.yaml +2 -2
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
package projection
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
_ "modernc.org/sqlite"
|
|
14
|
+
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// RunProjection is the derived relay_runs read model in the same SQLite
|
|
19
|
+
// database as the engine backend. Durable workflow history is authoritative;
|
|
20
|
+
// this table serves application-level queries only. Updates are idempotent
|
|
21
|
+
// durable activities, so replay repairs interrupted updates.
|
|
22
|
+
type RunProjection struct {
|
|
23
|
+
DB *sql.DB
|
|
24
|
+
runtimeMu sync.Mutex
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func openProjectionDatabase(path string) (*sql.DB, error) {
|
|
28
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
|
|
29
|
+
if err != nil {
|
|
30
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
31
|
+
}
|
|
32
|
+
if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
|
|
33
|
+
db.Close()
|
|
34
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
35
|
+
}
|
|
36
|
+
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
|
37
|
+
db.Close()
|
|
38
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
39
|
+
}
|
|
40
|
+
if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
|
|
41
|
+
db.Close()
|
|
42
|
+
return nil, fmt.Errorf("chmod %s: %w", path, err)
|
|
43
|
+
}
|
|
44
|
+
if err := (&RunProjection{DB: db}).Migrate(); err != nil {
|
|
45
|
+
db.Close()
|
|
46
|
+
return nil, fmt.Errorf("migrate relay projection: %w", err)
|
|
47
|
+
}
|
|
48
|
+
return db, nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// InitDatabase creates a relay projection database at path. It is shared by
|
|
52
|
+
// every durable executor; engine-specific history tables are owned by the
|
|
53
|
+
// selected executor and are not created here.
|
|
54
|
+
func InitDatabase(path string) error {
|
|
55
|
+
db, err := openProjectionDatabase(path)
|
|
56
|
+
if err != nil {
|
|
57
|
+
return err
|
|
58
|
+
}
|
|
59
|
+
return db.Close()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// InitDatabaseWithIdentity initializes the shared relay projection and writes
|
|
63
|
+
// its immutable executor marker before reporting initialization success. The
|
|
64
|
+
// marker write itself is transactional and uses the same open database that
|
|
65
|
+
// created the projection.
|
|
66
|
+
func InitDatabaseWithIdentity(path string, identity ExecutorIdentity) error {
|
|
67
|
+
if err := validateExecutorIdentity(identity); err != nil {
|
|
68
|
+
return err
|
|
69
|
+
}
|
|
70
|
+
db, err := openProjectionDatabase(path)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
defer db.Close()
|
|
75
|
+
return (&RunProjection{DB: db}).InitializeIdentity(context.Background(), identity)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// HasNonterminalRuns checks a valid existing projection without migrating it.
|
|
79
|
+
func HasNonterminalRuns(path string) (bool, error) {
|
|
80
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
|
|
81
|
+
if err != nil {
|
|
82
|
+
return false, fmt.Errorf("open %s: %w", path, err)
|
|
83
|
+
}
|
|
84
|
+
defer db.Close()
|
|
85
|
+
var active bool
|
|
86
|
+
if err := db.QueryRow(`SELECT EXISTS(
|
|
87
|
+
SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
|
|
88
|
+
)`).Scan(&active); err != nil {
|
|
89
|
+
return false, fmt.Errorf("inspect %s: %w", path, err)
|
|
90
|
+
}
|
|
91
|
+
return active, nil
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// NodeRuntime is the durable runtime identity for one node in a run. Unlike
|
|
95
|
+
// relay_runs' current-node fields, one row is retained for every visited node.
|
|
96
|
+
type NodeRuntime struct {
|
|
97
|
+
RunID run.ID
|
|
98
|
+
Node string
|
|
99
|
+
TerminalID string
|
|
100
|
+
SessionID string
|
|
101
|
+
NodeVisitID run.NodeVisitID
|
|
102
|
+
UpdatedAt time.Time
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const relayRunsSchema = `
|
|
106
|
+
CREATE TABLE IF NOT EXISTS relay_executor_identity (
|
|
107
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
108
|
+
executor_plugin TEXT NOT NULL,
|
|
109
|
+
temporal_address TEXT,
|
|
110
|
+
temporal_namespace TEXT
|
|
111
|
+
);
|
|
112
|
+
CREATE TABLE IF NOT EXISTS relay_runs (
|
|
113
|
+
id TEXT PRIMARY KEY,
|
|
114
|
+
logical_run_id TEXT,
|
|
115
|
+
attempt_id INTEGER,
|
|
116
|
+
repo TEXT NOT NULL,
|
|
117
|
+
workflow TEXT NOT NULL,
|
|
118
|
+
ticket_id TEXT NOT NULL,
|
|
119
|
+
ticket_key TEXT NOT NULL,
|
|
120
|
+
state TEXT NOT NULL,
|
|
121
|
+
current_node TEXT,
|
|
122
|
+
current_node_visit_id TEXT,
|
|
123
|
+
last_error TEXT,
|
|
124
|
+
retry_error TEXT,
|
|
125
|
+
retry_attempt INTEGER,
|
|
126
|
+
next_retry_at DATETIME,
|
|
127
|
+
started_at DATETIME NOT NULL,
|
|
128
|
+
updated_at DATETIME NOT NULL,
|
|
129
|
+
finished_at DATETIME
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS relay_runs_ticket_key ON relay_runs (ticket_key);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS relay_runs_workflow_state ON relay_runs (workflow, state);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS relay_runs_repo_state ON relay_runs (repo, state);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS relay_node_runtime (
|
|
135
|
+
run_id TEXT NOT NULL,
|
|
136
|
+
node TEXT NOT NULL,
|
|
137
|
+
terminal_id TEXT,
|
|
138
|
+
session_id TEXT,
|
|
139
|
+
node_visit_id TEXT NOT NULL,
|
|
140
|
+
updated_at DATETIME NOT NULL,
|
|
141
|
+
PRIMARY KEY (run_id, node),
|
|
142
|
+
FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
|
|
143
|
+
);
|
|
144
|
+
CREATE TABLE IF NOT EXISTS relay_processed_reports (
|
|
145
|
+
run_id TEXT NOT NULL,
|
|
146
|
+
report_id TEXT NOT NULL,
|
|
147
|
+
node_visit_id TEXT NOT NULL,
|
|
148
|
+
created_at DATETIME NOT NULL,
|
|
149
|
+
PRIMARY KEY (run_id, report_id),
|
|
150
|
+
FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
|
|
151
|
+
);
|
|
152
|
+
CREATE TABLE IF NOT EXISTS relay_node_sessions (
|
|
153
|
+
run_id TEXT NOT NULL,
|
|
154
|
+
node TEXT NOT NULL,
|
|
155
|
+
session_id TEXT NOT NULL,
|
|
156
|
+
node_visit_id TEXT NOT NULL,
|
|
157
|
+
created_at DATETIME NOT NULL,
|
|
158
|
+
PRIMARY KEY (run_id, node, session_id),
|
|
159
|
+
FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
|
|
160
|
+
);
|
|
161
|
+
`
|
|
162
|
+
|
|
163
|
+
func (p *RunProjection) migrate() error {
|
|
164
|
+
if _, err := p.DB.Exec(relayRunsSchema); err != nil {
|
|
165
|
+
return err
|
|
166
|
+
}
|
|
167
|
+
for name, definition := range map[string]string{
|
|
168
|
+
"logical_run_id": "TEXT",
|
|
169
|
+
"attempt_id": "INTEGER",
|
|
170
|
+
"retry_error": "TEXT",
|
|
171
|
+
"retry_attempt": "INTEGER",
|
|
172
|
+
"next_retry_at": "DATETIME",
|
|
173
|
+
} {
|
|
174
|
+
var count int
|
|
175
|
+
if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
|
|
176
|
+
return err
|
|
177
|
+
}
|
|
178
|
+
if count == 0 {
|
|
179
|
+
if _, err := p.DB.Exec(`ALTER TABLE relay_runs ADD COLUMN ` + name + ` ` + definition); err != nil {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Rows created before attempt identities were introduced represent the
|
|
185
|
+
// original attempt. Backfill the stable logical ID and attempt number so
|
|
186
|
+
// restart allocation remains numeric and never reuses attempt 1.
|
|
187
|
+
if _, err := p.DB.Exec(`UPDATE relay_runs SET logical_run_id = id WHERE COALESCE(logical_run_id, '') = ''`); err != nil {
|
|
188
|
+
return err
|
|
189
|
+
}
|
|
190
|
+
if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
|
|
191
|
+
return err
|
|
192
|
+
}
|
|
193
|
+
return nil
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var errRunNotFound = errors.New("run not found")
|
|
197
|
+
var errNodeRuntimeNotFound = errors.New("node runtime not found")
|
|
198
|
+
|
|
199
|
+
// IsNotFound reports a missing projection row.
|
|
200
|
+
func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
|
|
201
|
+
|
|
202
|
+
func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
|
|
203
|
+
logicalID := s.LogicalID
|
|
204
|
+
if logicalID == "" {
|
|
205
|
+
logicalID = s.ID
|
|
206
|
+
}
|
|
207
|
+
attemptID := s.AttemptID
|
|
208
|
+
if attemptID == 0 {
|
|
209
|
+
attemptID = 1
|
|
210
|
+
}
|
|
211
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
212
|
+
INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
213
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
214
|
+
ON CONFLICT(id) DO NOTHING`,
|
|
215
|
+
string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
|
|
216
|
+
string(run.StateStarting), now, now)
|
|
217
|
+
return err
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
221
|
+
terminal := state == run.StateCompleted || state == run.StateCanceled
|
|
222
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
223
|
+
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
|
|
224
|
+
retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
|
|
225
|
+
retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
|
|
226
|
+
next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
|
|
227
|
+
WHERE id = ?`,
|
|
228
|
+
string(state), lastErr, time.Now().UTC(), finished, terminal, terminal, terminal, string(id))
|
|
229
|
+
return err
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
233
|
+
if status == nil {
|
|
234
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
235
|
+
UPDATE relay_runs SET retry_error = NULL, retry_attempt = NULL, next_retry_at = NULL, updated_at = ?
|
|
236
|
+
WHERE id = ?`, time.Now().UTC(), string(id))
|
|
237
|
+
return err
|
|
238
|
+
}
|
|
239
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
240
|
+
UPDATE relay_runs SET retry_error = ?, retry_attempt = ?, next_retry_at = ?, updated_at = ?
|
|
241
|
+
WHERE id = ?`, status.LastError, status.Attempt, status.NextRetryAt, time.Now().UTC(), string(id))
|
|
242
|
+
return err
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
|
|
246
|
+
p.runtimeMu.Lock()
|
|
247
|
+
defer p.runtimeMu.Unlock()
|
|
248
|
+
tx, err := p.DB.BeginTx(ctx, nil)
|
|
249
|
+
if err != nil {
|
|
250
|
+
return err
|
|
251
|
+
}
|
|
252
|
+
defer tx.Rollback()
|
|
253
|
+
now := time.Now().UTC()
|
|
254
|
+
if _, err := tx.ExecContext(ctx, `
|
|
255
|
+
UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
|
|
256
|
+
WHERE id = ?`,
|
|
257
|
+
string(state), node, string(visit), now, string(id)); err != nil {
|
|
258
|
+
return err
|
|
259
|
+
}
|
|
260
|
+
// A revisit changes only the latest visit ID. Reusable terminal/session
|
|
261
|
+
// identities remain attached to this run/node row.
|
|
262
|
+
if _, err := tx.ExecContext(ctx, `
|
|
263
|
+
INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
|
|
264
|
+
VALUES (?, ?, ?, ?)
|
|
265
|
+
ON CONFLICT(run_id, node) DO UPDATE SET
|
|
266
|
+
node_visit_id = excluded.node_visit_id,
|
|
267
|
+
updated_at = excluded.updated_at`,
|
|
268
|
+
string(id), node, string(visit), now); err != nil {
|
|
269
|
+
return err
|
|
270
|
+
}
|
|
271
|
+
return tx.Commit()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
275
|
+
var rt NodeRuntime
|
|
276
|
+
var terminalID, sessionID sql.NullString
|
|
277
|
+
err := p.DB.QueryRowContext(ctx, `
|
|
278
|
+
SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
|
|
279
|
+
FROM relay_node_runtime WHERE run_id = ? AND node = ?`, string(id), node).
|
|
280
|
+
Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt)
|
|
281
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
282
|
+
return NodeRuntime{}, errNodeRuntimeNotFound
|
|
283
|
+
}
|
|
284
|
+
if err != nil {
|
|
285
|
+
return NodeRuntime{}, err
|
|
286
|
+
}
|
|
287
|
+
rt.TerminalID = terminalID.String
|
|
288
|
+
rt.SessionID = sessionID.String
|
|
289
|
+
return rt, nil
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func (p *RunProjection) updateNodeRuntime(ctx context.Context, rt NodeRuntime) error {
|
|
293
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
294
|
+
INSERT INTO relay_node_runtime (run_id, node, terminal_id, session_id, node_visit_id, updated_at)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
296
|
+
ON CONFLICT(run_id, node) DO UPDATE SET
|
|
297
|
+
terminal_id = excluded.terminal_id,
|
|
298
|
+
session_id = excluded.session_id,
|
|
299
|
+
node_visit_id = excluded.node_visit_id,
|
|
300
|
+
updated_at = excluded.updated_at`,
|
|
301
|
+
string(rt.RunID), rt.Node, nullableString(rt.TerminalID), nullableString(rt.SessionID),
|
|
302
|
+
string(rt.NodeVisitID), time.Now().UTC())
|
|
303
|
+
return err
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func (p *RunProjection) loadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
307
|
+
rt, err := p.getNodeRuntime(ctx, id, node)
|
|
308
|
+
if errors.Is(err, errNodeRuntimeNotFound) {
|
|
309
|
+
return NodeRuntime{RunID: id, Node: node}, nil
|
|
310
|
+
}
|
|
311
|
+
return rt, err
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func (p *RunProjection) nodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
|
|
315
|
+
var count int
|
|
316
|
+
err := p.DB.QueryRowContext(ctx, `
|
|
317
|
+
SELECT COUNT(1) FROM relay_node_runtime
|
|
318
|
+
WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
|
|
319
|
+
string(id), node, string(visit)).Scan(&count)
|
|
320
|
+
return count == 1, err
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func (p *RunProjection) replaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
|
|
324
|
+
result, err := p.DB.ExecContext(ctx, `
|
|
325
|
+
UPDATE relay_node_runtime SET terminal_id = ?,
|
|
326
|
+
session_id = CASE WHEN COALESCE(session_id, '') = ? THEN ? ELSE session_id END,
|
|
327
|
+
updated_at = ?
|
|
328
|
+
WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
|
|
329
|
+
nullableString(terminalID), previousSessionID, nullableString(sessionID), time.Now().UTC(),
|
|
330
|
+
string(id), node, string(visit))
|
|
331
|
+
if err != nil {
|
|
332
|
+
return err
|
|
333
|
+
}
|
|
334
|
+
updated, err := result.RowsAffected()
|
|
335
|
+
if err != nil {
|
|
336
|
+
return err
|
|
337
|
+
}
|
|
338
|
+
if updated != 1 {
|
|
339
|
+
return fmt.Errorf("node runtime %s/%s visit %s is not current", id, node, visit)
|
|
340
|
+
}
|
|
341
|
+
return nil
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func (p *RunProjection) updateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
|
|
345
|
+
p.runtimeMu.Lock()
|
|
346
|
+
defer p.runtimeMu.Unlock()
|
|
347
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
348
|
+
INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
|
|
349
|
+
VALUES (?, ?, ?, ?)
|
|
350
|
+
ON CONFLICT(run_id, node) DO UPDATE SET
|
|
351
|
+
node_visit_id = excluded.node_visit_id,
|
|
352
|
+
updated_at = excluded.updated_at`,
|
|
353
|
+
string(id), node, string(visit), time.Now().UTC())
|
|
354
|
+
return err
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func (p *RunProjection) registerNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
|
|
358
|
+
p.runtimeMu.Lock()
|
|
359
|
+
defer p.runtimeMu.Unlock()
|
|
360
|
+
tx, err := p.DB.BeginTx(ctx, nil)
|
|
361
|
+
if err != nil {
|
|
362
|
+
return false, err
|
|
363
|
+
}
|
|
364
|
+
defer tx.Rollback()
|
|
365
|
+
var current run.NodeVisitID
|
|
366
|
+
if err := tx.QueryRowContext(ctx, `
|
|
367
|
+
SELECT node_visit_id FROM relay_node_runtime WHERE run_id = ? AND node = ?`,
|
|
368
|
+
string(registration.RunID), registration.Node).Scan(¤t); err != nil {
|
|
369
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
370
|
+
return false, nil
|
|
371
|
+
}
|
|
372
|
+
return false, err
|
|
373
|
+
}
|
|
374
|
+
if _, err := tx.ExecContext(ctx, `
|
|
375
|
+
INSERT INTO relay_node_sessions (run_id, node, session_id, node_visit_id, created_at)
|
|
376
|
+
VALUES (?, ?, ?, ?, ?) ON CONFLICT(run_id, node, session_id) DO NOTHING`,
|
|
377
|
+
string(registration.RunID), registration.Node, registration.SessionID, string(current), time.Now().UTC()); err != nil {
|
|
378
|
+
return false, err
|
|
379
|
+
}
|
|
380
|
+
var bound run.NodeVisitID
|
|
381
|
+
if err := tx.QueryRowContext(ctx, `
|
|
382
|
+
SELECT node_visit_id FROM relay_node_sessions
|
|
383
|
+
WHERE run_id = ? AND node = ? AND session_id = ?`,
|
|
384
|
+
string(registration.RunID), registration.Node, registration.SessionID).Scan(&bound); err != nil {
|
|
385
|
+
return false, err
|
|
386
|
+
}
|
|
387
|
+
if bound != current {
|
|
388
|
+
return false, tx.Commit()
|
|
389
|
+
}
|
|
390
|
+
result, err := tx.ExecContext(ctx, `
|
|
391
|
+
UPDATE relay_node_runtime SET session_id = ?, updated_at = ?
|
|
392
|
+
WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
|
|
393
|
+
registration.SessionID, time.Now().UTC(), string(registration.RunID), registration.Node, string(bound))
|
|
394
|
+
if err != nil {
|
|
395
|
+
return false, err
|
|
396
|
+
}
|
|
397
|
+
updated, err := result.RowsAffected()
|
|
398
|
+
if err != nil {
|
|
399
|
+
return false, err
|
|
400
|
+
}
|
|
401
|
+
return updated == 1, tx.Commit()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
func (p *RunProjection) hasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
|
|
405
|
+
var count int
|
|
406
|
+
err := p.DB.QueryRowContext(ctx, `
|
|
407
|
+
SELECT COUNT(1) FROM relay_processed_reports WHERE run_id = ? AND report_id = ?`,
|
|
408
|
+
string(id), reportID).Scan(&count)
|
|
409
|
+
return count == 1, err
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
func (p *RunProjection) recordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
|
|
413
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
414
|
+
INSERT INTO relay_processed_reports (run_id, report_id, node_visit_id, created_at)
|
|
415
|
+
VALUES (?, ?, ?, ?) ON CONFLICT(run_id, report_id) DO NOTHING`,
|
|
416
|
+
string(id), reportID, string(visit), time.Now().UTC())
|
|
417
|
+
return err
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func (p *RunProjection) clearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
|
|
421
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
422
|
+
UPDATE relay_node_runtime SET
|
|
423
|
+
terminal_id = CASE WHEN ? THEN NULL ELSE terminal_id END,
|
|
424
|
+
session_id = CASE WHEN ? THEN NULL ELSE session_id END,
|
|
425
|
+
updated_at = ?
|
|
426
|
+
WHERE run_id = ? AND node = ?`, clearTerminal, clearSession,
|
|
427
|
+
time.Now().UTC(), string(id), node)
|
|
428
|
+
return err
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func (p *RunProjection) listNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
|
|
432
|
+
rows, err := p.DB.QueryContext(ctx, `
|
|
433
|
+
SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
|
|
434
|
+
FROM relay_node_runtime WHERE run_id = ?`, string(id))
|
|
435
|
+
if err != nil {
|
|
436
|
+
return nil, err
|
|
437
|
+
}
|
|
438
|
+
defer rows.Close()
|
|
439
|
+
var out []NodeRuntime
|
|
440
|
+
for rows.Next() {
|
|
441
|
+
var rt NodeRuntime
|
|
442
|
+
var terminalID, sessionID sql.NullString
|
|
443
|
+
if err := rows.Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt); err != nil {
|
|
444
|
+
return nil, err
|
|
445
|
+
}
|
|
446
|
+
rt.TerminalID, rt.SessionID = terminalID.String, sessionID.String
|
|
447
|
+
out = append(out, rt)
|
|
448
|
+
}
|
|
449
|
+
return out, rows.Err()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
func nullableString(value string) any {
|
|
453
|
+
if value == "" {
|
|
454
|
+
return nil
|
|
455
|
+
}
|
|
456
|
+
return value
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
|
|
460
|
+
row := p.DB.QueryRowContext(ctx, `
|
|
461
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
462
|
+
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
463
|
+
FROM relay_runs WHERE id = ?`, string(id))
|
|
464
|
+
return scanRun(row)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
468
|
+
row := p.DB.QueryRowContext(ctx, `
|
|
469
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
470
|
+
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
471
|
+
FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, ticket)
|
|
472
|
+
return scanRun(row)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
func (p *RunProjection) findByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
|
|
476
|
+
row := p.DB.QueryRowContext(ctx, `
|
|
477
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
478
|
+
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
479
|
+
FROM relay_runs WHERE logical_run_id = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, string(logicalID))
|
|
480
|
+
return scanRun(row)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
type rowScanner interface {
|
|
484
|
+
Scan(dest ...any) error
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
func scanRun(row rowScanner) (run.Run, error) {
|
|
488
|
+
var r run.Run
|
|
489
|
+
var logicalID sql.NullString
|
|
490
|
+
var attemptNumber sql.NullInt64
|
|
491
|
+
var node, visit, lastErr, retryErr sql.NullString
|
|
492
|
+
var retryAttempt sql.NullInt64
|
|
493
|
+
var nextRetry, finished sql.NullTime
|
|
494
|
+
var started, updated time.Time
|
|
495
|
+
err := row.Scan(&r.ID, &logicalID, &attemptNumber, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
|
|
496
|
+
&node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
|
|
497
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
498
|
+
return run.Run{}, errRunNotFound
|
|
499
|
+
}
|
|
500
|
+
if err != nil {
|
|
501
|
+
return run.Run{}, err
|
|
502
|
+
}
|
|
503
|
+
if logicalID.Valid && logicalID.String != "" {
|
|
504
|
+
r.LogicalID = run.ID(logicalID.String)
|
|
505
|
+
} else {
|
|
506
|
+
r.LogicalID = r.ID
|
|
507
|
+
}
|
|
508
|
+
if attemptNumber.Valid && attemptNumber.Int64 > 0 {
|
|
509
|
+
r.AttemptID = run.AttemptID(attemptNumber.Int64)
|
|
510
|
+
} else {
|
|
511
|
+
r.AttemptID = 1
|
|
512
|
+
}
|
|
513
|
+
r.CurrentNode = node.String
|
|
514
|
+
r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
|
|
515
|
+
r.LastError = lastErr.String
|
|
516
|
+
if retryErr.Valid && retryAttempt.Valid && nextRetry.Valid {
|
|
517
|
+
r.Retry = &run.RetryStatus{
|
|
518
|
+
Attempt: int(retryAttempt.Int64), LastError: retryErr.String, NextRetryAt: nextRetry.Time,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
r.StartedAt = started
|
|
522
|
+
r.UpdatedAt = updated
|
|
523
|
+
if finished.Valid {
|
|
524
|
+
t := finished.Time
|
|
525
|
+
r.FinishedAt = &t
|
|
526
|
+
}
|
|
527
|
+
return r, nil
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
|
|
531
|
+
q := `SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
|
|
532
|
+
var args []any
|
|
533
|
+
if f.Repo != "" {
|
|
534
|
+
q += ` AND repo = ?`
|
|
535
|
+
args = append(args, f.Repo)
|
|
536
|
+
}
|
|
537
|
+
if f.Workflow != "" {
|
|
538
|
+
q += ` AND workflow = ?`
|
|
539
|
+
args = append(args, f.Workflow)
|
|
540
|
+
}
|
|
541
|
+
if f.Ticket != "" {
|
|
542
|
+
q += ` AND ticket_key = ?`
|
|
543
|
+
args = append(args, f.Ticket)
|
|
544
|
+
}
|
|
545
|
+
if f.Active != nil {
|
|
546
|
+
if *f.Active {
|
|
547
|
+
q += ` AND state NOT IN ('completed', 'canceled')`
|
|
548
|
+
} else {
|
|
549
|
+
q += ` AND state IN ('completed', 'canceled')`
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
q += ` ORDER BY started_at`
|
|
553
|
+
rows, err := p.DB.QueryContext(ctx, q, args...)
|
|
554
|
+
if err != nil {
|
|
555
|
+
return nil, err
|
|
556
|
+
}
|
|
557
|
+
defer rows.Close()
|
|
558
|
+
var out []run.Run
|
|
559
|
+
for rows.Next() {
|
|
560
|
+
r, err := scanRun(rows)
|
|
561
|
+
if err != nil {
|
|
562
|
+
return nil, err
|
|
563
|
+
}
|
|
564
|
+
out = append(out, r)
|
|
565
|
+
}
|
|
566
|
+
return out, rows.Err()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
func (p *RunProjection) hasActive(ctx context.Context, column, value string) (bool, error) {
|
|
570
|
+
var n int
|
|
571
|
+
err := p.DB.QueryRowContext(ctx,
|
|
572
|
+
fmt.Sprintf(`SELECT COUNT(1) FROM relay_runs WHERE %s = ? AND state NOT IN ('completed', 'canceled')`, column),
|
|
573
|
+
value).Scan(&n)
|
|
574
|
+
return n > 0, err
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// sweepRetention removes terminal projection rows whose finished_at is older
|
|
578
|
+
// than the retention window. Nonterminal runs are never removed.
|
|
579
|
+
func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
|
|
580
|
+
rows, err := p.DB.QueryContext(ctx, `
|
|
581
|
+
SELECT id FROM relay_runs
|
|
582
|
+
WHERE state IN ('completed', 'canceled') AND finished_at IS NOT NULL AND finished_at < ?`, olderThan)
|
|
583
|
+
if err != nil {
|
|
584
|
+
return nil, err
|
|
585
|
+
}
|
|
586
|
+
var ids []string
|
|
587
|
+
for rows.Next() {
|
|
588
|
+
var id string
|
|
589
|
+
if err := rows.Scan(&id); err != nil {
|
|
590
|
+
rows.Close()
|
|
591
|
+
return nil, err
|
|
592
|
+
}
|
|
593
|
+
ids = append(ids, id)
|
|
594
|
+
}
|
|
595
|
+
rows.Close()
|
|
596
|
+
for _, id := range ids {
|
|
597
|
+
tx, err := p.DB.BeginTx(ctx, nil)
|
|
598
|
+
if err != nil {
|
|
599
|
+
return ids, err
|
|
600
|
+
}
|
|
601
|
+
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_processed_reports WHERE run_id = ?`, id); err != nil {
|
|
602
|
+
tx.Rollback()
|
|
603
|
+
return ids, err
|
|
604
|
+
}
|
|
605
|
+
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_sessions WHERE run_id = ?`, id); err != nil {
|
|
606
|
+
tx.Rollback()
|
|
607
|
+
return ids, err
|
|
608
|
+
}
|
|
609
|
+
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_runtime WHERE run_id = ?`, id); err != nil {
|
|
610
|
+
tx.Rollback()
|
|
611
|
+
return ids, err
|
|
612
|
+
}
|
|
613
|
+
if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
|
|
614
|
+
tx.Rollback()
|
|
615
|
+
return ids, err
|
|
616
|
+
}
|
|
617
|
+
if err := tx.Commit(); err != nil {
|
|
618
|
+
return ids, err
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return ids, nil
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The exported methods below are the engine-neutral projection surface. The
|
|
625
|
+
// small unexported methods above retain the original implementation shape so
|
|
626
|
+
// the goworkflows facade can preserve its package-local call sites without
|
|
627
|
+
// copying the schema or SQL.
|
|
628
|
+
|
|
629
|
+
// Migrate creates or upgrades all relay-owned projection tables.
|
|
630
|
+
func (p *RunProjection) Migrate() error { return p.migrate() }
|
|
631
|
+
|
|
632
|
+
// InsertStart records a starting run idempotently.
|
|
633
|
+
func (p *RunProjection) InsertStart(ctx context.Context, start run.Start, now time.Time) error {
|
|
634
|
+
return p.insertStart(ctx, start, now)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// UpdateState updates the lifecycle state and terminal metadata.
|
|
638
|
+
func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
639
|
+
return p.updateState(ctx, id, state, lastErr, finished)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// UpdateRetry updates or clears active retry metadata.
|
|
643
|
+
func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
644
|
+
return p.updateRetry(ctx, id, status)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// UpdateNode updates the current node and its latest visit identity.
|
|
648
|
+
func (p *RunProjection) UpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
|
|
649
|
+
return p.updateNode(ctx, id, state, node, visit)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// GetNodeRuntime returns one node's persisted runtime binding.
|
|
653
|
+
func (p *RunProjection) GetNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
654
|
+
return p.getNodeRuntime(ctx, id, node)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// UpdateNodeRuntime upserts a node's persisted runtime binding.
|
|
658
|
+
func (p *RunProjection) UpdateNodeRuntime(ctx context.Context, runtime NodeRuntime) error {
|
|
659
|
+
return p.updateNodeRuntime(ctx, runtime)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// LoadNodeRuntime loads a runtime binding, returning an empty binding when the
|
|
663
|
+
// node has not been seen yet.
|
|
664
|
+
func (p *RunProjection) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
665
|
+
return p.loadNodeRuntime(ctx, id, node)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// NodeRuntimeVisitIsCurrent reports whether visit is the current node visit.
|
|
669
|
+
func (p *RunProjection) NodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
|
|
670
|
+
return p.nodeRuntimeVisitIsCurrent(ctx, id, node, visit)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ReplaceNodeRuntime replaces runtime handles for the current visit.
|
|
674
|
+
func (p *RunProjection) ReplaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
|
|
675
|
+
return p.replaceNodeRuntime(ctx, id, node, visit, terminalID, previousSessionID, sessionID)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// UpdateNodeRuntimeVisit updates only the current visit while preserving
|
|
679
|
+
// reusable terminal and session handles.
|
|
680
|
+
func (p *RunProjection) UpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
|
|
681
|
+
return p.updateNodeRuntimeVisit(ctx, id, node, visit)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// RegisterNodeSession records a runtime session and binds it only when its
|
|
685
|
+
// visit is still current.
|
|
686
|
+
func (p *RunProjection) RegisterNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
|
|
687
|
+
return p.registerNodeSession(ctx, registration)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// HasProcessedReport checks the derived report receipt fast path.
|
|
691
|
+
func (p *RunProjection) HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
|
|
692
|
+
return p.hasProcessedReport(ctx, id, reportID)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// RecordProcessedReport records a report receipt idempotently.
|
|
696
|
+
func (p *RunProjection) RecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
|
|
697
|
+
return p.recordProcessedReport(ctx, id, visit, reportID)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ClearNodeRuntime clears selected reusable runtime handles.
|
|
701
|
+
func (p *RunProjection) ClearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
|
|
702
|
+
return p.clearNodeRuntime(ctx, id, node, clearTerminal, clearSession)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ListNodeRuntimes returns all persisted bindings for one run.
|
|
706
|
+
func (p *RunProjection) ListNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
|
|
707
|
+
return p.listNodeRuntimes(ctx, id)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Get returns one derived run row.
|
|
711
|
+
func (p *RunProjection) Get(ctx context.Context, id run.ID) (run.Run, error) {
|
|
712
|
+
return p.get(ctx, id)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// FindByTicket returns the latest derived run for a ticket.
|
|
716
|
+
func (p *RunProjection) FindByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
717
|
+
return p.findByTicket(ctx, ticket)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// FindByLogicalID returns the latest attempt for a logical run.
|
|
721
|
+
func (p *RunProjection) FindByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
|
|
722
|
+
return p.findByLogicalID(ctx, logicalID)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// List returns derived runs matching filter.
|
|
726
|
+
func (p *RunProjection) List(ctx context.Context, filter run.Filter) ([]run.Run, error) {
|
|
727
|
+
return p.list(ctx, filter)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// HasActiveWorkflow reports whether a nonterminal run exists for workflow.
|
|
731
|
+
func (p *RunProjection) HasActiveWorkflow(ctx context.Context, workflow string) (bool, error) {
|
|
732
|
+
return p.hasActive(ctx, "workflow", workflow)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// HasActiveRepo reports whether a nonterminal run exists for repo.
|
|
736
|
+
func (p *RunProjection) HasActiveRepo(ctx context.Context, repoName string) (bool, error) {
|
|
737
|
+
return p.hasActive(ctx, "repo", repoName)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// SweepRetention deletes terminal rows older than olderThan and their derived
|
|
741
|
+
// child rows. It never removes active lifecycle states.
|
|
742
|
+
func (p *RunProjection) SweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
|
|
743
|
+
return p.sweepRetention(ctx, olderThan)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ErrRunNotFound identifies a missing derived run row.
|
|
747
|
+
var ErrRunNotFound = errRunNotFound
|
|
748
|
+
|
|
749
|
+
// ErrNodeRuntimeNotFound identifies a missing node runtime row.
|
|
750
|
+
var ErrNodeRuntimeNotFound = errNodeRuntimeNotFound
|
|
751
|
+
|
|
752
|
+
// ExecutorIdentity is the immutable durable-execution identity of one
|
|
753
|
+
// initialized relay-flow home.
|
|
754
|
+
type ExecutorIdentity struct {
|
|
755
|
+
ExecutorPlugin string
|
|
756
|
+
TemporalAddress string
|
|
757
|
+
TemporalNamespace string
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ErrIdentityMissing means a database has no installation identity marker.
|
|
761
|
+
var ErrIdentityMissing = errors.New("executor identity missing")
|
|
762
|
+
|
|
763
|
+
// ErrIdentityMismatch means configured execution identity differs from the
|
|
764
|
+
// initialized installation marker.
|
|
765
|
+
var ErrIdentityMismatch = errors.New("executor identity mismatch")
|
|
766
|
+
|
|
767
|
+
func validateExecutorIdentity(identity ExecutorIdentity) error {
|
|
768
|
+
switch identity.ExecutorPlugin {
|
|
769
|
+
case "goworkflows":
|
|
770
|
+
if identity.TemporalAddress != "" || identity.TemporalNamespace != "" {
|
|
771
|
+
return fmt.Errorf("goworkflows identity must not contain Temporal address or namespace")
|
|
772
|
+
}
|
|
773
|
+
case "temporal":
|
|
774
|
+
if strings.TrimSpace(identity.TemporalAddress) == "" || strings.TrimSpace(identity.TemporalNamespace) == "" {
|
|
775
|
+
return fmt.Errorf("temporal identity requires address and namespace")
|
|
776
|
+
}
|
|
777
|
+
default:
|
|
778
|
+
return fmt.Errorf("unknown executor plugin %q", identity.ExecutorPlugin)
|
|
779
|
+
}
|
|
780
|
+
return nil
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// InitializeIdentity atomically creates the singleton marker or verifies that
|
|
784
|
+
// an existing marker has exactly the same durable identity. It never updates a
|
|
785
|
+
// marker in place, so a changed executor/address/namespace fails closed.
|
|
786
|
+
func (p *RunProjection) InitializeIdentity(ctx context.Context, expected ExecutorIdentity) error {
|
|
787
|
+
if err := validateExecutorIdentity(expected); err != nil {
|
|
788
|
+
return err
|
|
789
|
+
}
|
|
790
|
+
tx, err := p.DB.BeginTx(ctx, nil)
|
|
791
|
+
if err != nil {
|
|
792
|
+
return fmt.Errorf("begin executor identity transaction: %w", err)
|
|
793
|
+
}
|
|
794
|
+
defer tx.Rollback()
|
|
795
|
+
|
|
796
|
+
var actual ExecutorIdentity
|
|
797
|
+
var address, namespace sql.NullString
|
|
798
|
+
err = tx.QueryRowContext(ctx, `
|
|
799
|
+
SELECT executor_plugin, temporal_address, temporal_namespace
|
|
800
|
+
FROM relay_executor_identity WHERE singleton = 1`).Scan(
|
|
801
|
+
&actual.ExecutorPlugin, &address, &namespace)
|
|
802
|
+
switch {
|
|
803
|
+
case errors.Is(err, sql.ErrNoRows):
|
|
804
|
+
_, err = tx.ExecContext(ctx, `
|
|
805
|
+
INSERT INTO relay_executor_identity
|
|
806
|
+
(singleton, executor_plugin, temporal_address, temporal_namespace)
|
|
807
|
+
VALUES (1, ?, ?, ?)`, expected.ExecutorPlugin, nullableString(expected.TemporalAddress), nullableString(expected.TemporalNamespace))
|
|
808
|
+
if err != nil {
|
|
809
|
+
return fmt.Errorf("insert executor identity: %w", err)
|
|
810
|
+
}
|
|
811
|
+
case err != nil:
|
|
812
|
+
return fmt.Errorf("read executor identity: %w", err)
|
|
813
|
+
default:
|
|
814
|
+
actual.TemporalAddress = address.String
|
|
815
|
+
actual.TemporalNamespace = namespace.String
|
|
816
|
+
if actual != expected {
|
|
817
|
+
return fmt.Errorf("%w: configured=%+v persisted=%+v", ErrIdentityMismatch, expected, actual)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if err := tx.Commit(); err != nil {
|
|
821
|
+
return fmt.Errorf("commit executor identity: %w", err)
|
|
822
|
+
}
|
|
823
|
+
return nil
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// VerifyIdentity checks the singleton marker before worker startup. A legacy
|
|
827
|
+
// marker-less database may be adopted only by the embedded executor; this
|
|
828
|
+
// first successful verification records that legacy identity.
|
|
829
|
+
func (p *RunProjection) VerifyIdentity(ctx context.Context, expected ExecutorIdentity) error {
|
|
830
|
+
if err := validateExecutorIdentity(expected); err != nil {
|
|
831
|
+
return err
|
|
832
|
+
}
|
|
833
|
+
actual, present, err := p.Identity(ctx)
|
|
834
|
+
if err != nil {
|
|
835
|
+
return err
|
|
836
|
+
}
|
|
837
|
+
if !present {
|
|
838
|
+
if expected.ExecutorPlugin != "goworkflows" {
|
|
839
|
+
return fmt.Errorf("%w: Temporal installation requires an initialized marker", ErrIdentityMissing)
|
|
840
|
+
}
|
|
841
|
+
return p.InitializeIdentity(ctx, expected)
|
|
842
|
+
}
|
|
843
|
+
if actual != expected {
|
|
844
|
+
return fmt.Errorf("%w: configured=%+v persisted=%+v", ErrIdentityMismatch, expected, actual)
|
|
845
|
+
}
|
|
846
|
+
return nil
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Identity reads the singleton marker. The boolean is false when the marker is
|
|
850
|
+
// absent; absence is distinct from a malformed/missing table error.
|
|
851
|
+
func (p *RunProjection) Identity(ctx context.Context) (ExecutorIdentity, bool, error) {
|
|
852
|
+
var identity ExecutorIdentity
|
|
853
|
+
var address, namespace sql.NullString
|
|
854
|
+
err := p.DB.QueryRowContext(ctx, `
|
|
855
|
+
SELECT executor_plugin, temporal_address, temporal_namespace
|
|
856
|
+
FROM relay_executor_identity WHERE singleton = 1`).Scan(
|
|
857
|
+
&identity.ExecutorPlugin, &address, &namespace)
|
|
858
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
859
|
+
return ExecutorIdentity{}, false, nil
|
|
860
|
+
}
|
|
861
|
+
if err != nil {
|
|
862
|
+
return ExecutorIdentity{}, false, fmt.Errorf("read executor identity: %w", err)
|
|
863
|
+
}
|
|
864
|
+
identity.TemporalAddress = address.String
|
|
865
|
+
identity.TemporalNamespace = namespace.String
|
|
866
|
+
return identity, true, nil
|
|
867
|
+
}
|