Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

spindle: rework db schema

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 82d62fcf a8a3163d

verified
+599 -329
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/pipelinestatus.go

This is a binary file and will not be displayed.

api/tangled/tangledpipeline.go

This is a binary file and will not be displayed.

+1
cmd/spindle/main.go
··· 6 6 7 7 "tangled.sh/tangled.sh/core/log" 8 8 "tangled.sh/tangled.sh/core/spindle" 9 + _ "tangled.sh/tangled.sh/core/tid" 9 10 ) 10 11 11 12 func main() {
+13 -18
lexicons/pipeline/status.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["pipeline", "status", "startedAt", "updatedAt"], 12 + "required": ["pipeline", "workflow", "status", "createdAt"], 13 13 "properties": { 14 14 "pipeline": { 15 15 "type": "string", 16 16 "format": "at-uri", 17 - "description": "pipeline at ref" 17 + "description": "ATURI of the pipeline" 18 + }, 19 + "workflow": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "name of the workflow within this pipeline" 18 23 }, 19 24 "status": { 20 25 "type": "string", 21 - "description": "Pipeline status", 26 + "description": "status of the workflow", 22 27 "enum": [ 23 28 "pending", 24 29 "running", ··· 33 28 "success" 34 29 ] 35 30 }, 31 + "createdAt": { 32 + "type": "string", 33 + "format": "datetime", 34 + "description": "time of creation of this status update" 35 + }, 36 36 "error": { 37 37 "type": "string", 38 38 "description": "error message if failed" ··· 45 35 "exitCode": { 46 36 "type": "integer", 47 37 "description": "exit code if failed" 48 - }, 49 - "startedAt": { 50 - "type": "string", 51 - "format": "datetime", 52 - "description": "pipeline start time" 53 - }, 54 - "updatedAt": { 55 - "type": "string", 56 - "format": "datetime", 57 - "description": "pipeline last updated time" 58 - }, 59 - "finishedAt": { 60 - "type": "string", 61 - "format": "datetime", 62 - "description": "pipeline finish time, if finished" 63 38 } 64 39 } 65 40 }
+5 -13
spindle/db/db.go
··· 30 30 did text primary key 31 31 ); 32 32 33 - create table if not exists pipeline_status ( 33 + -- status event for a single workflow 34 + create table if not exists events ( 34 35 rkey text not null, 35 - pipeline text not null, 36 - status text not null, 37 - 38 - -- only set if status is 'failed' 39 - error text, 40 - exit_code integer, 41 - 42 - started_at timestamp not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 43 - updated_at timestamp not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - finished_at timestamp, 45 - 46 - primary key (rkey) 36 + nsid text not null, 37 + event text not null, -- json 38 + created integer not null -- unix nanos 47 39 ); 48 40 `) 49 41 if err != nil {
+148
spindle/db/events.go
··· 1 + package db 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "time" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/notifier" 10 + "tangled.sh/tangled.sh/core/spindle/models" 11 + "tangled.sh/tangled.sh/core/tid" 12 + ) 13 + 14 + type Event struct { 15 + Rkey string `json:"rkey"` 16 + Nsid string `json:"nsid"` 17 + Created int64 `json:"created"` 18 + EventJson string `json:"event"` 19 + } 20 + 21 + func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 22 + _, err := d.Exec( 23 + `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 + event.Rkey, 25 + event.Nsid, 26 + event.EventJson, 27 + time.Now().UnixNano(), 28 + ) 29 + 30 + notifier.NotifyAll() 31 + 32 + return err 33 + } 34 + 35 + func (d *DB) GetEvents(cursor int64) ([]Event, error) { 36 + whereClause := "" 37 + args := []any{} 38 + if cursor > 0 { 39 + whereClause = "where created > ?" 40 + args = append(args, cursor) 41 + } 42 + 43 + query := fmt.Sprintf(` 44 + select rkey, nsid, event, created 45 + from events 46 + %s 47 + order by created asc 48 + limit 100 49 + `, whereClause) 50 + 51 + rows, err := d.Query(query, args...) 52 + if err != nil { 53 + return nil, err 54 + } 55 + defer rows.Close() 56 + 57 + var evts []Event 58 + for rows.Next() { 59 + var ev Event 60 + if err := rows.Scan(&ev.Rkey, &ev.Nsid, &ev.EventJson, &ev.Created); err != nil { 61 + return nil, err 62 + } 63 + evts = append(evts, ev) 64 + } 65 + 66 + if err := rows.Err(); err != nil { 67 + return nil, err 68 + } 69 + 70 + return evts, nil 71 + } 72 + 73 + func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 + eventJson, err := json.Marshal(s) 75 + if err != nil { 76 + return err 77 + } 78 + 79 + event := Event{ 80 + Rkey: rkey, 81 + Nsid: tangled.PipelineStatusNSID, 82 + Created: time.Now().UnixNano(), 83 + EventJson: string(eventJson), 84 + } 85 + 86 + return d.InsertEvent(event, n) 87 + } 88 + 89 + type StatusKind string 90 + 91 + var ( 92 + StatusKindPending StatusKind = "pending" 93 + StatusKindRunning StatusKind = "running" 94 + StatusKindFailed StatusKind = "failed" 95 + StatusKindTimeout StatusKind = "timeout" 96 + StatusKindCancelled StatusKind = "cancelled" 97 + StatusKindSuccess StatusKind = "success" 98 + ) 99 + 100 + func (d *DB) createStatusEvent( 101 + workflowId models.WorkflowId, 102 + statusKind StatusKind, 103 + workflowError *string, 104 + exitCode *int64, 105 + n *notifier.Notifier, 106 + ) error { 107 + now := time.Now() 108 + pipelineAtUri := workflowId.PipelineId.AtUri() 109 + s := tangled.PipelineStatus{ 110 + CreatedAt: now.Format(time.RFC3339), 111 + Error: workflowError, 112 + ExitCode: exitCode, 113 + Pipeline: string(pipelineAtUri), 114 + Workflow: workflowId.Name, 115 + Status: string(statusKind), 116 + } 117 + 118 + eventJson, err := json.Marshal(s) 119 + if err != nil { 120 + return err 121 + } 122 + 123 + event := Event{ 124 + Rkey: tid.TID(), 125 + Nsid: tangled.PipelineStatusNSID, 126 + Created: now.UnixNano(), 127 + EventJson: string(eventJson), 128 + } 129 + 130 + return d.InsertEvent(event, n) 131 + 132 + } 133 + 134 + func (d *DB) StatusPending(workflowId models.WorkflowId, n *notifier.Notifier) error { 135 + return d.createStatusEvent(workflowId, StatusKindPending, nil, nil, n) 136 + } 137 + 138 + func (d *DB) StatusRunning(workflowId models.WorkflowId, n *notifier.Notifier) error { 139 + return d.createStatusEvent(workflowId, StatusKindRunning, nil, nil, n) 140 + } 141 + 142 + func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 143 + return d.createStatusEvent(workflowId, StatusKindFailed, &workflowError, &exitCode, n) 144 + } 145 + 146 + func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error { 147 + return d.createStatusEvent(workflowId, StatusKindSuccess, nil, nil, n) 148 + }
+205 -177
spindle/db/pipelines.go
··· 1 1 package db 2 2 3 - import ( 4 - "fmt" 5 - "time" 6 - 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/notifier" 9 - ) 10 - 11 - type PipelineRunStatus string 12 - 13 - var ( 14 - PipelinePending PipelineRunStatus = "pending" 15 - PipelineRunning PipelineRunStatus = "running" 16 - PipelineFailed PipelineRunStatus = "failed" 17 - PipelineTimeout PipelineRunStatus = "timeout" 18 - PipelineCancelled PipelineRunStatus = "cancelled" 19 - PipelineSuccess PipelineRunStatus = "success" 20 - ) 21 - 22 - type PipelineStatus struct { 23 - Rkey string `json:"rkey"` 24 - Pipeline string `json:"pipeline"` 25 - Status PipelineRunStatus `json:"status"` 26 - 27 - // only if Failed 28 - Error string `json:"error"` 29 - ExitCode int `json:"exit_code"` 30 - 31 - StartedAt time.Time `json:"started_at"` 32 - UpdatedAt time.Time `json:"updated_at"` 33 - FinishedAt time.Time `json:"finished_at"` 34 - } 35 - 36 - func (p PipelineStatus) AsRecord() *tangled.PipelineStatus { 37 - exitCode64 := int64(p.ExitCode) 38 - finishedAt := p.FinishedAt.String() 39 - 40 - return &tangled.PipelineStatus{ 41 - LexiconTypeID: tangled.PipelineStatusNSID, 42 - Pipeline: p.Pipeline, 43 - Status: string(p.Status), 44 - 45 - ExitCode: &exitCode64, 46 - Error: &p.Error, 47 - 48 - StartedAt: p.StartedAt.String(), 49 - UpdatedAt: p.UpdatedAt.String(), 50 - FinishedAt: &finishedAt, 51 - } 52 - } 53 - 54 - func pipelineAtUri(rkey, knot string) string { 55 - return fmt.Sprintf("at://%s/did:web:%s/%s", tangled.PipelineStatusNSID, knot, rkey) 56 - } 57 - 58 - func (db *DB) CreatePipeline(rkey, pipeline string, n *notifier.Notifier) error { 59 - _, err := db.Exec(` 60 - insert into pipeline_status (rkey, status, pipeline) 61 - values (?, ?, ?) 62 - `, rkey, PipelinePending, pipeline) 63 - 64 - if err != nil { 65 - return err 66 - } 67 - n.NotifyAll() 68 - return nil 69 - } 70 - 71 - func (db *DB) MarkPipelineRunning(rkey string, n *notifier.Notifier) error { 72 - _, err := db.Exec(` 73 - update pipeline_status 74 - set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 75 - where rkey = ? 76 - `, PipelineRunning, rkey) 77 - 78 - if err != nil { 79 - return err 80 - } 81 - n.NotifyAll() 82 - return nil 83 - } 84 - 85 - func (db *DB) MarkPipelineFailed(rkey string, exitCode int, errorMsg string, n *notifier.Notifier) error { 86 - _, err := db.Exec(` 87 - update pipeline_status 88 - set status = ?, 89 - exit_code = ?, 90 - error = ?, 91 - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 92 - finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 93 - where rkey = ? 94 - `, PipelineFailed, exitCode, errorMsg, rkey) 95 - if err != nil { 96 - return err 97 - } 98 - n.NotifyAll() 99 - return nil 100 - } 101 - 102 - func (db *DB) MarkPipelineTimeout(rkey string, n *notifier.Notifier) error { 103 - _, err := db.Exec(` 104 - update pipeline_status 105 - set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 106 - where rkey = ? 107 - `, PipelineTimeout, rkey) 108 - if err != nil { 109 - return err 110 - } 111 - n.NotifyAll() 112 - return nil 113 - } 114 - 115 - func (db *DB) MarkPipelineSuccess(rkey string, n *notifier.Notifier) error { 116 - _, err := db.Exec(` 117 - update pipeline_status 118 - set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 119 - finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 120 - where rkey = ? 121 - `, PipelineSuccess, rkey) 122 - 123 - if err != nil { 124 - return err 125 - } 126 - n.NotifyAll() 127 - return nil 128 - } 129 - 130 - func (db *DB) GetPipelineStatus(rkey string) (PipelineStatus, error) { 131 - var p PipelineStatus 132 - err := db.QueryRow(` 133 - select rkey, status, error, exit_code, started_at, updated_at, finished_at 134 - from pipelines 135 - where rkey = ? 136 - `, rkey).Scan(&p.Rkey, &p.Status, &p.Error, &p.ExitCode, &p.StartedAt, &p.UpdatedAt, &p.FinishedAt) 137 - return p, err 138 - } 139 - 140 - func (db *DB) GetPipelineStatusAsRecords(cursor string) ([]PipelineStatus, error) { 141 - whereClause := "" 142 - args := []any{} 143 - if cursor != "" { 144 - whereClause = "where rkey > ?" 145 - args = append(args, cursor) 146 - } 147 - 148 - query := fmt.Sprintf(` 149 - select rkey, status, error, exit_code, started_at, updated_at, finished_at 150 - from pipeline_status 151 - %s 152 - order by rkey asc 153 - limit 100 154 - `, whereClause) 155 - 156 - rows, err := db.Query(query, args...) 157 - if err != nil { 158 - return nil, err 159 - } 160 - defer rows.Close() 161 - 162 - var pipelines []PipelineStatus 163 - for rows.Next() { 164 - var p PipelineStatus 165 - rows.Scan(&p.Rkey, &p.Status, &p.Error, &p.ExitCode, &p.StartedAt, &p.UpdatedAt, &p.FinishedAt) 166 - pipelines = append(pipelines, p) 167 - } 168 - 169 - if err := rows.Err(); err != nil { 170 - return nil, err 171 - } 172 - 173 - records := []*tangled.PipelineStatus{} 174 - for _, p := range pipelines { 175 - records = append(records, p.AsRecord()) 176 - } 177 - 178 - return pipelines, nil 179 - } 3 + // 4 + // import ( 5 + // "database/sql" 6 + // "fmt" 7 + // "time" 8 + // 9 + // "tangled.sh/tangled.sh/core/api/tangled" 10 + // "tangled.sh/tangled.sh/core/notifier" 11 + // ) 12 + // 13 + // type PipelineRunStatus string 14 + // 15 + // var ( 16 + // PipelinePending PipelineRunStatus = "pending" 17 + // PipelineRunning PipelineRunStatus = "running" 18 + // PipelineFailed PipelineRunStatus = "failed" 19 + // PipelineTimeout PipelineRunStatus = "timeout" 20 + // PipelineCancelled PipelineRunStatus = "cancelled" 21 + // PipelineSuccess PipelineRunStatus = "success" 22 + // ) 23 + // 24 + // type PipelineStatus struct { 25 + // Rkey string `json:"rkey"` 26 + // Pipeline string `json:"pipeline"` 27 + // Status PipelineRunStatus `json:"status"` 28 + // 29 + // // only if Failed 30 + // Error string `json:"error"` 31 + // ExitCode int `json:"exit_code"` 32 + // 33 + // LastUpdate int64 `json:"last_update"` 34 + // StartedAt time.Time `json:"started_at"` 35 + // UpdatedAt time.Time `json:"updated_at"` 36 + // FinishedAt time.Time `json:"finished_at"` 37 + // } 38 + // 39 + // func (p PipelineStatus) AsRecord() *tangled.PipelineStatus { 40 + // exitCode64 := int64(p.ExitCode) 41 + // finishedAt := p.FinishedAt.String() 42 + // 43 + // return &tangled.PipelineStatus{ 44 + // LexiconTypeID: tangled.PipelineStatusNSID, 45 + // Pipeline: p.Pipeline, 46 + // Status: string(p.Status), 47 + // 48 + // ExitCode: &exitCode64, 49 + // Error: &p.Error, 50 + // 51 + // StartedAt: p.StartedAt.String(), 52 + // UpdatedAt: p.UpdatedAt.String(), 53 + // FinishedAt: &finishedAt, 54 + // } 55 + // } 56 + // 57 + // func pipelineAtUri(rkey, knot string) string { 58 + // return fmt.Sprintf("at://%s/did:web:%s/%s", tangled.PipelineStatusNSID, knot, rkey) 59 + // } 60 + // 61 + // func (db *DB) CreatePipeline(rkey, pipeline string, n *notifier.Notifier) error { 62 + // _, err := db.Exec(` 63 + // insert into pipeline_status (rkey, status, pipeline, last_update) 64 + // values (?, ?, ?, ?) 65 + // `, rkey, PipelinePending, pipeline, time.Now().UnixNano()) 66 + // 67 + // if err != nil { 68 + // return err 69 + // } 70 + // n.NotifyAll() 71 + // return nil 72 + // } 73 + // 74 + // func (db *DB) MarkPipelineRunning(rkey string, n *notifier.Notifier) error { 75 + // _, err := db.Exec(` 76 + // update pipeline_status 77 + // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), last_update = ? 78 + // where rkey = ? 79 + // `, PipelineRunning, rkey, time.Now().UnixNano()) 80 + // 81 + // if err != nil { 82 + // return err 83 + // } 84 + // n.NotifyAll() 85 + // return nil 86 + // } 87 + // 88 + // func (db *DB) MarkPipelineFailed(rkey string, exitCode int, errorMsg string, n *notifier.Notifier) error { 89 + // _, err := db.Exec(` 90 + // update pipeline_status 91 + // set status = ?, 92 + // exit_code = ?, 93 + // error = ?, 94 + // updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 95 + // finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 96 + // last_update = ? 97 + // where rkey = ? 98 + // `, PipelineFailed, exitCode, errorMsg, rkey, time.Now().UnixNano()) 99 + // if err != nil { 100 + // return err 101 + // } 102 + // n.NotifyAll() 103 + // return nil 104 + // } 105 + // 106 + // func (db *DB) MarkPipelineTimeout(rkey string, n *notifier.Notifier) error { 107 + // _, err := db.Exec(` 108 + // update pipeline_status 109 + // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 110 + // where rkey = ? 111 + // `, PipelineTimeout, rkey) 112 + // if err != nil { 113 + // return err 114 + // } 115 + // n.NotifyAll() 116 + // return nil 117 + // } 118 + // 119 + // func (db *DB) MarkPipelineSuccess(rkey string, n *notifier.Notifier) error { 120 + // _, err := db.Exec(` 121 + // update pipeline_status 122 + // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 123 + // finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 124 + // where rkey = ? 125 + // `, PipelineSuccess, rkey) 126 + // 127 + // if err != nil { 128 + // return err 129 + // } 130 + // n.NotifyAll() 131 + // return nil 132 + // } 133 + // 134 + // func (db *DB) GetPipelineStatus(rkey string) (PipelineStatus, error) { 135 + // var p PipelineStatus 136 + // err := db.QueryRow(` 137 + // select rkey, status, error, exit_code, started_at, updated_at, finished_at 138 + // from pipelines 139 + // where rkey = ? 140 + // `, rkey).Scan(&p.Rkey, &p.Status, &p.Error, &p.ExitCode, &p.StartedAt, &p.UpdatedAt, &p.FinishedAt) 141 + // return p, err 142 + // } 143 + // 144 + // func (db *DB) GetPipelineStatusAsRecords(cursor int64) ([]PipelineStatus, error) { 145 + // whereClause := "" 146 + // args := []any{} 147 + // if cursor != 0 { 148 + // whereClause = "where created_at > ?" 149 + // args = append(args, cursor) 150 + // } 151 + // 152 + // query := fmt.Sprintf(` 153 + // select rkey, status, error, exit_code, created_at, started_at, updated_at, finished_at 154 + // from pipeline_status 155 + // %s 156 + // order by created_at asc 157 + // limit 100 158 + // `, whereClause) 159 + // 160 + // rows, err := db.Query(query, args...) 161 + // if err != nil { 162 + // return nil, err 163 + // } 164 + // defer rows.Close() 165 + // 166 + // var pipelines []PipelineStatus 167 + // for rows.Next() { 168 + // var p PipelineStatus 169 + // var pipelineError sql.NullString 170 + // var exitCode sql.NullInt64 171 + // var startedAt, updatedAt string 172 + // var finishedAt sql.NullTime 173 + // 174 + // err := rows.Scan(&p.Rkey, &p.Status, &pipelineError, &exitCode, &p.LastUpdate, &startedAt, &updatedAt, &finishedAt) 175 + // if err != nil { 176 + // return nil, err 177 + // } 178 + // 179 + // if pipelineError.Valid { 180 + // p.Error = pipelineError.String 181 + // } 182 + // 183 + // if exitCode.Valid { 184 + // p.ExitCode = int(exitCode.Int64) 185 + // } 186 + // 187 + // if v, err := time.Parse(time.RFC3339, startedAt); err == nil { 188 + // p.StartedAt = v 189 + // } 190 + // 191 + // if v, err := time.Parse(time.RFC3339, updatedAt); err == nil { 192 + // p.UpdatedAt = v 193 + // } 194 + // 195 + // if finishedAt.Valid { 196 + // p.FinishedAt = finishedAt.Time 197 + // } 198 + // 199 + // pipelines = append(pipelines, p) 200 + // } 201 + // 202 + // if err := rows.Err(); err != nil { 203 + // return nil, err 204 + // } 205 + // 206 + // return pipelines, nil 207 + // }
+83 -73
spindle/engine/engine.go
··· 10 10 "path" 11 11 "strings" 12 12 "sync" 13 - "syscall" 14 13 15 14 "github.com/docker/docker/api/types/container" 16 15 "github.com/docker/docker/api/types/image" ··· 18 19 "github.com/docker/docker/api/types/volume" 19 20 "github.com/docker/docker/client" 20 21 "github.com/docker/docker/pkg/stdcopy" 21 - "golang.org/x/sync/errgroup" 22 22 "tangled.sh/tangled.sh/core/api/tangled" 23 23 "tangled.sh/tangled.sh/core/log" 24 24 "tangled.sh/tangled.sh/core/notifier" 25 25 "tangled.sh/tangled.sh/core/spindle/db" 26 + "tangled.sh/tangled.sh/core/spindle/models" 26 27 ) 27 28 28 29 const ( ··· 68 69 return e, nil 69 70 } 70 71 71 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *tangled.Pipeline, id string) error { 72 - e.l.Info("starting all workflows in parallel", "pipeline", id) 72 + func (e *Engine) StartWorkflows(ctx context.Context, pipeline *tangled.Pipeline, pipelineId models.PipelineId) { 73 + e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 73 74 74 - err := e.db.MarkPipelineRunning(id, e.n) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - g := errgroup.Group{} 75 + wg := sync.WaitGroup{} 80 76 for _, w := range pipeline.Workflows { 81 - g.Go(func() error { 82 - err := e.SetupWorkflow(ctx, id, w.Name) 77 + wg.Add(1) 78 + go func() error { 79 + defer wg.Done() 80 + wid := models.WorkflowId{ 81 + PipelineId: pipelineId, 82 + Name: w.Name, 83 + } 84 + 85 + err := e.db.StatusRunning(wid, e.n) 83 86 if err != nil { 84 87 return err 85 88 } 86 89 87 - defer e.DestroyWorkflow(ctx, id, w.Name) 90 + err = e.SetupWorkflow(ctx, wid) 91 + if err != nil { 92 + e.l.Error("setting up worklow", "wid", wid, "err", err) 93 + return err 94 + } 95 + defer e.DestroyWorkflow(ctx, wid) 88 96 89 97 // TODO: actual checks for image/registry etc. 90 98 var deps string ··· 107 101 cimg := path.Join("nixery.dev", deps) 108 102 reader, err := e.docker.ImagePull(ctx, cimg, image.PullOptions{}) 109 103 if err != nil { 110 - e.l.Error("pipeline failed!", "id", id, "error", err.Error()) 111 - err := e.db.MarkPipelineFailed(id, -1, err.Error(), e.n) 104 + e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error()) 105 + 106 + err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 112 107 if err != nil { 113 108 return err 114 109 } 110 + 115 111 return fmt.Errorf("pulling image: %w", err) 116 112 } 117 113 defer reader.Close() 118 114 io.Copy(os.Stdout, reader) 119 115 120 - err = e.StartSteps(ctx, w.Steps, w.Name, id, cimg) 116 + err = e.StartSteps(ctx, w.Steps, wid, cimg) 121 117 if err != nil { 122 - e.l.Error("pipeline failed!", "id", id, "error", err.Error()) 123 - return e.db.MarkPipelineFailed(id, -1, err.Error(), e.n) 118 + e.l.Error("workflow failed!", "wid", wid.String(), "error", err.Error()) 119 + 120 + err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 121 + if err != nil { 122 + return err 123 + } 124 + } 125 + 126 + err = e.db.StatusSuccess(wid, e.n) 127 + if err != nil { 128 + return err 124 129 } 125 130 126 131 return nil 127 - }) 132 + }() 128 133 } 129 134 130 - err = g.Wait() 131 - if err != nil { 132 - e.l.Error("pipeline failed!", "id", id, "error", err.Error()) 133 - return e.db.MarkPipelineFailed(id, -1, err.Error(), e.n) 134 - } 135 - 136 - e.l.Info("pipeline success!", "id", id) 137 - return e.db.MarkPipelineSuccess(id, e.n) 135 + wg.Wait() 138 136 } 139 137 140 138 // SetupWorkflow sets up a new network for the workflow and volumes for 141 139 // the workspace and Nix store. These are persisted across steps and are 142 140 // destroyed at the end of the workflow. 143 - func (e *Engine) SetupWorkflow(ctx context.Context, id, workflowName string) error { 144 - e.l.Info("setting up workflow", "pipeline", id, "workflow", workflowName) 141 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 142 + e.l.Info("setting up workflow", "workflow", wid) 145 143 146 144 _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 147 - Name: workspaceVolume(id, workflowName), 145 + Name: workspaceVolume(wid), 148 146 Driver: "local", 149 147 }) 150 148 if err != nil { 151 149 return err 152 150 } 153 - e.registerCleanup(id, workflowName, func(ctx context.Context) error { 154 - return e.docker.VolumeRemove(ctx, workspaceVolume(id, workflowName), true) 151 + e.registerCleanup(wid, func(ctx context.Context) error { 152 + return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 155 153 }) 156 154 157 155 _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 158 - Name: nixVolume(id, workflowName), 156 + Name: nixVolume(wid), 159 157 Driver: "local", 160 158 }) 161 159 if err != nil { 162 160 return err 163 161 } 164 - e.registerCleanup(id, workflowName, func(ctx context.Context) error { 165 - return e.docker.VolumeRemove(ctx, nixVolume(id, workflowName), true) 162 + e.registerCleanup(wid, func(ctx context.Context) error { 163 + return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 166 164 }) 167 165 168 - _, err = e.docker.NetworkCreate(ctx, networkName(id, workflowName), network.CreateOptions{ 166 + _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 169 167 Driver: "bridge", 170 168 }) 171 169 if err != nil { 172 170 return err 173 171 } 174 - e.registerCleanup(id, workflowName, func(ctx context.Context) error { 175 - return e.docker.NetworkRemove(ctx, networkName(id, workflowName)) 172 + e.registerCleanup(wid, func(ctx context.Context) error { 173 + return e.docker.NetworkRemove(ctx, networkName(wid)) 176 174 }) 177 175 178 176 return nil ··· 185 175 // StartSteps starts all steps sequentially with the same base image. 186 176 // ONLY marks pipeline as failed if container's exit code is non-zero. 187 177 // All other errors are bubbled up. 188 - func (e *Engine) StartSteps(ctx context.Context, steps []*tangled.Pipeline_Step, workflowName, id, image string) error { 178 + func (e *Engine) StartSteps(ctx context.Context, steps []*tangled.Pipeline_Step, wid models.WorkflowId, image string) error { 189 179 // set up logging channels 190 180 e.chanMu.Lock() 191 - if _, exists := e.stdoutChans[id]; !exists { 192 - e.stdoutChans[id] = make(chan string, 100) 181 + if _, exists := e.stdoutChans[wid.String()]; !exists { 182 + e.stdoutChans[wid.String()] = make(chan string, 100) 193 183 } 194 - if _, exists := e.stderrChans[id]; !exists { 195 - e.stderrChans[id] = make(chan string, 100) 184 + if _, exists := e.stderrChans[wid.String()]; !exists { 185 + e.stderrChans[wid.String()] = make(chan string, 100) 196 186 } 197 187 e.chanMu.Unlock() 198 188 199 189 // close channels after all steps are complete 200 190 defer func() { 201 - close(e.stdoutChans[id]) 202 - close(e.stderrChans[id]) 191 + close(e.stdoutChans[wid.String()]) 192 + close(e.stderrChans[wid.String()]) 203 193 }() 204 194 205 195 for _, step := range steps { 206 - hostConfig := hostConfig(id, workflowName) 196 + hostConfig := hostConfig(wid) 207 197 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 208 198 Image: image, 209 199 Cmd: []string{"bash", "-c", step.Command}, ··· 216 206 return fmt.Errorf("creating container: %w", err) 217 207 } 218 208 219 - err = e.docker.NetworkConnect(ctx, networkName(id, workflowName), resp.ID, nil) 209 + err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 220 210 if err != nil { 221 211 return fmt.Errorf("connecting network: %w", err) 222 212 } ··· 232 222 wg.Add(1) 233 223 go func() { 234 224 defer wg.Done() 235 - err := e.TailStep(ctx, resp.ID, id) 225 + err := e.TailStep(ctx, resp.ID, wid) 236 226 if err != nil { 237 227 e.l.Error("failed to tail container", "container", resp.ID) 238 228 return ··· 247 237 return err 248 238 } 249 239 250 - err = e.DestroyStep(ctx, resp.ID, id) 240 + err = e.DestroyStep(ctx, resp.ID) 251 241 if err != nil { 252 242 return err 253 243 } 254 244 255 245 if state.ExitCode != 0 { 256 - e.l.Error("pipeline failed!", "id", id, "error", state.Error, "exit_code", state.ExitCode) 257 - return e.db.MarkPipelineFailed(id, state.ExitCode, state.Error, e.n) 246 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode) 247 + // return e.db.MarkPipelineFailed(id, state.ExitCode, state.Error, e.n) 258 248 } 259 249 } 260 250 ··· 282 272 return info.State, nil 283 273 } 284 274 285 - func (e *Engine) TailStep(ctx context.Context, containerID, pipelineID string) error { 275 + func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId) error { 286 276 logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 287 277 Follow: true, 288 278 ShowStdout: true, ··· 318 308 // once all steps are done. 319 309 go func() { 320 310 e.chanMu.RLock() 321 - stdoutCh := e.stdoutChans[pipelineID] 311 + stdoutCh := e.stdoutChans[wid.String()] 322 312 e.chanMu.RUnlock() 323 313 324 314 scanner := bufio.NewScanner(rpipeOut) ··· 335 325 // once all steps are done. 336 326 go func() { 337 327 e.chanMu.RLock() 338 - stderrCh := e.stderrChans[pipelineID] 328 + stderrCh := e.stderrChans[wid.String()] 339 329 e.chanMu.RUnlock() 340 330 341 331 scanner := bufio.NewScanner(rpipeErr) ··· 350 340 return nil 351 341 } 352 342 353 - func (e *Engine) DestroyStep(ctx context.Context, containerID, pipelineID string) error { 354 - err := e.docker.ContainerKill(ctx, containerID, syscall.SIGKILL.String()) 343 + func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 344 + err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 355 345 if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 356 346 return err 357 347 } ··· 367 357 return nil 368 358 } 369 359 370 - func (e *Engine) DestroyWorkflow(ctx context.Context, pipelineID, workflowName string) error { 360 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 371 361 e.cleanupMu.Lock() 372 - key := fmt.Sprintf("%s-%s", pipelineID, workflowName) 362 + key := wid.String() 373 363 374 364 fns := e.cleanup[key] 375 365 delete(e.cleanup, key) ··· 377 367 378 368 for _, fn := range fns { 379 369 if err := fn(ctx); err != nil { 380 - e.l.Error("failed to cleanup workflow resource", "pipeline", pipelineID, "workflow", workflowName, "err", err) 370 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid) 381 371 } 382 372 } 383 373 return nil 384 374 } 385 375 386 - func (e *Engine) LogChannels(pipelineID string) (stdout <-chan string, stderr <-chan string, ok bool) { 376 + func (e *Engine) LogChannels(wid models.WorkflowId) (stdout <-chan string, stderr <-chan string, ok bool) { 387 377 e.chanMu.RLock() 388 378 defer e.chanMu.RUnlock() 389 379 390 - stdoutCh, ok1 := e.stdoutChans[pipelineID] 391 - stderrCh, ok2 := e.stderrChans[pipelineID] 380 + stdoutCh, ok1 := e.stdoutChans[wid.String()] 381 + stderrCh, ok2 := e.stderrChans[wid.String()] 392 382 393 383 if !ok1 || !ok2 { 394 384 return nil, nil, false ··· 396 386 return stdoutCh, stderrCh, true 397 387 } 398 388 399 - func (e *Engine) registerCleanup(pipelineID, workflowName string, fn cleanupFunc) { 389 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 400 390 e.cleanupMu.Lock() 401 391 defer e.cleanupMu.Unlock() 402 392 403 - key := fmt.Sprintf("%s-%s", pipelineID, workflowName) 393 + key := wid.String() 404 394 e.cleanup[key] = append(e.cleanup[key], fn) 405 395 } 406 396 407 - func workspaceVolume(id, name string) string { 408 - return fmt.Sprintf("workspace-%s-%s", id, name) 397 + func workspaceVolume(wid models.WorkflowId) string { 398 + return fmt.Sprintf("workspace-%s", wid) 409 399 } 410 400 411 - func nixVolume(id, name string) string { 412 - return fmt.Sprintf("nix-%s-%s", id, name) 401 + func nixVolume(wid models.WorkflowId) string { 402 + return fmt.Sprintf("nix-%s", wid) 413 403 } 414 404 415 - func networkName(id, name string) string { 416 - return fmt.Sprintf("workflow-network-%s-%s", id, name) 405 + func networkName(wid models.WorkflowId) string { 406 + return fmt.Sprintf("workflow-network-%s", wid) 417 407 } 418 408 419 - func hostConfig(id, name string) *container.HostConfig { 409 + func hostConfig(wid models.WorkflowId) *container.HostConfig { 420 410 hostConfig := &container.HostConfig{ 421 411 Mounts: []mount.Mount{ 422 412 { 423 413 Type: mount.TypeVolume, 424 - Source: workspaceVolume(id, name), 414 + Source: workspaceVolume(wid), 425 415 Target: workspaceDir, 426 416 }, 427 417 { 428 418 Type: mount.TypeVolume, 429 - Source: nixVolume(id, name), 419 + Source: nixVolume(wid), 430 420 Target: "/nix", 431 421 }, 432 422 },
+37
spindle/models/models.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "regexp" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + var ( 13 + re = regexp.MustCompile(`[^a-zA-Z0-9_.-]`) 14 + ) 15 + 16 + type PipelineId struct { 17 + Knot string 18 + Rkey string 19 + } 20 + 21 + func (p *PipelineId) AtUri() syntax.ATURI { 22 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 23 + } 24 + 25 + type WorkflowId struct { 26 + PipelineId 27 + Name string 28 + } 29 + 30 + func (wid WorkflowId) String() string { 31 + return fmt.Sprintf("%s-%s-%s", normalize(wid.Knot), wid.Rkey, normalize(wid.Name)) 32 + } 33 + 34 + func normalize(name string) string { 35 + normalized := re.ReplaceAllString(name, "-") 36 + return normalized 37 + }
+29 -11
spindle/queue/queue.go
··· 1 1 package queue 2 2 3 + import ( 4 + "sync" 5 + ) 6 + 3 7 type Job struct { 4 8 Run func() error 5 9 OnFail func(error) 6 10 } 7 11 8 12 type Queue struct { 9 - jobs chan Job 13 + jobs chan Job 14 + workers int 15 + wg sync.WaitGroup 10 16 } 11 17 12 - func NewQueue(size int) *Queue { 18 + func NewQueue(queueSize, numWorkers int) *Queue { 13 19 return &Queue{ 14 - jobs: make(chan Job, size), 20 + jobs: make(chan Job, queueSize), 21 + workers: numWorkers, 15 22 } 16 23 } 17 24 ··· 31 24 } 32 25 } 33 26 34 - func (q *Queue) StartRunner() { 35 - go func() { 36 - for job := range q.jobs { 37 - if err := job.Run(); err != nil { 38 - if job.OnFail != nil { 39 - job.OnFail(err) 40 - } 27 + func (q *Queue) Start() { 28 + for range q.workers { 29 + q.wg.Add(1) 30 + go q.worker() 31 + } 32 + } 33 + 34 + func (q *Queue) worker() { 35 + defer q.wg.Done() 36 + for job := range q.jobs { 37 + if err := job.Run(); err != nil { 38 + if job.OnFail != nil { 39 + job.OnFail(err) 41 40 } 42 41 } 43 - }() 42 + } 43 + } 44 + 45 + func (q *Queue) Stop() { 46 + close(q.jobs) 47 + q.wg.Wait() 44 48 }
+23 -13
spindle/server.go
··· 18 18 "tangled.sh/tangled.sh/core/spindle/config" 19 19 "tangled.sh/tangled.sh/core/spindle/db" 20 20 "tangled.sh/tangled.sh/core/spindle/engine" 21 + "tangled.sh/tangled.sh/core/spindle/models" 21 22 "tangled.sh/tangled.sh/core/spindle/queue" 22 23 ) 23 24 ··· 62 61 return err 63 62 } 64 63 65 - jq := queue.NewQueue(100) 64 + jq := queue.NewQueue(100, 2) 66 65 67 66 // starts a job queue runner in the background 68 - jq.StartRunner() 67 + jq.Start() 68 + defer jq.Stop() 69 69 70 70 spindle := Spindle{ 71 71 jc: jc, ··· 111 109 mux := chi.NewRouter() 112 110 113 111 mux.HandleFunc("/events", s.Events) 114 - mux.HandleFunc("/logs/{pipelineID}", s.Logs) 112 + mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 115 113 return mux 116 114 } 117 115 ··· 124 122 return err 125 123 } 126 124 127 - ok := s.jq.Enqueue(queue.Job{ 128 - Run: func() error { 129 - // this is a "fake" at uri for now 130 - pipelineAtUri := fmt.Sprintf("at://%s/did:web:%s/%s", tangled.PipelineNSID, pipeline.TriggerMetadata.Repo.Knot, msg.Rkey) 125 + pipelineId := models.PipelineId{ 126 + Knot: src.Knot, 127 + Rkey: msg.Rkey, 128 + } 131 129 132 - rkey := TID() 133 - 134 - err = s.db.CreatePipeline(rkey, pipelineAtUri, s.n) 130 + for _, w := range pipeline.Workflows { 131 + if w != nil { 132 + err := s.db.StatusPending(models.WorkflowId{ 133 + PipelineId: pipelineId, 134 + Name: w.Name, 135 + }, s.n) 135 136 if err != nil { 136 137 return err 137 138 } 139 + } 140 + } 138 141 139 - return s.eng.StartWorkflows(ctx, &pipeline, rkey) 142 + ok := s.jq.Enqueue(queue.Job{ 143 + Run: func() error { 144 + s.eng.StartWorkflows(ctx, &pipeline, pipelineId) 145 + return nil 140 146 }, 141 - OnFail: func(error) { 142 - s.l.Error("pipeline run failed", "error", err) 147 + OnFail: func(jobError error) { 148 + s.l.Error("pipeline run failed", "error", jobError) 143 149 }, 144 150 }) 145 151 if ok {
+54 -23
spindle/stream.go
··· 1 1 package spindle 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "net/http" 7 + "strconv" 6 8 "time" 7 9 8 - "context" 10 + "tangled.sh/tangled.sh/core/spindle/models" 9 11 10 12 "github.com/go-chi/chi/v5" 11 13 "github.com/gorilla/websocket" ··· 20 18 21 19 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 22 20 l := s.l.With("handler", "Events") 23 - l.Info("received new connection") 21 + l.Debug("received new connection") 24 22 25 23 conn, err := upgrader.Upgrade(w, r, nil) 26 24 if err != nil { ··· 29 27 return 30 28 } 31 29 defer conn.Close() 32 - l.Info("upgraded http to wss") 30 + l.Debug("upgraded http to wss") 33 31 34 32 ch := s.n.Subscribe() 35 33 defer s.n.Unsubscribe(ch) ··· 46 44 } 47 45 }() 48 46 49 - cursor := "" 47 + defaultCursor := time.Now().UnixNano() 48 + cursorStr := r.URL.Query().Get("cursor") 49 + cursor, err := strconv.ParseInt(cursorStr, 10, 64) 50 + if err != nil { 51 + l.Error("empty or invalid cursor", "invalidCursor", cursorStr, "default", defaultCursor) 52 + } 53 + if cursor == 0 { 54 + cursor = defaultCursor 55 + } 50 56 51 57 // complete backfill first before going to live data 52 - l.Info("going through backfill", "cursor", cursor) 58 + l.Debug("going through backfill", "cursor", cursor) 53 59 if err := s.streamPipelines(conn, &cursor); err != nil { 54 60 l.Error("failed to backfill", "err", err) 55 61 return ··· 67 57 // wait for new data or timeout 68 58 select { 69 59 case <-ctx.Done(): 70 - l.Info("stopping stream: client closed connection") 60 + l.Debug("stopping stream: client closed connection") 71 61 return 72 62 case <-ch: 73 63 // we have been notified of new data 74 - l.Info("going through live data", "cursor", cursor) 64 + l.Debug("going through live data", "cursor", cursor) 75 65 if err := s.streamPipelines(conn, &cursor); err != nil { 76 66 l.Error("failed to stream", "err", err) 77 67 return 78 68 } 79 69 case <-time.After(30 * time.Second): 80 70 // send a keep-alive 81 - l.Info("sent keepalive") 71 + l.Debug("sent keepalive") 82 72 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 83 73 l.Error("failed to write control", "err", err) 84 74 } ··· 89 79 func (s *Spindle) Logs(w http.ResponseWriter, r *http.Request) { 90 80 l := s.l.With("handler", "Logs") 91 81 92 - pipelineID := chi.URLParam(r, "pipelineID") 93 - if pipelineID == "" { 94 - http.Error(w, "pipelineID required", http.StatusBadRequest) 82 + knot := chi.URLParam(r, "knot") 83 + if knot == "" { 84 + http.Error(w, "knot required", http.StatusBadRequest) 95 85 return 96 86 } 97 - l = l.With("pipelineID", pipelineID) 87 + 88 + rkey := chi.URLParam(r, "rkey") 89 + if rkey == "" { 90 + http.Error(w, "rkey required", http.StatusBadRequest) 91 + return 92 + } 93 + 94 + name := chi.URLParam(r, "name") 95 + if name == "" { 96 + http.Error(w, "name required", http.StatusBadRequest) 97 + return 98 + } 99 + 100 + wid := models.WorkflowId{ 101 + PipelineId: models.PipelineId{ 102 + Knot: knot, 103 + Rkey: rkey, 104 + }, 105 + Name: name, 106 + } 107 + 108 + l = l.With("knot", knot, "rkey", rkey, "name", name) 98 109 99 110 conn, err := upgrader.Upgrade(w, r, nil) 100 111 if err != nil { ··· 124 93 return 125 94 } 126 95 defer conn.Close() 127 - l.Info("upgraded http to wss") 96 + l.Debug("upgraded http to wss") 128 97 129 98 ctx, cancel := context.WithCancel(r.Context()) 130 99 defer cancel() ··· 132 101 go func() { 133 102 for { 134 103 if _, _, err := conn.NextReader(); err != nil { 135 - l.Info("client disconnected", "err", err) 104 + l.Debug("client disconnected", "err", err) 136 105 cancel() 137 106 return 138 107 } 139 108 } 140 109 }() 141 110 142 - if err := s.streamLogs(ctx, conn, pipelineID); err != nil { 111 + if err := s.streamLogs(ctx, conn, wid); err != nil { 143 112 l.Error("streamLogs failed", "err", err) 144 113 } 145 - l.Info("logs connection closed") 114 + l.Debug("logs connection closed") 146 115 } 147 116 148 - func (s *Spindle) streamLogs(ctx context.Context, conn *websocket.Conn, pipelineID string) error { 149 - l := s.l.With("pipelineID", pipelineID) 117 + func (s *Spindle) streamLogs(ctx context.Context, conn *websocket.Conn, wid models.WorkflowId) error { 118 + l := s.l.With("workflow_id", wid.String()) 150 119 151 - stdoutCh, stderrCh, ok := s.eng.LogChannels(pipelineID) 120 + stdoutCh, stderrCh, ok := s.eng.LogChannels(wid) 152 121 if !ok { 153 - return fmt.Errorf("pipelineID %q not found", pipelineID) 122 + return fmt.Errorf("workflow_id %q not found", wid.String()) 154 123 } 155 124 156 125 done := make(chan struct{}) ··· 205 174 return nil 206 175 } 207 176 208 - func (s *Spindle) streamPipelines(conn *websocket.Conn, cursor *string) error { 209 - ops, err := s.db.GetPipelineStatusAsRecords(*cursor) 177 + func (s *Spindle) streamPipelines(conn *websocket.Conn, cursor *int64) error { 178 + ops, err := s.db.GetEvents(*cursor) 210 179 if err != nil { 211 180 s.l.Debug("err", "err", err) 212 181 return err ··· 218 187 s.l.Debug("err", "err", err) 219 188 return err 220 189 } 221 - *cursor = op.Rkey 190 + *cursor = op.Created 222 191 } 223 192 224 193 return nil
+1 -1
spindle/tid.go tid/tid.go
··· 1 - package spindle 1 + package tid 2 2 3 3 import "github.com/bluesky-social/indigo/atproto/syntax" 4 4