···11+package executor
22+33+import (
44+ "context"
55+ "io"
66+ "sync"
77+ "time"
88+99+ "github.com/hpcloud/tail"
1010+1111+ "tangled.org/core/api/tangled"
1212+ "tangled.org/core/spindle/models"
1313+ "tangled.org/core/spindle/secrets"
1414+1515+ millv1 "tangled.org/core/spindle/mill/proto/gen"
1616+)
1717+1818+// observeLoop watches the executor's own eventstream and relays the status rows
1919+// belonging to active leases. This is the "observe-by-cursor" relay: the jobs
2020+// write status to the real db/notifier exactly as a standalone spindle would,
2121+// and we forward what is relevant without touching the execution path.
2222+func (e *Executor) observeLoop(ctx context.Context) {
2323+ sub := e.n.Subscribe()
2424+ defer e.n.Unsubscribe(sub)
2525+2626+ // start past existing history; only relay rows produced from now on.
2727+ cursor := time.Now().UnixNano()
2828+2929+ drain := func() {
3030+ for {
3131+ evs, err := e.db.GetEvents(cursor, 100)
3232+ if err != nil {
3333+ e.l.Error("observe GetEvents failed", "err", err)
3434+ return
3535+ }
3636+ for _, ev := range evs {
3737+ cursor = ev.Created
3838+ if ev.Nsid != tangled.PipelineStatusNSID {
3939+ continue
4040+ }
4141+ st, ok := parseStatus(ev.EventJson)
4242+ if !ok {
4343+ continue
4444+ }
4545+ e.onStatusRow(st)
4646+ }
4747+ if len(evs) < 100 {
4848+ return
4949+ }
5050+ }
5151+ }
5252+5353+ // safety tick in case a notify is ever missed. The notifier is the primary
5454+ // wakeup; this only backstops a dropped signal, so it can be coarse.
5555+ ticker := time.NewTicker(5 * time.Second)
5656+ defer ticker.Stop()
5757+5858+ for {
5959+ select {
6060+ case <-ctx.Done():
6161+ return
6262+ case <-sub:
6363+ drain()
6464+ case <-ticker.C:
6565+ drain()
6666+ }
6767+ }
6868+}
6969+7070+func (e *Executor) onStatusRow(st *tangled.PipelineStatus) {
7171+ res := e.reservationFor(st.Pipeline, st.Workflow)
7272+ if res == nil {
7373+ return
7474+ }
7575+7676+ kind := models.StatusKind(st.Status)
7777+ switch {
7878+ case kind.IsFinish():
7979+ e.finishJob(res, st)
8080+ case kind == models.StatusKindRunning:
8181+ e.relayMu.Lock()
8282+ e.send(e.relay.appendStatus(res.leaseID, st, st.Pipeline))
8383+ e.relayMu.Unlock()
8484+ }
8585+}
8686+8787+// finishJob flushes the log tail, then relays the terminal as an AttemptResult
8888+// (which the mill turns into the terminal status row), then forgets the lease.
8989+// Tail flush happens first so all log offsets precede the terminal offset.
9090+func (e *Executor) finishJob(res *reservation, st *tangled.PipelineStatus) {
9191+ cleanup, cancelled, ok := e.finishReservation(res)
9292+ if !ok {
9393+ return
9494+ }
9595+ cleanup.run()
9696+9797+ e.relayMu.Lock()
9898+ terminalStatus := st.Status
9999+ if cancelled {
100100+ terminalStatus = string(models.StatusKindCancelled)
101101+ }
102102+ e.send(e.relay.appendTerminal(res.leaseID, terminalStatus, st))
103103+ e.relayMu.Unlock()
104104+105105+ e.pushSnapshot()
106106+}
107107+108108+// reservationFor finds the active reservation whose workflow matches a status
109109+// row, by pipeline aturi + workflow name. Active reservations are few (bounded
110110+// by seats), so a scan is cheaper than maintaining a parallel index.
111111+func (e *Executor) reservationFor(pipelineAturi, workflow string) *reservation {
112112+ e.mu.Lock()
113113+ defer e.mu.Unlock()
114114+ for _, res := range e.active {
115115+ if string(res.wid.PipelineId.AtUri()) == pipelineAturi && res.wid.Name == workflow {
116116+ return res
117117+ }
118118+ }
119119+ return nil
120120+}
121121+122122+func (e *Executor) relayLogLine(leaseID, text string) {
123123+ e.relayMu.Lock()
124124+ defer e.relayMu.Unlock()
125125+ e.send(e.relay.appendLog(leaseID, []byte(text)))
126126+}
127127+128128+// startTail follows the job's local log file and relays each line. The lines are
129129+// already-encoded models.LogLine JSON, so we forward them verbatim.
130130+func (e *Executor) startTail(res *reservation) {
131131+ path := models.LogFilePath(e.cfg.Server.LogDir, res.wid)
132132+ t, err := tail.TailFile(path, tail.Config{
133133+ Follow: true,
134134+ ReOpen: true,
135135+ MustExist: false,
136136+ Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekStart},
137137+ Logger: tail.DiscardingLogger,
138138+ })
139139+ if err != nil {
140140+ e.l.Error("tail log file failed", "wid", res.wid, "err", err)
141141+ return
142142+ }
143143+144144+ done := make(chan struct{})
145145+ go func() {
146146+ defer close(done)
147147+ for line := range t.Lines {
148148+ if line == nil || line.Err != nil {
149149+ continue
150150+ }
151151+ e.relayLogLine(res.leaseID, line.Text)
152152+ }
153153+ }()
154154+155155+ var once sync.Once
156156+ res.stopTail = func() {
157157+ once.Do(func() {
158158+ // Stop() blocks until tailing ends and t.Lines is closed, so the
159159+ // consumer goroutine then drains the remaining (finite) buffered
160160+ // lines and closes done. Waiting on done in full guarantees every
161161+ // log line is relayed before finishJob sends the terminal — without
162162+ // it a slow drain lets the terminal overtake a log line, which the
163163+ // mill then drops against its sealed log file.
164164+ _ = t.Stop()
165165+ <-done
166166+ })
167167+ }
168168+}
169169+170170+// memVault is a temporary in-memory secrets manager holding the secrets the
171171+// mill handed over at commit. They never touch disk on the executor.
172172+type memVault struct {
173173+ secrets []secrets.UnlockedSecret
174174+}
175175+176176+func newMemVault(pb []*millv1.Secret) *memVault {
177177+ s := make([]secrets.UnlockedSecret, len(pb))
178178+ for i, x := range pb {
179179+ s[i] = secrets.UnlockedSecret{Key: x.GetKey(), Value: x.GetValue()}
180180+ }
181181+ return &memVault{secrets: s}
182182+}
183183+184184+func (v *memVault) GetSecretsUnlocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.UnlockedSecret, error) {
185185+ return v.secrets, nil
186186+}
187187+func (v *memVault) GetSecretsLocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.LockedSecret, error) {
188188+ return nil, nil
189189+}
190190+func (v *memVault) AddSecret(ctx context.Context, s secrets.UnlockedSecret) error { return nil }
191191+func (v *memVault) RemoveSecret(ctx context.Context, s secrets.Secret[any]) error { return nil }
192192+193193+var _ secrets.Manager = (*memVault)(nil)
+157
spindle/mill/executor/relay.go
···11+package executor
22+33+import (
44+ "encoding/json"
55+ "sync"
66+77+ "tangled.org/core/api/tangled"
88+99+ millproto "tangled.org/core/spindle/mill/proto"
1010+ millv1 "tangled.org/core/spindle/mill/proto/gen"
1111+)
1212+1313+// relayLog is the per-session sequential event log the executor keeps so it can
1414+// replay anything the mill missed across a reconnect. Only mill-facing relay
1515+// messages (StatusEvent / LogLine / AttemptResult) get offsets and are buffered;
1616+// control messages do not.
1717+type relayLog struct {
1818+ mu sync.Mutex
1919+ entries []*millproto.Message
2020+ next uint64 // next offset to assign
2121+ acked uint64 // highest offset the mill has acked
2222+}
2323+2424+func newRelayLog() *relayLog {
2525+ return &relayLog{next: 1}
2626+}
2727+2828+// appendStatus stamps and buffers a relayed running-status event.
2929+func (rl *relayLog) appendStatus(leaseID string, st *tangled.PipelineStatus, pipelineAturi string) *millproto.Message {
3030+ rl.mu.Lock()
3131+ defer rl.mu.Unlock()
3232+ offset := rl.next
3333+ rl.next++
3434+ var exit int64
3535+ if st.ExitCode != nil {
3636+ exit = *st.ExitCode
3737+ }
3838+ var errStr string
3939+ if st.Error != nil {
4040+ errStr = *st.Error
4141+ }
4242+ msg := &millproto.Message{StatusEvent: &millv1.StatusEvent{
4343+ Offset: offset,
4444+ LeaseId: leaseID,
4545+ Status: st.Status,
4646+ Error: errStr,
4747+ ExitCode: exit,
4848+ Workflow: st.Workflow,
4949+ PipelineAturi: pipelineAturi,
5050+ }}
5151+ rl.entries = append(rl.entries, msg)
5252+ return msg
5353+}
5454+5555+// appendLog stamps and buffers a relayed log line.
5656+func (rl *relayLog) appendLog(leaseID string, raw []byte) *millproto.Message {
5757+ rl.mu.Lock()
5858+ defer rl.mu.Unlock()
5959+ offset := rl.next
6060+ rl.next++
6161+ msg := &millproto.Message{LogLine: &millv1.LogLine{
6262+ Offset: offset,
6363+ LeaseId: leaseID,
6464+ RawJson: raw,
6565+ }}
6666+ rl.entries = append(rl.entries, msg)
6767+ return msg
6868+}
6969+7070+// appendTerminal stamps and buffers the terminal attempt result.
7171+func (rl *relayLog) appendTerminal(leaseID, status string, st *tangled.PipelineStatus) *millproto.Message {
7272+ rl.mu.Lock()
7373+ defer rl.mu.Unlock()
7474+ offset := rl.next
7575+ rl.next++
7676+ var exit int64
7777+ var errStr string
7878+ if st != nil {
7979+ if st.ExitCode != nil {
8080+ exit = *st.ExitCode
8181+ }
8282+ if st.Error != nil {
8383+ errStr = *st.Error
8484+ }
8585+ }
8686+ msg := &millproto.Message{AttemptResult: &millv1.AttemptResult{
8787+ Offset: offset,
8888+ LeaseId: leaseID,
8989+ TerminalStatus: status,
9090+ Error: errStr,
9191+ ExitCode: exit,
9292+ }}
9393+ rl.entries = append(rl.entries, msg)
9494+ return msg
9595+}
9696+9797+// ack trims acked entries.
9898+func (rl *relayLog) ack(upTo uint64) {
9999+ rl.mu.Lock()
100100+ defer rl.mu.Unlock()
101101+ if upTo > rl.acked {
102102+ rl.acked = upTo
103103+ }
104104+ kept := rl.entries[:0]
105105+ for _, e := range rl.entries {
106106+ if offsetOf(e) > rl.acked {
107107+ kept = append(kept, e)
108108+ }
109109+ }
110110+ // nil the drained tail so acked messages can be GC'd (kept aliases the same
111111+ // backing array as entries).
112112+ for i := len(kept); i < len(rl.entries); i++ {
113113+ rl.entries[i] = nil
114114+ }
115115+ rl.entries = kept
116116+}
117117+118118+// since returns buffered entries with offset strictly greater than offset, for
119119+// replay on reconnect.
120120+func (rl *relayLog) since(offset uint64) []*millproto.Message {
121121+ rl.mu.Lock()
122122+ defer rl.mu.Unlock()
123123+ var out []*millproto.Message
124124+ for _, e := range rl.entries {
125125+ if offsetOf(e) > offset {
126126+ out = append(out, e)
127127+ }
128128+ }
129129+ return out
130130+}
131131+132132+func (rl *relayLog) lastOffset() uint64 {
133133+ rl.mu.Lock()
134134+ defer rl.mu.Unlock()
135135+ return rl.next - 1
136136+}
137137+138138+func offsetOf(m *millproto.Message) uint64 {
139139+ switch {
140140+ case m.GetStatusEvent() != nil:
141141+ return m.GetStatusEvent().GetOffset()
142142+ case m.GetLogLine() != nil:
143143+ return m.GetLogLine().GetOffset()
144144+ case m.GetAttemptResult() != nil:
145145+ return m.GetAttemptResult().GetOffset()
146146+ }
147147+ return 0
148148+}
149149+150150+// parseStatus decodes a PipelineStatus event row.
151151+func parseStatus(raw json.RawMessage) (*tangled.PipelineStatus, bool) {
152152+ var st tangled.PipelineStatus
153153+ if err := json.Unmarshal(raw, &st); err != nil {
154154+ return nil, false
155155+ }
156156+ return &st, true
157157+}
+66
spindle/mill/executor/reserved.go
···11+package executor
22+33+import (
44+ "context"
55+ "fmt"
66+ "sync"
77+ "time"
88+99+ "tangled.org/core/api/tangled"
1010+ "tangled.org/core/spindle/engine"
1111+ "tangled.org/core/spindle/models"
1212+ "tangled.org/core/spindle/secrets"
1313+)
1414+1515+// reservedEngine wraps a real engine so that the slot acquired up-front during
1616+// ReserveSeat is the slot StartWorkflows gets, instead of acquiring a second
1717+// one. Every other method delegates straight to the real engine, so the
1818+// execution path runs byte-for-byte as it would standalone.
1919+//
2020+// This is the only change to the execution path on an executor.
2121+type reservedEngine struct {
2222+ inner models.Engine
2323+ slot engine.WorkflowSlot
2424+ once sync.Once
2525+}
2626+2727+// newReservedEngine returns a wrapper around inner that hands back slot exactly
2828+// once from AcquireWorkflowSlot.
2929+func newReservedEngine(inner models.Engine, slot engine.WorkflowSlot) models.Engine {
3030+ return &reservedEngine{inner: inner, slot: slot}
3131+}
3232+3333+func (e *reservedEngine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
3434+ return e.inner.InitWorkflow(twf, tpl)
3535+}
3636+3737+func (e *reservedEngine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error {
3838+ return e.inner.SetupWorkflow(ctx, wid, wf, wfLogger)
3939+}
4040+4141+func (e *reservedEngine) WorkflowTimeout() time.Duration {
4242+ return e.inner.WorkflowTimeout()
4343+}
4444+4545+func (e *reservedEngine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
4646+ return e.inner.DestroyWorkflow(ctx, wid)
4747+}
4848+4949+func (e *reservedEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
5050+ return e.inner.RunStep(ctx, wid, w, idx, secrets, wfLogger)
5151+}
5252+5353+// AcquireWorkflowSlot hands back the pre-acquired slot exactly once. The slot
5454+// was already obtained (and resource-accounted) during ReserveSeat, so a second
5555+// acquire would double-count.
5656+func (e *reservedEngine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, _ engine.AcquireMode) (engine.WorkflowSlot, error) {
5757+ var slot engine.WorkflowSlot
5858+ e.once.Do(func() {
5959+ slot = e.slot
6060+ e.slot = nil
6161+ })
6262+ if slot == nil {
6363+ return nil, fmt.Errorf("reserved slot already consumed")
6464+ }
6565+ return slot, nil
6666+}