forked from tangled.org/core
Monorepo for Tangled

appview,spindle: handle graceful shutdown of websockets

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

oppi.li 9243b43c 64e72958

verified
Changed files
+100 -34
appview
pipelines
spindle
+26 -3
appview/pipelines/pipelines.go
··· 158 158 l.Error("websocket upgrade failed", "err", err) 159 159 return 160 160 } 161 - defer clientConn.Close() 161 + defer func() { 162 + _ = clientConn.WriteControl( 163 + websocket.CloseMessage, 164 + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"), 165 + time.Now().Add(time.Second), 166 + ) 167 + clientConn.Close() 168 + }() 162 169 163 170 ctx, cancel := context.WithCancel(r.Context()) 164 171 defer cancel() ··· 236 243 // start a goroutine to read from spindle 237 244 go func() { 238 245 defer close(msgChan) 246 + defer close(errChan) 247 + 239 248 for { 240 249 _, msg, err := spindleConn.ReadMessage() 241 250 if err != nil { 242 - errChan <- err 251 + if websocket.IsCloseError(err, 252 + websocket.CloseNormalClosure, 253 + websocket.CloseGoingAway, 254 + websocket.CloseAbnormalClosure) { 255 + errChan <- nil // signal graceful end 256 + } else { 257 + errChan <- err 258 + } 243 259 return 244 260 } 245 261 msgChan <- msg ··· 252 268 l.Info("client disconnected") 253 269 return 254 270 case err := <-errChan: 255 - l.Error("error reading from spindle", "err", err) 271 + if err != nil { 272 + l.Error("error reading from spindle", "err", err) 273 + } 274 + 275 + if err == nil { 276 + l.Info("log tail complete") 277 + } 278 + 256 279 return 257 280 case msg := <-msgChan: 258 281 var logLine spindlemodel.LogLine
+34
spindle/db/events.go
··· 120 120 121 121 } 122 122 123 + func (d *DB) GetStatus(workflowId models.WorkflowId) (*tangled.PipelineStatus, error) { 124 + pipelineAtUri := workflowId.PipelineId.AtUri() 125 + 126 + var eventJson string 127 + err := d.QueryRow( 128 + ` 129 + select 130 + event from events 131 + where 132 + nsid = ? 133 + and json_extract(event, '$.pipeline') = ? 134 + and json_extract(event, '$.workflow') = ? 135 + order by 136 + created desc 137 + limit 138 + 1 139 + `, 140 + tangled.PipelineStatusNSID, 141 + string(pipelineAtUri), 142 + workflowId.Name, 143 + ).Scan(&eventJson) 144 + 145 + if err != nil { 146 + return nil, err 147 + } 148 + 149 + var status tangled.PipelineStatus 150 + if err := json.Unmarshal([]byte(eventJson), &status); err != nil { 151 + return nil, err 152 + } 153 + 154 + return &status, nil 155 + } 156 + 123 157 func (d *DB) StatusPending(workflowId models.WorkflowId, n *notifier.Notifier) error { 124 158 return d.createStatusEvent(workflowId, models.StatusKindPending, nil, nil, n) 125 159 }
+5 -1
spindle/engine/engine.go
··· 326 326 } 327 327 defer wfLogger.Close() 328 328 329 - _, err = stdcopy.StdCopy(wfLogger.Stdout(), wfLogger.Stderr(), logs) 329 + _, err = stdcopy.StdCopy( 330 + wfLogger.Writer("stdout", stepIdx), 331 + wfLogger.Writer("stderr", stepIdx), 332 + logs, 333 + ) 330 334 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 331 335 return fmt.Errorf("failed to copy logs: %w", err) 332 336 }
+5 -23
spindle/engine/logger.go
··· 17 17 } 18 18 19 19 func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 - dir := filepath.Join(baseDir, wid.String()) 21 - if err := os.MkdirAll(dir, 0755); err != nil { 22 - return nil, fmt.Errorf("creating log dir: %w", err) 23 - } 24 - 25 20 path := LogFilePath(baseDir, wid) 26 21 27 - file, err := os.Create(path) 22 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 28 23 if err != nil { 29 24 return nil, fmt.Errorf("creating log file: %w", err) 30 25 } ··· 43 38 return l.file.Close() 44 39 } 45 40 46 - func OpenLogFile(baseDir string, workflowID models.WorkflowId) (*os.File, error) { 47 - logPath := LogFilePath(baseDir, workflowID) 48 - 49 - file, err := os.Open(logPath) 50 - if err != nil { 51 - return nil, fmt.Errorf("error opening log file: %w", err) 52 - } 53 - 54 - return file, nil 55 - } 56 - 57 41 func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 58 42 logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 59 43 return logFilePath 60 44 } 61 45 62 - func (l *WorkflowLogger) Stdout() io.Writer { 63 - return &jsonWriter{logger: l, stream: "stdout"} 64 - } 65 - 66 - func (l *WorkflowLogger) Stderr() io.Writer { 67 - return &jsonWriter{logger: l, stream: "stderr"} 46 + func (l *WorkflowLogger) Writer(stream string, stepId int) io.Writer { 47 + return &jsonWriter{logger: l, stream: stream, stepId: stepId} 68 48 } 69 49 70 50 type jsonWriter struct { 71 51 logger *WorkflowLogger 72 52 stream string 53 + stepId int 73 54 } 74 55 75 56 func (w *jsonWriter) Write(p []byte) (int, error) { ··· 78 59 entry := models.LogLine{ 79 60 Stream: w.stream, 80 61 Data: line, 62 + StepId: w.stepId, 81 63 } 82 64 83 65 if err := w.logger.encoder.Encode(entry); err != nil {
+1
spindle/models/models.go
··· 74 74 type LogLine struct { 75 75 Stream string `json:"s"` 76 76 Data string `json:"d"` 77 + StepId int `json:"i"` 77 78 }
+29 -7
spindle/stream.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "net/http" 8 9 "strconv" 9 10 "time" ··· 105 106 http.Error(w, "failed to upgrade", http.StatusInternalServerError) 106 107 return 107 108 } 108 - defer conn.Close() 109 + defer func() { 110 + _ = conn.WriteControl( 111 + websocket.CloseMessage, 112 + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"), 113 + time.Now().Add(time.Second), 114 + ) 115 + conn.Close() 116 + }() 109 117 l.Debug("upgraded http to wss") 110 118 111 119 ctx, cancel := context.WithCancel(r.Context()) ··· 122 130 }() 123 131 124 132 if err := s.streamLogsFromDisk(ctx, conn, wid); err != nil { 125 - l.Error("log stream failed", "err", err) 133 + l.Info("log stream ended", "err", err) 126 134 } 127 - l.Debug("logs connection closed") 135 + 136 + l.Info("logs connection closed") 128 137 } 129 138 130 139 func (s *Spindle) streamLogsFromDisk(ctx context.Context, conn *websocket.Conn, wid models.WorkflowId) error { 140 + status, err := s.db.GetStatus(wid) 141 + if err != nil { 142 + return err 143 + } 144 + isFinished := models.StatusKind(status.Status).IsFinish() 145 + 131 146 filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid) 132 147 133 148 config := tail.Config{ 134 - Follow: true, 135 - ReOpen: true, 149 + Follow: !isFinished, 150 + ReOpen: !isFinished, 136 151 MustExist: false, 137 - Location: &tail.SeekInfo{Offset: 0, Whence: 0}, 138 - Logger: tail.DiscardingLogger, 152 + Location: &tail.SeekInfo{ 153 + Offset: 0, 154 + Whence: io.SeekStart, 155 + }, 156 + // Logger: tail.DiscardingLogger, 139 157 } 140 158 141 159 t, err := tail.TailFile(filePath, config) ··· 149 167 case <-ctx.Done(): 150 168 return ctx.Err() 151 169 case line := <-t.Lines: 170 + if line == nil && isFinished { 171 + return fmt.Errorf("tail completed") 172 + } 173 + 152 174 if line == nil { 153 175 return fmt.Errorf("tail channel closed unexpectedly") 154 176 }