carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package runs
2
3import (
4 "context"
5 "errors"
6 "io"
7 "sync"
8 "time"
9
10 "github.com/carlosframework/platform/internal/release"
11)
12
13// errBadStream is what Close reports when a writer was made for a stream
14// that is neither StreamOut nor StreamErr. ChunkKey trusts its stream, so the
15// chunker refuses anything else before it reaches a key.
16var errBadStream = errors.New("runs: chunker stream is not out or err")
17
18// Chunker turns a run's two streams into chunks (run spec §5.5):
19// out/<seq>-<o|e>, one seq for both streams in the order the bytes were
20// read. A chunk holds one stream's bytes: a write on the other stream cuts
21// it, so the follower interleaves exactly as the agent read. A chunk is
22// written every second, or as soon as it holds 256 KB. Past 64 MB in total
23// nothing more is written and Close says truncated; writes never fail, so
24// the command is never failed by a refused pipe. Each chunk is a
25// create-only Put retried until it lands or the context ends, and the
26// retries run under the lock. A stalled bucket therefore does block the
27// command's pipes, and the command with them once its pipe buffers fill,
28// until the Put lands or the context ends. That is deliberate: it keeps
29// seqs in order and stops output piling up in memory.
30type Chunker struct {
31 ctx context.Context
32 s release.Store
33 acct, app, id string
34 every time.Duration
35 max int64
36
37 mu sync.Mutex
38 stream string
39 pending []byte
40 seq int64
41 total int64
42 truncated bool
43 err error
44 closed bool
45
46 stop chan struct{}
47 done chan struct{}
48 closeOnce sync.Once
49}
50
51// NewChunker starts a chunker for run id with the spec's cadence and cap.
52// An account, app or run id the store helpers would refuse (ErrBadApp,
53// ErrBadID) gives a chunker that writes nothing and whose Close returns that
54// error, so it can never write outside runs/<id>/out/.
55func NewChunker(ctx context.Context, s release.Store, acct, app, id string) *Chunker {
56 return newChunker(ctx, s, acct, app, id, ChunkEvery, MaxOutputBytes)
57}
58
59func newChunker(ctx context.Context, s release.Store, acct, app, id string, every time.Duration, max int64) *Chunker {
60 c := &Chunker{ctx: ctx, s: s, acct: acct, app: app, id: id, every: every, max: max,
61 stop: make(chan struct{}), done: make(chan struct{})}
62 if err := checkRun(acct, app, id); err != nil {
63 c.err, c.truncated = err, true
64 close(c.done)
65 return c
66 }
67 go c.tick()
68 return c
69}
70
71func (c *Chunker) tick() {
72 defer close(c.done)
73 t := time.NewTicker(c.every)
74 defer t.Stop()
75 for {
76 select {
77 case <-c.stop:
78 return
79 case <-t.C:
80 c.mu.Lock()
81 c.flushLocked()
82 c.mu.Unlock()
83 }
84 }
85}
86
87type chunkStream struct {
88 c *Chunker
89 stream string
90}
91
92func (w chunkStream) Write(p []byte) (int, error) {
93 w.c.write(w.stream, p)
94 return len(p), nil
95}
96
97// Stream is the writer for one stream, StreamOut or StreamErr. Its Write
98// never fails and never returns short.
99func (c *Chunker) Stream(stream string) io.Writer { return chunkStream{c: c, stream: stream} }
100
101func (c *Chunker) write(stream string, p []byte) {
102 c.mu.Lock()
103 defer c.mu.Unlock()
104 if c.closed {
105 return
106 }
107 if stream != StreamOut && stream != StreamErr {
108 if c.err == nil {
109 c.err, c.truncated, c.pending = errBadStream, true, nil
110 }
111 return
112 }
113 if c.err != nil {
114 c.truncated = c.truncated || len(p) > 0
115 return
116 }
117 if room := c.max - c.total; int64(len(p)) > room {
118 p = p[:room]
119 c.truncated = true
120 }
121 for len(p) > 0 {
122 if c.stream != stream && len(c.pending) > 0 {
123 c.flushLocked()
124 if c.err != nil {
125 return
126 }
127 }
128 c.stream = stream
129 n := min(len(p), ChunkBytes-len(c.pending))
130 c.pending = append(c.pending, p[:n]...)
131 c.total += int64(n)
132 p = p[n:]
133 if len(c.pending) >= ChunkBytes {
134 c.flushLocked()
135 if c.err != nil {
136 c.truncated = true
137 return
138 }
139 }
140 }
141}
142
143// flushLocked writes the pending chunk under the next seq. A Put that
144// answers ErrCASConflict was written by an earlier attempt whose answer was
145// lost; the seq is spent either way.
146func (c *Chunker) flushLocked() {
147 if len(c.pending) == 0 || c.err != nil {
148 return
149 }
150 seq := c.seq + 1
151 key := ChunkKey(c.acct, c.app, c.id, seq, c.stream)
152 backoff := 100 * time.Millisecond
153 for {
154 _, err := c.s.Put(c.ctx, key, c.pending, "")
155 if err == nil || errors.Is(err, release.ErrCASConflict) {
156 break
157 }
158 select {
159 case <-c.ctx.Done():
160 c.err, c.truncated, c.pending = err, true, nil
161 return
162 case <-time.After(backoff):
163 }
164 backoff = min(backoff*2, 5*time.Second)
165 }
166 c.seq, c.pending = seq, nil
167}
168
169// Close stops the ticker and writes the last chunk. Call it after both
170// streams reached their end (CmdHandle.Stop drains them); writes after Close
171// are dropped. It does not return until every chunk is written or chunking
172// gave up, so a caller may write result.json once it returns. It returns the
173// last seq written (0: no output), whether output was cut off, and the
174// failure that ended chunking, if one did. It is safe to call twice.
175func (c *Chunker) Close() (int64, bool, error) {
176 c.closeOnce.Do(func() {
177 close(c.stop)
178 <-c.done
179 })
180 c.mu.Lock()
181 defer c.mu.Unlock()
182 c.flushLocked()
183 c.closed = true
184 return c.seq, c.truncated, c.err
185}
186