carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "container/list"
5 "context"
6 "fmt"
7 "log"
8 "math"
9 "strconv"
10 "sync"
11
12 "github.com/carlosframework/platform/internal/activator"
13 "github.com/carlosframework/platform/internal/hostclaim"
14 "github.com/carlosframework/platform/internal/moves"
15 "github.com/carlosframework/platform/internal/provision"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// The move log and the member stream (spec §13, Plan 6 Task 3).
20//
21// Every record write a box makes for a move goes through observedMove, which
22// keeps the record as the write found it; logTransition names what the write
23// did (moves.TransitionOf, which the console's move pass shares): one event,
24// one identity. Transitions another writer made (the console's request,
25// manual cut, abort and retry; a declared loss) are seen by the box's own
26// pass (observeMove) and named the same way. A recovery probe's starts are
27// the attempt journal's (movesProbeAttempts.RecoveryAttempt,
28// recoveryAttemptLogger). The identity is the log object's key, written
29// create-only behind moveCfg.LogEvent (moveLogWriter in production), and
30// only the writer that created the object tells the app's members (preflight
31// L9, L27).
32//
33// A write that fails is kept, with the entry's exact bytes, in the worker's
34// outbox on disk (moveslogoutbox.go), and every pass retries the outbox
35// whatever the record now says, until the entry is created or found there;
36// so neither a phase that moves on nor a close loses it (fix round 2,
37// ruling 1).
38//
39// A worker remembers the identities it has handed off (written, found, or
40// kept in the outbox), so a pass that sees the same transition again does
41// not even try. The memory is bounded (moveLogMemoCap, oldest dropped), and
42// a move is forgotten once its terminal outcome is logged or a pass sees its
43// record with no move or another move (retireMoves): create-only storage
44// still refuses a replay.
45
46// observedMove wraps a record mutation so *pre keeps the record as the write
47// found it: the transition's before, for the log.
48func observedMove(pre *provision.Record, mut func(*provision.Record) error) func(*provision.Record) error {
49 return func(rec *provision.Record) error {
50 *pre = *rec
51 pre.Move = copyMove(rec.Move)
52 return mut(rec)
53 }
54}
55
56// copyMove is a deep enough copy of a move for moves.TransitionOf: the fields
57// it compares and reads, safe from a mutation that edits the journal in
58// place.
59func copyMove(m *provision.MoveState) *provision.MoveState {
60 if m == nil {
61 return nil
62 }
63 c := *m
64 if m.Rollback != nil {
65 rb := *m.Rollback
66 c.Rollback = &rb
67 }
68 if m.Landing != nil {
69 l := *m.Landing
70 c.Landing = &l
71 }
72 if m.Source != nil {
73 src := *m.Source
74 c.Source = &src
75 }
76 return &c
77}
78
79// logTransition logs the transition one write made.
80func (w *movesWorker) logTransition(ctx context.Context, s release.Store, before, after provision.Record) {
81 if e, ok := moves.TransitionOf(before, after); ok {
82 w.record(ctx, s, after, e)
83 }
84}
85
86// observeMove logs the transitions another writer made that this box's pass
87// sees: the console's request, manual cut, abort and retry, and a declared
88// loss. Each is named as if it were this box's write from a record with no
89// move (or, for a retry, by its epoch), so it has the same identity whoever
90// logs it.
91func (w *movesWorker) observeMove(ctx context.Context, s release.Store, rec provision.Record) {
92 w.retireMoves(rec)
93 m := rec.Move
94 if m == nil || m.ID == "" {
95 return
96 }
97 switch {
98 case m.State == provision.MovePreparing, m.State == provision.MoveAborting,
99 m.State == provision.MoveCutting && m.CutPhase == provision.CutRequested:
100 w.logTransition(ctx, s, provision.Record{}, rec)
101 case m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.TargetPhase == provision.RbTargetLost:
102 // The console that performed the declaration logs it first (final
103 // review I4); this is the second producer of the same identity.
104 if e, ok := moves.DeclaredLost(m); ok {
105 w.record(ctx, s, rec, e)
106 }
107 }
108 if m.RecoveryRetryEpoch > 0 && m.State != provision.MoveStuck {
109 w.record(ctx, s, rec, moves.Transition{Op: m.ID, Event: "retry", Identity: "retry-" + strconv.FormatUint(m.RecoveryRetryEpoch, 10),
110 Fields: map[string]string{"from": m.From, "to": m.To}})
111 }
112}
113
114// logRevPhase logs a revert's step, which is the claim's rev_phase, not the
115// record's: reverting-<phase>, event rev_<phase>.
116func (w *movesWorker) logRevPhase(ctx context.Context, s release.Store, rec provision.Record, before, after hostclaim.Observed) {
117 phase := func(o hostclaim.Observed) string {
118 if o.Claim == nil {
119 return ""
120 }
121 return o.Claim.Op.Journal[hostclaim.JournalRevPhase]
122 }
123 p := phase(after)
124 if p == "" || p == phase(before) || rec.Move == nil || rec.Move.ID == "" {
125 return
126 }
127 w.record(ctx, s, rec, moves.Transition{Op: rec.Move.ID, Event: "rev_" + p, Identity: "reverting-" + p,
128 Fields: map[string]string{"from": rec.Move.From, "to": rec.Move.To}})
129}
130
131// recoveryAttemptLogger is the attempt journal's RecoveryAttempt hook for
132// this worker (set by probeAttempts): each recovery probe start the journal
133// counts is probe_attempt-<epoch>-<n> (preflight L11), the starts of one wake
134// included. s is the store the log is written to. A write that fails goes to
135// the outbox like any other.
136func (w *movesWorker) recoveryAttemptLogger(s release.Store) func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
137 return func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
138 if rec.Move == nil || rec.Move.ID == "" {
139 return
140 }
141 w.record(ctx, s, rec, moves.Transition{Op: rec.Move.ID, Event: "probe_attempt",
142 Identity: "probe_attempt-" + strconv.FormatUint(epoch, 10) + "-" + strconv.Itoa(n),
143 Fields: map[string]string{"from": rec.Move.From, "to": rec.Move.To}})
144 }
145}
146
147// terminalEvents are the events that end a move's log.
148var terminalEvents = map[string]bool{"done": true, "rolled_back": true, "aborted": true}
149
150// record hands one entry to the LogEvent hook unless this worker has
151// already handed it off. An entry whose write fails is kept in the outbox,
152// for every later pass to retry; only an entry written, found there or kept
153// is remembered. With no hook (a test's worker) the event is a log line.
154func (w *movesWorker) record(ctx context.Context, s release.Store, rec provision.Record, e moves.Transition) {
155 key := rec.Host + "/" + e.Op + "/" + e.Identity
156 if w.logged.seen(key) {
157 return
158 }
159 ev := moves.LogEvent{V: 1, Op: e.Op, Host: rec.Host, Event: e.Event, Identity: e.Identity, Actor: w.cfg.Box, Fields: e.Fields}
160 if w.cfg.Now != nil {
161 ev.At = w.cfg.Now().UTC()
162 }
163 if f := w.cfg.LogEvent; f != nil {
164 if err := f(ctx, s, rec, ev); err != nil {
165 if qerr := w.queueLog(rec, ev); qerr != nil {
166 log.Printf("moves: %s: the log of %s at %s waits for the next sighting: %v; the outbox: %v", rec.Host, e.Op, e.Identity, err, qerr)
167 return
168 }
169 log.Printf("moves: %s: the log of %s at %s is kept for the next pass: %v", rec.Host, e.Op, e.Identity, err)
170 }
171 } else {
172 log.Printf("moves: %s: %s %s (%s)", rec.Host, e.Op, e.Event, e.Identity)
173 }
174 if terminalEvents[e.Event] {
175 w.logged.forget(rec.Host, e.Op)
176 return
177 }
178 w.logged.add(rec.Host, e.Op, key)
179}
180
181// retireMoves forgets the moves of rec's host other than the one its record
182// names now: a move closed, or replaced, by another writer.
183func (w *movesWorker) retireMoves(rec provision.Record) {
184 keep := ""
185 if rec.Move != nil {
186 keep = rec.Move.ID
187 }
188 w.logged.retire(rec.Host, keep)
189}
190
191// moveLogMemoCap bounds a worker's memory of handed-off identities.
192const moveLogMemoCap = 4096
193
194// moveLogMemo is the identities a worker has handed off: at most
195// moveLogMemoCap, the oldest dropped first, indexed by host and move so a
196// move is forgotten whole. The zero value is ready.
197type moveLogMemo struct {
198 mu sync.Mutex
199 order *list.List // memoEntry, oldest first
200 at map[string]*list.Element // key to its element
201 moves map[string]map[string][]*list.Element // host to move to its elements
202}
203
204type memoEntry struct{ host, op, key string }
205
206func (m *moveLogMemo) seen(key string) bool {
207 m.mu.Lock()
208 defer m.mu.Unlock()
209 _, ok := m.at[key]
210 return ok
211}
212
213func (m *moveLogMemo) len() int {
214 m.mu.Lock()
215 defer m.mu.Unlock()
216 return len(m.at)
217}
218
219func (m *moveLogMemo) add(host, op, key string) {
220 m.mu.Lock()
221 defer m.mu.Unlock()
222 if m.order == nil {
223 m.order, m.at, m.moves = list.New(), map[string]*list.Element{}, map[string]map[string][]*list.Element{}
224 }
225 if _, ok := m.at[key]; ok {
226 return
227 }
228 for len(m.at) >= moveLogMemoCap {
229 m.drop(m.order.Front())
230 }
231 el := m.order.PushBack(memoEntry{host, op, key})
232 m.at[key] = el
233 if m.moves[host] == nil {
234 m.moves[host] = map[string][]*list.Element{}
235 }
236 m.moves[host][op] = append(m.moves[host][op], el)
237}
238
239// drop removes one element from every index.
240func (m *moveLogMemo) drop(el *list.Element) {
241 e := el.Value.(memoEntry)
242 m.order.Remove(el)
243 delete(m.at, e.key)
244 els := m.moves[e.host][e.op]
245 for i, x := range els {
246 if x == el {
247 els = append(els[:i], els[i+1:]...)
248 break
249 }
250 }
251 if len(els) > 0 {
252 m.moves[e.host][e.op] = els
253 return
254 }
255 delete(m.moves[e.host], e.op)
256 if len(m.moves[e.host]) == 0 {
257 delete(m.moves, e.host)
258 }
259}
260
261// forget drops one move's identities.
262func (m *moveLogMemo) forget(host, op string) {
263 m.mu.Lock()
264 defer m.mu.Unlock()
265 for _, el := range append([]*list.Element(nil), m.moves[host][op]...) {
266 m.drop(el)
267 }
268}
269
270// retire drops the identities of host's moves other than keep.
271func (m *moveLogMemo) retire(host, keep string) {
272 m.mu.Lock()
273 defer m.mu.Unlock()
274 for op, els := range m.moves[host] {
275 if op == keep {
276 continue
277 }
278 for _, el := range append([]*list.Element(nil), els...) {
279 m.drop(el)
280 }
281 }
282}
283
284// moveLogWriter is the production LogEvent: the object, create-only, under
285// the move's report prefix in the deployment bucket, and the member event
286// when this writer created it. A storage failure is returned, so the worker
287// tries the transition again at its next sighting.
288func moveLogWriter(sink activator.EventSink) func(context.Context, release.Store, provision.Record, moves.LogEvent) error {
289 return func(ctx context.Context, s release.Store, rec provision.Record, ev moves.LogEvent) error {
290 if s == nil {
291 return fmt.Errorf("moves: no store for the log of %s", ev.Op)
292 }
293 created, err := moves.WriteLog(ctx, s, moves.ReportPrefix(rec.Account, rec.App, rec.Host, ev.Op), ev)
294 if err != nil {
295 return err
296 }
297 if !created || sink == nil {
298 return nil
299 }
300 if line, ok := memberLine(ev, rec.Host); ok {
301 sink.Event(rec.Account, rec.App, rec.Host, memberLevel(ev.Event), line)
302 }
303 return nil
304 }
305}
306
307// memberLine is what the app's members read for an event (preflight L9: M-C1,
308// M-C5, M-C6, M-C7, M-C8, M-C9, and for stuck MV-N380, a line with no
309// operator command in it), from the shared templates (moves/operatorcopy.go,
310// L12), built from the entry alone, so an entry the outbox writes later says
311// the same. A region the record names as the home's "" is
312// moves.HomeRegionText. ok is false for an event members are not told of.
313// Its level is memberLevel's.
314func memberLine(ev moves.LogEvent, host string) (line string, ok bool) {
315 from, to := moves.RegionText(ev.Fields["from"]), moves.RegionText(ev.Fields["to"])
316 switch ev.Event {
317 case "requested":
318 return moves.MoveStartingLine(host, from, to), true
319 case "admitted":
320 budget, err := strconv.Atoi(ev.Fields["cut_budget_s"])
321 if err != nil || budget <= 0 {
322 return "", false
323 }
324 return moves.CutStartingLine(to, host, moves.CutBudgetText(budget)), true
325 case "flipped":
326 return moves.FlippedLine(from, to), true
327 case "done":
328 offline, err := strconv.ParseFloat(ev.Fields["offline_s"], 64)
329 if err != nil {
330 return "", false
331 }
332 return moves.MovedLine(host, to, int(math.Round(offline))), true
333 case "rolling_back":
334 reason := ev.Fields["reason"]
335 if reason == "" {
336 return "", false
337 }
338 return moves.MovingBackLine(to, host, reason, from), true
339 case "rolled_back":
340 return moves.MovedBackLine(host, from), true
341 case "stuck":
342 return moves.StuckMemberLine(host, to, from), true
343 }
344 return "", false
345}
346
347// memberLevel is the level of an event's member line.
348func memberLevel(event string) string {
349 switch event {
350 case "rolling_back", "rolled_back":
351 return "warn"
352 case "stuck":
353 return "error"
354 }
355 return "info"
356}
357