···130130 expires_at text
131131 );
132132133133+ create table if not exists mill_leases (
134134+ lease_id text primary key,
135135+ node_id text not null,
136136+ engine text not null,
137137+ knot text not null,
138138+ rkey text not null,
139139+ workflow text not null,
140140+ state text not null,
141141+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
142142+ );
143143+144144+ create table if not exists mill_executor_cursors (
145145+ node_id text primary key,
146146+ acked_offset integer not null
147147+ );
148148+133149 create table if not exists migrations (
134150 id integer primary key autoincrement,
135151 name text unique
+33
spindle/db/events.go
···9494 return d.insertEvent(event, n)
9595}
96969797+// CompleteOrphanMillLease makes the terminal event and lease deletion one
9898+// durable transition; an event without its matching deletion would be replayed.
9999+func (d *DB) CompleteOrphanMillLease(
100100+ leaseID string,
101101+ pipelineAtUri string,
102102+ workflow string,
103103+ status string,
104104+ workflowError *string,
105105+ exitCode *int64,
106106+ n *notifier.Notifier,
107107+) error {
108108+ event, err := statusEvent(pipelineAtUri, workflow, status, workflowError, exitCode)
109109+ if err != nil {
110110+ return err
111111+ }
112112+ tx, err := d.Begin()
113113+ if err != nil {
114114+ return err
115115+ }
116116+ defer tx.Rollback()
117117+ if err := eventstream.Insert(tx, event, nil); err != nil {
118118+ return err
119119+ }
120120+ if _, err := tx.Exec(`delete from mill_leases where lease_id = ?`, leaseID); err != nil {
121121+ return err
122122+ }
123123+ if err := tx.Commit(); err != nil {
124124+ return err
125125+ }
126126+ n.NotifyAll()
127127+ return nil
128128+}
129129+97130func (d *DB) GetStatus(workflowId models.WorkflowId) (*tangled.PipelineStatus, error) {
98131 pipelineAtUri := workflowId.PipelineId.AtUri()
99132
+86
spindle/db/mill_state.go
···11+package db
22+33+// MillLease is the persisted view of a mill-side remote lease, enough to
44+// rebuild the fencing token and its workflow identity after a mill restart.
55+type MillLease struct {
66+ LeaseID string
77+ NodeID string
88+ Engine string
99+ Knot string
1010+ Rkey string
1111+ Workflow string
1212+ State string
1313+}
1414+1515+func (d *DB) SaveMillLease(l MillLease) error {
1616+ _, err := d.Exec(
1717+ `insert into mill_leases (lease_id, node_id, engine, knot, rkey, workflow, state)
1818+ values (?, ?, ?, ?, ?, ?, ?)
1919+ on conflict(lease_id) do update set state = excluded.state`,
2020+ l.LeaseID, l.NodeID, l.Engine, l.Knot, l.Rkey, l.Workflow, l.State,
2121+ )
2222+ return err
2323+}
2424+2525+func (d *DB) DeleteMillLease(leaseID string) error {
2626+ _, err := d.Exec(`delete from mill_leases where lease_id = ?`, leaseID)
2727+ return err
2828+}
2929+3030+func (d *DB) DeleteMillLeasesByNode(nodeID string) error {
3131+ _, err := d.Exec(`delete from mill_leases where node_id = ?`, nodeID)
3232+ return err
3333+}
3434+3535+func (d *DB) ListMillLeases() ([]MillLease, error) {
3636+ rows, err := d.Query(`select lease_id, node_id, engine, knot, rkey, workflow, state from mill_leases`)
3737+ if err != nil {
3838+ return nil, err
3939+ }
4040+ defer rows.Close()
4141+4242+ var leases []MillLease
4343+ for rows.Next() {
4444+ var l MillLease
4545+ if err := rows.Scan(&l.LeaseID, &l.NodeID, &l.Engine, &l.Knot, &l.Rkey, &l.Workflow, &l.State); err != nil {
4646+ return nil, err
4747+ }
4848+ leases = append(leases, l)
4949+ }
5050+ return leases, rows.Err()
5151+}
5252+5353+// SetExecutorCursor persists the highest relay offset the mill has applied for
5454+// a node.
5555+func (d *DB) SetExecutorCursor(nodeID string, offset uint64) error {
5656+ _, err := d.Exec(
5757+ `insert into mill_executor_cursors (node_id, acked_offset) values (?, ?)
5858+ on conflict(node_id) do update set acked_offset = excluded.acked_offset`,
5959+ nodeID, offset,
6060+ )
6161+ return err
6262+}
6363+6464+func (d *DB) DeleteExecutorCursor(nodeID string) error {
6565+ _, err := d.Exec(`delete from mill_executor_cursors where node_id = ?`, nodeID)
6666+ return err
6767+}
6868+6969+func (d *DB) ListExecutorCursors() (map[string]uint64, error) {
7070+ rows, err := d.Query(`select node_id, acked_offset from mill_executor_cursors`)
7171+ if err != nil {
7272+ return nil, err
7373+ }
7474+ defer rows.Close()
7575+7676+ cursors := make(map[string]uint64)
7777+ for rows.Next() {
7878+ var node string
7979+ var offset uint64
8080+ if err := rows.Scan(&node, &offset); err != nil {
8181+ return nil, err
8282+ }
8383+ cursors[node] = offset
8484+ }
8585+ return cursors, rows.Err()
8686+}
···252252// NodeSnapshot is pushed on connect, periodically, and right after any state
253253// change (reserve, commit, terminal).
254254type NodeSnapshot struct {
255255- state protoimpl.MessageState `protogen:"open.v1"`
256256- NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
257257- Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"`
258258- Engines map[string]*EngineAvailability `protobuf:"bytes,3,rep,name=engines,proto3" json:"engines,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
259259- unknownFields protoimpl.UnknownFields
260260- sizeCache protoimpl.SizeCache
255255+ state protoimpl.MessageState `protogen:"open.v1"`
256256+ NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
257257+ Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"`
258258+ Engines map[string]*EngineAvailability `protobuf:"bytes,3,rep,name=engines,proto3" json:"engines,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
259259+ // every lease the executor currently holds (reserved or running), so the
260260+ // mill can reconcile restored leases against executor reality.
261261+ ActiveLeaseIds []string `protobuf:"bytes,4,rep,name=active_lease_ids,json=activeLeaseIds,proto3" json:"active_lease_ids,omitempty"`
262262+ unknownFields protoimpl.UnknownFields
263263+ sizeCache protoimpl.SizeCache
261264}
262265263266func (x *NodeSnapshot) Reset() {
···307310func (x *NodeSnapshot) GetEngines() map[string]*EngineAvailability {
308311 if x != nil {
309312 return x.Engines
313313+ }
314314+ return nil
315315+}
316316+317317+func (x *NodeSnapshot) GetActiveLeaseIds() []string {
318318+ if x != nil {
319319+ return x.ActiveLeaseIds
310320 }
311321 return nil
312322}
···11711181 "\fcapabilities\x18\x03 \x03(\tR\fcapabilities\x1a7\n" +
11721182 "\tLoadEntry\x12\x10\n" +
11731183 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
11741174- "\x05value\x18\x02 \x01(\x01R\x05value:\x028\x01\"\xe0\x01\n" +
11841184+ "\x05value\x18\x02 \x01(\x01R\x05value:\x028\x01\"\x8a\x02\n" +
11751185 "\fNodeSnapshot\x12\x17\n" +
11761186 "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x10\n" +
11771187 "\x03seq\x18\x02 \x01(\x04R\x03seq\x12D\n" +
11781178- "\aengines\x18\x03 \x03(\v2*.spindle.mill.v1.NodeSnapshot.EnginesEntryR\aengines\x1a_\n" +
11881188+ "\aengines\x18\x03 \x03(\v2*.spindle.mill.v1.NodeSnapshot.EnginesEntryR\aengines\x12(\n" +
11891189+ "\x10active_lease_ids\x18\x04 \x03(\tR\x0eactiveLeaseIds\x1a_\n" +
11791190 "\fEnginesEntry\x12\x10\n" +
11801191 "\x03key\x18\x01 \x01(\tR\x03key\x129\n" +
11811192 "\x05value\x18\x02 \x01(\v2#.spindle.mill.v1.EngineAvailabilityR\x05value:\x028\x01\"\x80\x02\n" +
+3
spindle/mill/proto/spindle/mill/v1/mill.proto
···4242 string node_id = 1;
4343 uint64 seq = 2;
4444 map<string, EngineAvailability> engines = 3;
4545+ // every lease the executor currently holds (reserved or running), so the
4646+ // mill can reconcile restored leases against executor reality.
4747+ repeated string active_lease_ids = 4;
4548}
46494750// ReserveSeat asks an executor to hold a seat for a job. Zero secrets ride this
+159
spindle/mill/restore.go
···11+package mill
22+33+import (
44+ "time"
55+66+ "tangled.org/core/spindle/db"
77+ "tangled.org/core/spindle/models"
88+)
99+1010+// mill_leases.state values. Committing persists as reserved: a crash mid-commit
1111+// is indistinguishable from one before it, and reconciliation treats both the
1212+// same (the executor either holds the lease or it doesn't).
1313+const (
1414+ leaseRowReserved = "reserved"
1515+ leaseRowRunning = "running"
1616+)
1717+1818+func (m *Mill) persistLease(lease *RemoteLease, state string) error {
1919+ if m.db == nil {
2020+ return nil
2121+ }
2222+ return m.db.SaveMillLease(db.MillLease{
2323+ LeaseID: lease.id,
2424+ NodeID: lease.nodeID,
2525+ Engine: lease.engine,
2626+ Knot: lease.wid.Knot,
2727+ Rkey: lease.wid.Rkey,
2828+ Workflow: lease.wid.Name,
2929+ State: state,
3030+ })
3131+}
3232+3333+func (m *Mill) deleteLeaseRow(leaseID string) {
3434+ if m.db == nil {
3535+ return
3636+ }
3737+ if err := m.db.DeleteMillLease(leaseID); err != nil {
3838+ m.l.Error("delete mill lease row", "lease", leaseID, "err", err)
3939+ }
4040+}
4141+4242+// RestoreState rebuilds leases and relay cursors persisted by a previous run.
4343+// Restored leases are orphaned: no RunStep waits on them, so their terminal
4444+// (or their executor's death) is authored directly as a status row.
4545+func (m *Mill) RestoreState() error {
4646+ if m.db == nil {
4747+ return nil
4848+ }
4949+ cursors, err := m.db.ListExecutorCursors()
5050+ if err != nil {
5151+ return err
5252+ }
5353+ rows, err := m.db.ListMillLeases()
5454+ if err != nil {
5555+ return err
5656+ }
5757+5858+ m.mu.Lock()
5959+ for node, offset := range cursors {
6060+ m.nodeOffset[node] = offset
6161+ }
6262+ for _, r := range rows {
6363+ lease := newLease(r.LeaseID, r.NodeID, r.Engine)
6464+ lease.wid = models.WorkflowId{
6565+ PipelineId: models.PipelineId{Knot: r.Knot, Rkey: r.Rkey},
6666+ Name: r.Workflow,
6767+ }
6868+ lease.orphaned = true
6969+ if r.State == leaseRowRunning {
7070+ lease.state = leaseRunning
7171+ }
7272+ m.leases[r.LeaseID] = lease
7373+ }
7474+ restored := len(rows)
7575+ m.mu.Unlock()
7676+7777+ if restored > 0 {
7878+ m.l.Info("restored mill leases from previous run", "leases", restored, "cursors", len(cursors))
7979+ time.AfterFunc(m.cfg.ReconnectGrace, m.sweepUnclaimedOrphans)
8080+ }
8181+ return nil
8282+}
8383+8484+func (m *Mill) sweepUnclaimedOrphans() {
8585+ m.mu.Lock()
8686+ var unclaimed []*RemoteLease
8787+ for _, lease := range m.leases {
8888+ if !lease.orphaned {
8989+ continue
9090+ }
9191+ if sess := m.sessions[lease.nodeID]; sess == nil || sess.disconnected {
9292+ unclaimed = append(unclaimed, lease)
9393+ }
9494+ }
9595+ m.mu.Unlock()
9696+9797+ reason := "executor did not reconnect after mill restart"
9898+ for _, lease := range unclaimed {
9999+ m.l.Warn("failing unclaimed restored lease", "lease", lease.id, "node", lease.nodeID)
100100+ if err := m.finishOrphan(lease, string(models.StatusKindFailed), &reason, nil); err != nil {
101101+ m.l.Error("finish unclaimed restored lease", "lease", lease.id, "err", err)
102102+ }
103103+ }
104104+}
105105+106106+// reconcileOrphans fails restored leases the executor no longer holds. Only
107107+// orphaned leases are touched, so an in-flight bid can never race this.
108108+func (m *Mill) reconcileOrphans(nodeID string, activeLeaseIDs []string) error {
109109+ active := make(map[string]struct{}, len(activeLeaseIDs))
110110+ for _, id := range activeLeaseIDs {
111111+ active[id] = struct{}{}
112112+ }
113113+114114+ m.mu.Lock()
115115+ var gone []*RemoteLease
116116+ for _, lease := range m.leases {
117117+ if lease.orphaned && lease.nodeID == nodeID {
118118+ if _, ok := active[lease.id]; !ok {
119119+ gone = append(gone, lease)
120120+ }
121121+ }
122122+ }
123123+ m.mu.Unlock()
124124+125125+ reason := "executor no longer holds lease after mill restart"
126126+ for _, lease := range gone {
127127+ m.l.Warn("failing restored lease dropped by executor", "lease", lease.id, "node", nodeID)
128128+ if err := m.finishOrphan(lease, string(models.StatusKindFailed), &reason, nil); err != nil {
129129+ return err
130130+ }
131131+ }
132132+ return nil
133133+}
134134+135135+// finishOrphan authors the terminal status row (there is no RunStep waiter to
136136+// do it) and unwinds the lease. markDone makes duplicates no-ops.
137137+func (m *Mill) finishOrphan(lease *RemoteLease, status string, errMsg *string, exitCode *int64) error {
138138+ lease.finishMu.Lock()
139139+ defer lease.finishMu.Unlock()
140140+ if lease.getState() == leaseDone {
141141+ return nil
142142+ }
143143+ if m.db != nil {
144144+ if err := m.db.CompleteOrphanMillLease(
145145+ lease.id,
146146+ string(lease.wid.PipelineId.AtUri()),
147147+ lease.wid.Name,
148148+ status,
149149+ errMsg,
150150+ exitCode,
151151+ m.n,
152152+ ); err != nil {
153153+ return err
154154+ }
155155+ }
156156+ lease.markDone()
157157+ m.cleanupLease(lease)
158158+ return nil
159159+}