~james/core

core/spindle/logview/logview.go -rw-r--r-- 3.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 14 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package logview

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/hpcloud/tail"
	"tangled.org/core/spindle/artifactstore"
	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/models"
)

// streams a workflow's log lines: tails the log file while running, reads
// the uploaded artifact once finished, same for every role. stop ends a
// live follow early, the channel closes when the source drains or ctx ends
func Follow(ctx context.Context, d *db.DB, reader artifactstore.Reader, logDir string, wid models.WorkflowId, finished bool) (<-chan *tail.Line, func(), error) {
	lines, stop, _, err := FollowDrain(ctx, d, reader, logDir, wid, finished)
	return lines, stop, err
}

// drain reads to EOF first and the last lines still come out
func FollowDrain(ctx context.Context, d *db.DB, reader artifactstore.Reader, logDir string, wid models.WorkflowId, finished bool) (<-chan *tail.Line, func(), func(), error) {
	logPath := models.LogFilePath(logDir, wid)
	var lookup func() (*db.FinishedLog, error)
	if finished && reader != nil && d != nil {
		lookup = func() (*db.FinishedLog, error) { return d.GetFinishedLog(wid) }
	}
	rc, err := archiveOrLocal(ctx, lookup, reader, logPath, finished)
	if err != nil {
		return nil, nil, nil, err
	}
	if rc != nil {
		ch, cancel := streamArchive(ctx, rc)
		return ch, cancel, cancel, nil
	}

	t, err := tail.TailFile(logPath, tail.Config{
		Follow:    !finished,
		ReOpen:    !finished,
		MustExist: false,
		Location:  &tail.SeekInfo{Offset: 0, Whence: io.SeekStart},
	})
	if err != nil {
		return nil, nil, nil, err
	}
	stop := func() {
		// nobody reads t.Lines anymore, drain it or the tail blocks on stop
		go func() {
			for range t.Lines {
			}
		}()
		_ = t.Stop()
	}
	drain := func() { _ = t.StopAtEOF() }
	return t.Lines, stop, drain, nil
}

// a missing local file means the archive row exists, since the file is only removed after the row is saved
func archiveOrLocal(ctx context.Context, lookup func() (*db.FinishedLog, error), reader artifactstore.Reader, logPath string, finished bool) (io.ReadCloser, error) {
	if lookup == nil {
		// stat it first, tail waits forever on a missing file
		if finished {
			if _, err := os.Stat(logPath); err != nil {
				return nil, fmt.Errorf("finished workflow log unavailable: %w", err)
			}
		}
		return nil, nil
	}
	rc, artifactErr := openArchive(ctx, lookup, reader)
	if artifactErr == nil {
		return rc, nil
	}
	if _, err := os.Stat(logPath); err == nil {
		return nil, nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("stat local workflow log: %w", err)
	}
	rc, retryErr := openArchive(ctx, lookup, reader)
	if retryErr != nil {
		return nil, fmt.Errorf("finished workflow log unavailable: %w", retryErr)
	}
	return rc, nil
}

func openArchive(ctx context.Context, lookup func() (*db.FinishedLog, error), reader artifactstore.Reader) (io.ReadCloser, error) {
	fl, err := lookup()
	if err != nil {
		return nil, fmt.Errorf("find archived workflow log: %w", err)
	}
	if fl.Ref == "" {
		return nil, errors.New("archived workflow log has an empty ref")
	}
	rc, err := reader.Open(ctx, fl.Ref)
	if err != nil {
		return nil, fmt.Errorf("open archived workflow log: %w", err)
	}
	return rc, nil
}

func streamArchive(ctx context.Context, rc io.ReadCloser) (<-chan *tail.Line, func()) {
	ch := make(chan *tail.Line, 64)
	followCtx, cancel := context.WithCancel(ctx)
	go func() {
		defer close(ch)
		defer rc.Close()
		send := func(line *tail.Line) bool {
			select {
			case <-followCtx.Done():
				return false
			case ch <- line:
				return true
			}
		}
		// bufio.Scanner gives up past its token cap, and log lines can be longer
		reader := bufio.NewReader(rc)
		for {
			text, err := reader.ReadString('\n')
			if text != "" && !send(&tail.Line{Text: strings.TrimSuffix(strings.TrimSuffix(text, "\n"), "\r")}) {
				return
			}
			if err != nil {
				if err != io.EOF {
					send(&tail.Line{Err: err})
				}
				return
			}
		}
	}()
	return ch, cancel
}