carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log"
12 "os"
13 "time"
14
15 "github.com/carlosframework/platform/internal/alerting"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostfence"
18 "github.com/carlosframework/platform/internal/hostops"
19 "github.com/carlosframework/platform/internal/moves"
20 "github.com/carlosframework/platform/internal/park"
21 "github.com/carlosframework/platform/internal/provision"
22)
23
24// The console's move pass (moves Plan 5 Task 7, spec §7.5, §7.10, §7.11,
25// §13; Paul's ruling M4). It is the moves body of claim Plan 10's
26// Recovery.Moves hook: the recovery pass (ControlWriter.RecoveryPass) calls
27// movesRecovery once per claim of a move kind, with a MoveRecoveryGrant bound
28// to that claim, its move registration and the exact record it observed.
29// ConfigureClaimRuntime binds it only with the moves switch on.
30//
31// Each call reads the claim and the record again through the console's store,
32// gives up if either moved on since the grant's observation (the next pass
33// observes afresh), decides at most one action (decideMoveRecovery) and takes
34// it. The grant is spent by its first write, so one observation is never two
35// writes. In the order the decision tries them:
36//
37// 1. An orphan: a claim of a move kind whose record carries neither the move
38// nor its outcome. A moving claim before the seal (the request door's
39// record write was lost) is left for ten minutes, then its start effect
40// is settled without the request's handle (ResolveStart, K3), and on a
41// later pass, with the start settled not applied, released
42// (release-not-applied). Any other orphan is paged, once, and left: an
43// operation past the seal with no record is an incident.
44// 2. A source terminated before the flip (K13): Convert with the source's
45// termination fence, which writes the record's relocation decision on
46// one pass and converts the claim to relocating on the next.
47// 3. The six-hour abort (K18): a move still preparing moves.AbortAfter
48// after its request is aborted through ConsoleMove(ConsoleMoveAbort);
49// the source's own abort (movesabort.go) takes it from there.
50// 4. The claim half the record decided (§7.5, Review Focus 5): flip,
51// commit or rollback, with the target's fence between the flip and the
52// rest (below). Anything else is left alone.
53// 5. The deadline: a landing past the earlier of its cut and landing
54// deadlines is rolled back by the record (J11, J9's recorded reason).
55// 6. A lost target released with nothing of its box or region owed:
56// target_parked_at, through the
57// record-sequenced recovery grant (StampLostMoveTargetParked)
58// (p10 Task G).
59// 7. A close whose stamps are in and whose op owes nothing: the record's
60// close (close, rolled-back-close or revert-close). The boxes close on
61// their own in the pass the close becomes due; the console writes it
62// only when it has been due for moveCloseGrace, and pages that it did.
63// An abort is never closed here (fix round 1, ruling 1): only the
64// source closes it, after it retired its own snapshots, receipts,
65// ledger and journal (movesabort.go), and nothing on the record shows
66// that it did. An abort due past the lane's horizon (the claim's
67// quiet_after for the target region) plus moveCloseGrace is paged
68// once, and left to the source.
69// 8. The release: the record closed (LastMove names the op) and the claim
70// not yet released. This is the second half of every record-first close
71// (ruling M4); after a box crashed between the halves, only this pass
72// writes it, because no box visits a record without a move.
73//
74// The target's fence (moves after claim Plan 12, Task 5, ruling Q8). A
75// landing claim before its commit, or a restoring claim, whose target box was
76// terminated has its regional worker's termination fence at
77// control/box-fences/<target enrollment>.json. The grant's
78// ReleaseFencedTarget takes one step of it per call, record first: on a
79// landing it rolls the record back with the target lost under the fence
80// (RollbackTargetFenced), or on a rollback marks the target lost; then the
81// claim's release, which rolls a landing claim back and consumes the fence in
82// one write; then the record's loss commit (released, the fence as its
83// evidence). The fence comes after the flip (the grant refuses a moving
84// claim) and ahead of the claim's rollback half and the deadline's rollback,
85// because the grant writes both halves itself. Once the claim carries the
86// consumed fence, the reference is the claim's (ConsumedMoveTargetFence),
87// never the bucket's; and once the record carries it as both its loss and its
88// evidence, the pass stops calling it. A dirty target (one that released with
89// writes the source does not have) cannot be finished from a dead box: the
90// grant refuses it after it verified the fence, the pass pages once that an
91// operator must declare the target lost, and leaves it for J4. The page rests
92// only on the grant's verified-dirty outcome, never on a bare ErrPending. Its fence
93// comes after the claim's rollback half, which the grant does not take for
94// it. The console logs the fence's record writes, and its claim rollback, as
95// its own move writes, from the grant's outcome (the write's own evidence).
96//
97// Pages (§13) go through alerting.Page on the sinks cmd/console arms, best
98// effort: a rollback waiting past its release deadline (an incident, again
99// only a full hour after the last attempt), a move preparing for over an
100// hour, an orphan past the seal (an incident), an overdue close the console
101// wrote, an overdue abort the console leaves to its source, and a dirty
102// fenced target left for J4 (an incident) (each once).
103// The rate limit is durable: the last attempt's time is kept per finding in
104// the console's store (move_pages, fix round 1 ruling 3), written whether or
105// not delivery worked, so a restarted console keeps the spacing.
106//
107// A relocating claim is region HA's, and the pass does nothing for it.
108
109const (
110 // moveOrphanAfter is how old, by Op.StartedAt, a moving claim with no
111 // record move must be before the pass settles its start.
112 moveOrphanAfter = 10 * time.Minute
113 // movePreparingPage is how long a move may prepare before it pages
114 // (§13: 60 minutes with no promotable round).
115 movePreparingPage = time.Hour
116 // moveCloseGrace is how long a close must have been due before the
117 // console writes it instead of the boxes.
118 moveCloseGrace = 10 * time.Minute
119)
120
121type moveRecoveryAction string
122
123const (
124 moveActNone moveRecoveryAction = ""
125 moveActResolveStart moveRecoveryAction = "resolve-start"
126 moveActConvert moveRecoveryAction = "convert"
127 moveActAbort moveRecoveryAction = "abort"
128 moveActClaim moveRecoveryAction = "claim"
129 moveActRecord moveRecoveryAction = "record"
130 moveActStampLost moveRecoveryAction = "stamp-lost"
131 // moveActFence is one step of the target's termination fence, through
132 // the grant's ReleaseFencedTarget (Q8).
133 moveActFence moveRecoveryAction = "fence"
134 moveActClose moveRecoveryAction = "close"
135 // moveActAbortOverdue writes nothing: an abort due to close, which only
136 // its source may close, is checked for being overdue and paged.
137 moveActAbortOverdue moveRecoveryAction = "abort-overdue"
138)
139
140type movePageKind string
141
142const (
143 movePageReleaseDeadline movePageKind = "release-deadline"
144 movePagePreparing movePageKind = "preparing"
145 movePageOrphan movePageKind = "orphan"
146 movePageCloseOverdue movePageKind = "close-overdue"
147 movePageAbortOverdue movePageKind = "abort-overdue"
148 // movePageTargetFencedDirty is a dirty target whose fence the grant
149 // refused: left for J4 (Q8).
150 movePageTargetFencedDirty movePageKind = "target-fenced-dirty"
151)
152
153// moveRecoveryInput is what one observation saw. Start is the op's move-start
154// effect, read only for a moving claim with no record move; Fence is the
155// source's termination fence, read only for a moving claim. TargetFence is
156// the target's (Q8), read only for a landing claim before its commit or a
157// restoring claim: the claim's consumed fence when it carries one
158// (TargetFenceConsumed), otherwise the bucket's.
159type moveRecoveryInput struct {
160 Now time.Time
161 Claim *hostclaim.Claim
162 Rec provision.Record
163 Start *hostops.Effect
164 Fence *hostclaim.FenceRef
165 TargetFence *hostclaim.FenceRef
166 TargetFenceConsumed bool
167}
168
169// moveRecoveryDecision is at most one action, with the grant action it
170// names, and the pages the observation calls for.
171type moveRecoveryDecision struct {
172 Action moveRecoveryAction
173 Claim hostops.MoveRecoveryClaimAction
174 Record hostops.MoveRecoveryRecordAction
175 Pages []movePageKind
176}
177
178// decideMoveRecovery is the pass's whole policy, on values (see the file
179// comment for the order).
180func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision {
181 var d moveRecoveryDecision
182 c := in.Claim
183 if c == nil || c.Holder == nil || c.Op.ID == "" || c.Op.Kind == hostclaim.KindRelocating {
184 return d
185 }
186 op, rec, m := c.Op, in.Rec, in.Rec.Move
187 sealed := op.Kind == hostclaim.KindMoving && op.Phase == hostclaim.PhaseSealed
188 if m == nil || m.Legacy() || m.ID != op.ID {
189 if m != nil {
190 return d // another move's record: not this op's to settle
191 }
192 if rec.LastMove != nil && rec.LastMove.ID == op.ID {
193 d.Action, d.Claim = moveActClaim, hostops.RecoveryMoveRelease
194 return d
195 }
196 if op.Kind != hostclaim.KindMoving || op.Phase != hostclaim.PhaseMoving {
197 d.Pages = append(d.Pages, movePageOrphan)
198 return d
199 }
200 if op.StartedAt.IsZero() || in.Now.Before(op.StartedAt.Add(moveOrphanAfter)) || in.Start == nil {
201 return d
202 }
203 switch {
204 case in.Start.State == "uncertain":
205 d.Action = moveActResolveStart
206 case in.Start.State == "complete" && in.Start.ResultReference == "not-applied":
207 d.Action, d.Claim = moveActClaim, hostops.RecoveryMoveReleaseNotApplied
208 }
209 return d
210 }
211 if m.Incarnation != c.Holder.Incarnation {
212 return d
213 }
214 rb := m.Rollback
215 if m.State == provision.MoveRollingBack && rb != nil && rb.ReleaseDeadline != nil && in.Now.After(*rb.ReleaseDeadline) && rb.TargetPhase == provision.RbTargetReleasing {
216 d.Pages = append(d.Pages, movePageReleaseDeadline)
217 }
218 if m.State == provision.MovePreparing && !m.RequestedAt.IsZero() && !in.Now.Before(moves.PreparingSince(m).Add(movePreparingPage)) {
219 d.Pages = append(d.Pages, movePagePreparing)
220 }
221 owed := owedUnderOp(c, op.ID, nil)
222 fence, fenceDirty := targetFenceStep(in)
223
224 switch {
225 case op.Kind == hostclaim.KindMoving && in.Fence != nil && (m.State == provision.MovePreparing || m.State == provision.MoveReady || m.State == provision.MoveCutting || m.State == provision.MoveRelocating):
226 d.Action = moveActConvert
227 case op.Kind == hostclaim.KindMoving && op.Phase == hostclaim.PhaseMoving && moves.AbortDue(rec, in.Now):
228 d.Action = moveActAbort
229 case m.State == provision.MoveLanding && m.FlippedAt != nil && rec.Region == m.To && sealed:
230 d.Action, d.Claim = moveActClaim, hostops.RecoveryMoveFlip
231 case fence && !fenceDirty:
232 d.Action = moveActFence
233 case m.State == provision.MoveCommitted && m.CommittedAt != nil && m.FlippedAt != nil && rec.Region == m.To && op.Kind == hostclaim.KindLanding && op.Phase == hostclaim.PhaseLandingProbed:
234 d.Action, d.Claim = moveActClaim, hostops.RecoveryMoveCommit
235 case m.State == provision.MoveRollingBack && rb != nil && rec.Region == m.From && (op.Kind == hostclaim.KindLanding && op.Phase != hostclaim.PhaseCommitted || sealed):
236 d.Action, d.Claim = moveActClaim, hostops.RecoveryMoveRollback
237 case m.State == provision.MoveLanding && op.Kind == hostclaim.KindLanding && op.Phase != hostclaim.PhaseCommitted && moveDeadlinePassed(m, in.Now):
238 d.Action, d.Record = moveActRecord, hostops.RecoveryMoveDeadlineRollback
239 case fence:
240 // A dirty target: after the claim's rollback half, which the grant
241 // does not take for it. The grant verifies the fence and refuses it;
242 // the pass pages (movesRecovery).
243 d.Action = moveActFence
244 case m.State == provision.MoveRollingBack && rb != nil && rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty && rb.TargetParkedAt == nil &&
245 hostclaim.LostMoveTargetParked(c, park.PlacementRegion(m.To)):
246 d.Action = moveActStampLost
247 case owed > 0:
248 case m.State == provision.MoveCommitted && m.OpenedAt != nil && m.SourceParkedAt != nil && rec.Region == m.To && op.Kind == hostclaim.KindLanding && op.Phase == hostclaim.PhaseCommitted:
249 d.Action, d.Record = moveActClose, hostops.RecoveryMoveClose
250 case m.State == provision.MoveRollingBack && rb != nil && rb.RecoveredAt != nil && rb.OpenedAt != nil && rb.TargetParkedAt != nil && rec.Region == m.From && op.Kind == hostclaim.KindRestoring && op.Phase == hostclaim.PhaseOpened:
251 d.Action, d.Record = moveActClose, hostops.RecoveryMoveRolledBackClose
252 case m.State == provision.MoveReverting && m.ReopenedAt != nil && rec.Region == m.From && op.Kind == hostclaim.KindReverting && op.Phase == hostclaim.PhaseOpened:
253 d.Action, d.Record = moveActClose, hostops.RecoveryMoveRevertClose
254 case m.State == provision.MoveAborting && op.Kind == hostclaim.KindMoving && op.Phase == hostclaim.PhaseAborting:
255 d.Action = moveActAbortOverdue
256 }
257 return d
258}
259
260// targetFenceStep reports whether the target's fence (in.TargetFence) has a
261// step left for the grant's ReleaseFencedTarget, and whether the step is a
262// dirty target's, which the grant refuses and the pass leaves for J4 (Q8).
263// The record's regions are compared as the grant compares them, code with
264// code.
265//
266// Consumed (the claim's release is done), the one step left is the record's
267// loss commit: the target lost under this fence. Once the record carries the
268// fence as its loss and its evidence, or the target released on its own
269// evidence, the fence is spent. Not yet consumed, a step is one the grant
270// would take: a landing's rollback; a rollback whose target still holds
271// something the source needs (releasing, returning, or released dirty) is
272// marked lost; the target lost under this fence, the claim's release; or a
273// target that released clean while the claim still waits on it
274// (restoring/releasing under the target), the claim's release. A target
275// lost under another loss (J4's declaration) is not the fence's.
276func targetFenceStep(in moveRecoveryInput) (step, dirty bool) {
277 c, rec, ref := in.Claim, in.Rec, in.TargetFence
278 if ref == nil || c == nil || rec.Move == nil {
279 return false, false
280 }
281 op, m := c.Op, rec.Move
282 rb := m.Rollback
283 if in.TargetFenceConsumed {
284 return op.Kind == hostclaim.KindRestoring && m.State == provision.MoveRollingBack && rb != nil && rec.Region == m.From &&
285 rb.TargetPhase == provision.RbTargetLost && rb.LossRef == ref.Key && !rb.Dirty, false
286 }
287 if !(op.Kind == hostclaim.KindLanding && op.Phase != hostclaim.PhaseCommitted) && op.Kind != hostclaim.KindRestoring {
288 return false, false
289 }
290 if m.State == provision.MoveLanding {
291 return op.Kind == hostclaim.KindLanding && m.FlippedAt != nil && rec.Region == m.To, false
292 }
293 if m.State != provision.MoveRollingBack || rb == nil || rec.Region != m.From {
294 return false, false
295 }
296 switch rb.TargetPhase {
297 case provision.RbTargetReleasing, provision.RbTargetReturning:
298 step = true
299 case provision.RbTargetReleased:
300 step = rb.Dirty || op.Kind == hostclaim.KindRestoring && op.Phase == hostclaim.PhaseReleasing && op.Exec == op.Intent["target"]
301 case provision.RbTargetLost:
302 step = rb.LossRef == ref.Key
303 }
304 return step, step && rb.Dirty
305}
306
307// moveDeadlinePassed is the grant's deadline rule: the earlier of the cut and
308// landing deadlines (J11, provision.LandingDeadlineOf, the box's own rule) is
309// behind now.
310func moveDeadlinePassed(m *provision.MoveState, now time.Time) bool {
311 deadline, _ := provision.LandingDeadlineOf(m)
312 return deadline != nil && now.After(*deadline)
313}
314
315// owedUnderOp counts the claim's obligations under op id, those match
316// accepts when it is set.
317func owedUnderOp(c *hostclaim.Claim, id string, match func(hostclaim.Obligation) bool) int {
318 n := 0
319 for _, o := range c.Obligations {
320 if o.Op == id && (match == nil || match(o)) {
321 n++
322 }
323 }
324 return n
325}
326
327func (s *Server) movesNow() time.Time {
328 if s.movesClock != nil {
329 return s.movesClock().UTC()
330 }
331 return time.Now().UTC()
332}
333
334// movesRecovery is the Recovery.Moves hook (see the file comment).
335func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant) error {
336 a := s.cfg.ClaimAuthority
337 store := s.cfg.Releases
338 if a == nil || a.Control == nil || store == nil || g == nil {
339 return hostops.ErrContract
340 }
341 o := g.Observation()
342 obs, err := hostclaim.Read(ctx, store, o.Host)
343 if err != nil {
344 return err
345 }
346 if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || obs.ETag != o.ClaimETag || obs.Claim.Op.ID != o.OpID {
347 return nil // moved on since the observation: the next pass observes again
348 }
349 c := obs.Claim
350 r, err := hostops.ReadRecord(ctx, store, c.Holder.Key)
351 if err != nil {
352 return err
353 }
354 if r.ETag != o.RecordETag {
355 return nil
356 }
357 rec, err := provision.DecodeRecord(r.Body)
358 if err != nil {
359 return err
360 }
361 now := s.movesNow()
362 in := moveRecoveryInput{Now: now, Claim: c, Rec: rec}
363 ours := rec.Move != nil && rec.Move.ID == o.OpID
364 if c.Op.Kind == hostclaim.KindMoving && c.Op.Phase == hostclaim.PhaseMoving && !ours {
365 adm, err := hostops.ReadAdmission(ctx, store, o.Account, o.App, o.AppID)
366 if err != nil {
367 return err
368 }
369 if e, ok := adm.Value.Effects[o.OpID]; ok {
370 in.Start = &e
371 }
372 }
373 if c.Op.Kind == hostclaim.KindMoving && ours {
374 if in.Fence, err = s.sourceFence(ctx, c); err != nil {
375 return err
376 }
377 }
378 if ours && (c.Op.Kind == hostclaim.KindLanding && c.Op.Phase != hostclaim.PhaseCommitted || c.Op.Kind == hostclaim.KindRestoring) {
379 // Once consumed, the fence is the claim's, never the bucket's (Q8).
380 if ref, ok := hostclaim.ConsumedMoveTargetFence(c); ok {
381 in.TargetFence, in.TargetFenceConsumed = &ref, true
382 } else if in.TargetFence, err = s.bucketFence(ctx, c.Op.Intent["target_enrollment"]); err != nil {
383 return err
384 }
385 }
386 d := decideMoveRecovery(in)
387 for _, kind := range d.Pages {
388 s.pageMove(ctx, kind, o, c, rec, now)
389 }
390 switch d.Action {
391 case moveActResolveStart:
392 out, err := g.ResolveStart(ctx)
393 if errors.Is(err, hostops.ErrMoveStartFenceUnproven) {
394 log.Printf("console: moves: %s: the start of move %s is uncertain and its record has not advanced past it; left", o.Host, o.OpID)
395 return nil
396 }
397 if err == nil {
398 log.Printf("console: moves: %s: the start of move %s settled %s", o.Host, o.OpID, out)
399 }
400 return err
401 case moveActConvert:
402 return g.Convert(ctx, *in.Fence)
403 case moveActAbort:
404 co, err := hostops.ObserveConsoleMove(ctx, store, o.Account, o.App, o.Host)
405 if err != nil {
406 return err
407 }
408 if co != o {
409 return nil
410 }
411 if _, err := a.Control.ConsoleMove(ctx, co, hostops.ConsoleMoveAbort); err != nil {
412 return err
413 }
414 log.Printf("console: moves: %s: move %s is still preparing after %s; aborted", o.Host, o.OpID, moves.AbortAfter)
415 if t, ok := abortTransition(rec); ok {
416 s.queueMoveLog(o, t, now)
417 }
418 return nil
419 case moveActClaim:
420 if d.Claim == hostops.RecoveryMoveRelease {
421 // claim_released is published only from a release that
422 // happened: queued unconfirmed now, confirmed once the release
423 // succeeds (a crash between the two is settled by the drainer,
424 // from the claim).
425 s.queueMoveLogAs(o, claimTransition(o, rec, "claim_released", "claim-released"), now, false)
426 if f := s.moveReleaseFault; f != nil {
427 if err := f(); err != nil {
428 return err
429 }
430 }
431 }
432 if err := g.Claim(ctx, d.Claim); err != nil {
433 return err
434 }
435 switch d.Claim {
436 case hostops.RecoveryMoveRelease:
437 if err := s.store.ConfirmMoveLog(o.Host, o.OpID, "claim-released"); err != nil {
438 log.Printf("console: moves: %s: the release of move %s is not confirmed in its log outbox (the drainer settles it from the claim): %v", o.Host, o.OpID, err)
439 }
440 case hostops.RecoveryMoveRollback:
441 s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now)
442 }
443 return nil
444 case moveActRecord:
445 if err := g.Record(ctx, d.Record); err != nil {
446 return err
447 }
448 if t, ok := g.Transition(); ok {
449 s.queueMoveLog(o, t, now)
450 }
451 return nil
452 case moveActStampLost:
453 return g.StampLostMoveTargetParked(ctx)
454 case moveActFence:
455 if f := s.moveFenceCall; f != nil {
456 f(*in.TargetFence)
457 }
458 out, err := g.ReleaseFencedTarget(ctx, *in.TargetFence)
459 if out.VerifiedDirty && errors.Is(err, hostops.ErrPending) {
460 // Only the grant's verified-dirty outcome pages (fix round 1,
461 // item 1): it is given after the fence verified. Any other
462 // ErrPending (a withdrawn registration, a spent grant) is not
463 // evidence of a terminated target. J4, the operator's
464 // declaration, takes it from here.
465 s.pageMove(ctx, movePageTargetFencedDirty, o, c, rec, now)
466 return nil
467 }
468 if err != nil {
469 return err
470 }
471 // The step's own writes, from the grant's evidence (fix round 1,
472 // item 2), never from a later read of the record.
473 if out.Transition != nil {
474 s.queueMoveLog(o, *out.Transition, now)
475 }
476 if out.ClaimRolledBack {
477 s.queueMoveLog(o, claimTransition(o, rec, "claim_rollback", "claim-rollback"), now)
478 }
479 return nil
480 case moveActClose:
481 if !s.moveCloseOverdue(o, d.Record, now) {
482 return nil
483 }
484 if err := g.Record(ctx, d.Record); err != nil {
485 return err
486 }
487 log.Printf("console: moves: %s: move %s closed by the console (%s); the release follows", o.Host, o.OpID, d.Record)
488 if t, ok := g.Transition(); ok {
489 s.queueMoveLog(o, t, now)
490 }
491 s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now)
492 return nil
493 case moveActAbortOverdue:
494 if s.moveAbortOverdue(o, park.QuietUntil(c.QuietAfter, s.cfg.dbClaimHomeRegion(), park.PlacementRegion(rec.Move.To)), now) {
495 s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now)
496 }
497 return nil
498 }
499 return nil
500}
501
502// sourceFence is the moving source's termination fence, when one exists:
503// the reference to control/box-fences/<source enrollment>.json and its exact
504// bytes. Convert verifies it; nothing here trusts it.
505func (s *Server) sourceFence(ctx context.Context, c *hostclaim.Claim) (*hostclaim.FenceRef, error) {
506 return s.bucketFence(ctx, c.Op.Intent["source_enrollment"])
507}
508
509// bucketFence is the termination fence of the box enrolled as enrollment,
510// when one exists in the bucket: its reference and its exact bytes' digest.
511// The grant verifies it; nothing here trusts it.
512func (s *Server) bucketFence(ctx context.Context, enrollment string) (*hostclaim.FenceRef, error) {
513 if enrollment == "" {
514 return nil, nil
515 }
516 key := hostfence.FenceKey(enrollment)
517 body, _, err := s.cfg.Releases.Get(ctx, key)
518 if errors.Is(err, os.ErrNotExist) {
519 return nil, nil
520 }
521 if err != nil {
522 return nil, err
523 }
524 sum := sha256.Sum256(body)
525 return &hostclaim.FenceRef{Enrollment: enrollment, Key: key, SHA256: hex.EncodeToString(sum[:])}, nil
526}
527
528// moveDueSince is when the pass first saw this close due, remembering now
529// if it had not. The memory is the process's: a restarted console waits the
530// grace again, which only delays the console's close or page. Entries a day
531// old are dropped.
532func (s *Server) moveDueSince(key string, now time.Time) time.Time {
533 s.moveCloseMu.Lock()
534 defer s.moveCloseMu.Unlock()
535 if s.moveCloseSeen == nil {
536 s.moveCloseSeen = map[string]time.Time{}
537 }
538 for k, at := range s.moveCloseSeen {
539 if now.Sub(at) > 24*time.Hour {
540 delete(s.moveCloseSeen, k)
541 }
542 }
543 first, ok := s.moveCloseSeen[key]
544 if !ok {
545 s.moveCloseSeen[key] = now
546 return now
547 }
548 return first
549}
550
551// moveCloseOverdue reports whether this close has been due for
552// moveCloseGrace; when it has, the memory is cleared for the close.
553func (s *Server) moveCloseOverdue(o hostops.ConsoleMoveObservation, action hostops.MoveRecoveryRecordAction, now time.Time) bool {
554 key := o.Host + "|" + o.OpID + "|" + string(action)
555 if now.Sub(s.moveDueSince(key, now)) < moveCloseGrace {
556 return false
557 }
558 s.moveCloseMu.Lock()
559 delete(s.moveCloseSeen, key)
560 s.moveCloseMu.Unlock()
561 return true
562}
563
564// moveAbortOverdue reports whether an abort with nothing owed has stayed
565// unclosed past its lane's horizon (quiet, the claim's quiet_after for the
566// target region, when the claim has one) plus moveCloseGrace, and has been
567// seen due for moveCloseGrace itself. The page it leads to is once per move
568// (pageMove), so the memory is kept.
569func (s *Server) moveAbortOverdue(o hostops.ConsoleMoveObservation, quiet, now time.Time) bool {
570 since := s.moveDueSince(o.Host+"|"+o.OpID+"|abort", now)
571 if now.Sub(since) < moveCloseGrace {
572 return false
573 }
574 return quiet.IsZero() || !now.Before(quiet.Add(moveCloseGrace))
575}
576
577// pageMove sends one page for a finding (its kind, host and move) unless
578// the finding was paged before: ever, for a once-per-move page, or within
579// the last full hour, for the release deadline. The attempt's time is
580// recorded whether or not delivery worked (pages are best effort).
581func (s *Server) pageMove(ctx context.Context, kind movePageKind, o hostops.ConsoleMoveObservation, c *hostclaim.Claim, rec provision.Record, now time.Time) {
582 rule := string(kind) + " " + o.Host + " " + o.OpID
583 level := alerting.LevelWarning
584 every := time.Duration(0) // once per move
585 var subject, body string
586 switch kind {
587 case movePageReleaseDeadline:
588 level, every = alerting.LevelIncident, time.Hour
589 subject, body = moveWaitingPage(o.Host, o.OpID, rec.Move.To)
590 case movePagePreparing:
591 subject, body = movePreparingPageText(o.Host, o.OpID, rec.Move.To)
592 case movePageOrphan:
593 level = alerting.LevelIncident
594 subject, body = moveOrphanPage(o.Host, o.OpID)
595 case movePageCloseOverdue:
596 subject, body = moveCloseOverduePage(o.Host, o.OpID)
597 case movePageAbortOverdue:
598 subject, body = moveAbortOverduePage(o.Host, o.OpID, rec.Move.To, c.Op.Intent["source"])
599 case movePageTargetFencedDirty:
600 level = alerting.LevelIncident
601 subject, body = moveTargetFencedDirtyPage(o.Host, o.OpID, c.Op.Intent["target"], moves.RegionText(rec.Move.To), moves.RegionText(rec.Move.From))
602 default:
603 return
604 }
605 if s.store == nil {
606 return
607 }
608 last, paged, err := s.store.MovePageAttempt(rule)
609 if err != nil {
610 log.Printf("console: moves: %s: the page record for %s: %v", o.Host, rule, err)
611 return
612 }
613 if paged && (every == 0 || now.Sub(last) < every) {
614 return
615 }
616 if err := s.store.SetMovePageAttempt(rule, now); err != nil {
617 log.Printf("console: moves: %s: record the page %s: %v", o.Host, rule, err)
618 return
619 }
620 if err := alerting.Page(ctx, level, subject, body); err != nil {
621 log.Printf("console: moves: %s: page %s: %v", o.Host, kind, err)
622 }
623}
624
625// moveWaitingPage is a rollback waiting past its release deadline (§7.8
626// waiting): the subject (MV-N325) and the body (MV-N326).
627func moveWaitingPage(host, id, to string) (subject, body string) {
628 subject = fmt.Sprintf("carlos move waiting for %s to release: %s", to, host)
629 body = fmt.Sprintf("The move %s of %s is rolling back, and %s has not released it by its deadline. %s stays offline until a box in %s confirms it has stopped, or that box is declared lost.",
630 id, host, to, host, to)
631 return subject, body
632}
633
634// movePreparingPageText is a move preparing for over an hour (§13): the
635// subject (MV-N327) and the body (MV-N328).
636func movePreparingPageText(host, id, to string) (subject, body string) {
637 subject = fmt.Sprintf("carlos move preparing for over an hour: %s", host)
638 body = fmt.Sprintf("The move %s of %s to %s has been preparing for over an hour. It aborts itself six hours after the request if %s is still not ready.",
639 id, host, to, to)
640 return subject, body
641}
642
643// moveOrphanPage is a claim held by a move the record does not carry (K3):
644// the subject (MV-N329) and the body (MV-N330).
645func moveOrphanPage(host, id string) (subject, body string) {
646 subject = fmt.Sprintf("carlos move without a record: %s", host)
647 body = fmt.Sprintf("The host claim of %s is held by the move %s, and the instance record carries no move. Nothing releases it on its own. An operator must check the record.",
648 host, id)
649 return subject, body
650}
651
652// moveCloseOverduePage is a close the console wrote because no box did: the
653// subject (MV-N331) and the body (MV-N332).
654func moveCloseOverduePage(host, id string) (subject, body string) {
655 subject = fmt.Sprintf("carlos move close overdue: %s", host)
656 body = fmt.Sprintf("The move %s of %s was ready to close for more than 10 minutes and no box closed it. The console has closed it.", id, host)
657 return subject, body
658}
659
660// moveAbortOverduePage is an abort that owes nothing and that its source has
661// not closed (fix round 1, ruling 1): the subject (MV-N333) and the body
662// (MV-N334).
663func moveAbortOverduePage(host, id, to, box string) (subject, body string) {
664 subject = fmt.Sprintf("carlos move abort not closed: %s", host)
665 body = fmt.Sprintf("The abort of the move %s of %s has nothing left to park in %s, and %s has not closed it. %s closes it once it has removed the move's snapshots from its own bucket. Check %s.",
666 id, host, to, box, box, box)
667 return subject, body
668}
669
670// moveTargetFencedDirtyPage is a target terminated before it returned the
671// writes it made, whose fence the grant refuses (Q8, left for J4): the
672// subject (MV-N450) and the body (MV-N451). to and from are as a sentence
673// names them (moves.RegionText).
674func moveTargetFencedDirtyPage(host, id, box, to, from string) (subject, body string) {
675 subject = fmt.Sprintf("carlos move target terminated before returning its writes: %s", host)
676 body = fmt.Sprintf("%s in %s was terminated after it wrote data it had not returned to %s. the move %s of %s cannot finish on its own. run carlos move resolve --host %s --declare-lost %s to move %s back to %s.",
677 box, to, from, id, host, host, box, host, from)
678 return subject, body
679}
680
681// The move log of the console's own move writes (Plan 6 Task 3; fix round 3,
682// ruling 1): the closes, the landing deadline's rollback, the six-hour abort,
683// and the claim's rollback and release. Each entry is named as a box's write
684// of the same transition would be (moves.TransitionOf), from the write's own
685// evidence (the grant's Transition, or the record the pass acted on), and
686// carries the console as its actor; the console has no member sink.
687//
688// The release's own entry (claim_released) is published only from a release
689// that happened (ruling 2): queued unconfirmed before g.Claim and confirmed
690// after it succeeds; the drainer settles a row left unconfirmed (a failed
691// release, or a crash before the confirm) from the claim. A declared loss of
692// a move's target (declared_lost) is the console's too, from either door
693// that performs the declaration, in the same two phases (final review I4,
694// declareMoveLossLogged); the drainer settles its unconfirmed row from the
695// loss the claim records (settleDeclaredLost).
696//
697// An entry is queued in the console's outbox (move_log_outbox) with its
698// exact bytes and its report prefix, and the recovery pass's drainer
699// (drainMoveLog) delivers it, once a pass, whatever its move has done since:
700// independent of any grant (spent by the time the entry exists) and of the
701// claim's release, which never waits for the log. A row whose write fails
702// is retried with backoff, indefinitely, and warned of once in the journal
703// when it is an hour old. A confirmed row the writer refuses outright is
704// held, never deleted: kept, not retried and warned of once, until its
705// incarnation is retired or purged (PurgeMoveLogIncarnation). A release of a start never applied is not logged:
706// the record never named that move.
707
708// moveLogWarnAfter is the age at which an undelivered entry is warned of.
709const moveLogWarnAfter = time.Hour
710
711// moveLogDrainBatch bounds one pass's deliveries.
712const moveLogDrainBatch = 256
713
714// moveLogBackoff is the wait after a row's attempts-th failure: 30 seconds,
715// doubling, at most an hour.
716func moveLogBackoff(attempts int) time.Duration {
717 d := 30 * time.Second
718 for i := 1; i < attempts && d < time.Hour; i++ {
719 d *= 2
720 }
721 return min(d, time.Hour)
722}
723
724// moveLogEntry is the console's entry for t.
725func moveLogEntry(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) ([]byte, error) {
726 return json.Marshal(moves.LogEvent{V: 1, Op: t.Op, Host: o.Host, Event: t.Event, Identity: t.Identity, Actor: "console", At: now.UTC(), Fields: t.Fields})
727}
728
729// queueMoveLog queues the entry for t, for the drainer.
730func (s *Server) queueMoveLog(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) {
731 s.queueMoveLogAs(o, t, now, true)
732}
733
734// queueMoveLogAs queues the entry for t, confirmed or not: the drainer
735// delivers a confirmed entry, and settles an unconfirmed one from the claim
736// (settleUnconfirmed). The row is bound to the incarnation t's evidence
737// carried, which must be the grant's move and instance.
738func (s *Server) queueMoveLogAs(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time, confirmed bool) {
739 if err := s.queueMoveLogErr(o, t, now, confirmed); err != nil {
740 log.Printf("console: moves: %s: the log of move %s at %s is lost: %v", o.Host, o.OpID, t.Identity, err)
741 }
742}
743
744// queueMoveLogErr is queueMoveLogAs, answering whether the row is queued:
745// for a caller that must not act unless it is (declareMoveLossLogged).
746func (s *Server) queueMoveLogErr(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time, confirmed bool) error {
747 if t.Op != o.OpID || t.Incarnation != o.Incarnation {
748 return fmt.Errorf("the entry names move %s of incarnation %q, not move %s of %q", t.Op, t.Incarnation, o.OpID, o.Incarnation)
749 }
750 body, err := moveLogEntry(o, t, now)
751 if err != nil {
752 return err
753 }
754 return s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, t.Incarnation, body, confirmed, now)
755}
756
757// claimRootKey is the root public key the claim runtime signs with, which
758// the move loss challenges and records verify under; nil without the
759// runtime.
760func (s *Server) claimRootKey() ed25519.PublicKey {
761 if a := s.cfg.ClaimAuthority; a != nil {
762 return a.Signer.RootPublicKey()
763 }
764 return nil
765}
766
767// confirmMoveLog confirms t's entry once the write it names succeeded. The
768// row is queued again, confirmed, in case a pass dropped the unconfirmed one
769// in between (settleDeclaredLost drops a row whose loss is not committed
770// yet), and then marked confirmed if it was there already.
771func (s *Server) confirmMoveLog(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time) {
772 s.queueMoveLogAs(o, t, now, true)
773 if err := s.store.ConfirmMoveLog(o.Host, o.OpID, t.Identity); err != nil {
774 log.Printf("console: moves: %s: the log of move %s at %s is not confirmed in its log outbox (the drainer settles it): %v", o.Host, o.OpID, t.Identity, err)
775 }
776}
777
778// declareMoveLossLogged runs one declaration of a move's lost target
779// (declare, the configured authority's DeclareMoveLoss) with its move log
780// entry, declared_lost (final review I4): queued unconfirmed before the
781// declaration, from the record as the door reads it, and confirmed once the
782// declaration succeeds. The declaration moves the target on from lost to
783// released within the request, so the box's pass may never see it lost; the
784// console that performed it is the entry's producer. A record that cannot be
785// read, or a row that cannot be queued, refuses the declaration: an
786// operator's action fails closed rather than leave no retry for its entry
787// (final re-review 1, I4).
788func (s *Server) declareMoveLossLogged(ctx context.Context, account, app, host string, sub hostfence.Submission, declare func() error) error {
789 var (
790 o hostops.ConsoleMoveObservation
791 t moves.Transition
792 logged bool
793 )
794 rec, err := provision.GetRecord(ctx, s.cfg.Releases, account, app, host)
795 if err != nil {
796 // Fail closed (final re-review 1, I4): a declaration whose entry
797 // cannot be queued is not made.
798 return fmt.Errorf("moves: the declared loss cannot be queued in the move log: the record cannot be read: %w", err)
799 }
800 // Only a declaration whose signed consequence is the loss of this move's
801 // target is declared_lost; a destroyed-local-only one changes no move
802 // state and logs nothing (final re-review 1, N1).
803 if op, inc, ok := hostops.DeclaresMoveTargetLoss(s.claimRootKey(), sub); ok && rec.Move != nil && op == rec.Move.ID && inc == rec.Move.Incarnation {
804 if t, logged = moves.DeclaredLost(rec.Move); logged {
805 o = hostops.ConsoleMoveObservation{Host: host, OpID: rec.Move.ID, Incarnation: rec.Move.Incarnation}
806 if err := s.queueMoveLogErr(o, t, s.movesNow(), false); err != nil {
807 return fmt.Errorf("moves: the declared loss cannot be queued in the move log: %w", err)
808 }
809 }
810 }
811 if err := declare(); err != nil {
812 return err
813 }
814 if logged {
815 s.confirmMoveLog(o, t, s.movesNow())
816 }
817 return nil
818}
819
820// drainMoveLog is the Recovery.MoveLogs hook: every due row of the outbox,
821// delivered through the pass's writer.
822func (s *Server) drainMoveLog(ctx context.Context, lw *hostops.MoveLogWriter) error {
823 now := s.movesNow()
824 rows, err := s.store.MoveLogDue(now, moveLogDrainBatch)
825 if err != nil {
826 return fmt.Errorf("moves: the log outbox: %w", err)
827 }
828 for _, r := range rows {
829 if err := ctx.Err(); err != nil {
830 return err
831 }
832 if !r.Confirmed {
833 deliver, wait := s.settleUnconfirmed(ctx, r)
834 if wait != nil {
835 s.retryMoveLog(r, now, wait)
836 }
837 if !deliver {
838 continue
839 }
840 }
841 s.deliverMoveLog(ctx, lw, r, now)
842 }
843 return nil
844}
845
846// settleUnconfirmed settles a row queued for a release that was not yet
847// confirmed, from the host's claim: released for the row's move, it is
848// confirmed (deliver); still the move's, or the claim unreadable, it waits
849// (wait says why, and it takes a failed delivery's backoff and warning, so
850// it rotates out of the batch); anything else, it is dropped with a journal
851// line.
852func (s *Server) settleUnconfirmed(ctx context.Context, r MoveLogPendingEntry) (deliver bool, wait error) {
853 if r.Name == moves.DeclaredLostIdentity {
854 return s.settleDeclaredLost(ctx, r)
855 }
856 o, err := hostclaim.Read(ctx, s.cfg.Releases, r.Host)
857 if err != nil {
858 return false, fmt.Errorf("unconfirmed, and the claim cannot be read: %w", err)
859 }
860 switch {
861 case !o.Absent && o.Claim != nil && hostclaim.RequireReleasedMove(r.Op)(o.Claim) == nil:
862 if err := s.store.ConfirmMoveLog(r.Host, r.Op, r.Name); err != nil {
863 return false, fmt.Errorf("unconfirmed: %w", err)
864 }
865 return true, nil
866 case !o.Absent && o.Claim != nil && o.Claim.Op.ID == r.Op:
867 return false, errors.New("unconfirmed: the claim still holds the move")
868 }
869 log.Printf("console: moves: %s: the log of move %s at %s was never confirmed and the claim no longer holds the move; dropped", r.Host, r.Op, r.Name)
870 if err := s.store.SettleMoveLog(r.Host, r.Op, r.Name); err != nil {
871 log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
872 }
873 return false, nil
874}
875
876// settleDeclaredLost settles an unconfirmed declared_lost row (final review
877// I4; final re-review 1) from the claim. A committed loss of the move's
878// target, in the claim's loss_refs (which the declaration's claim CAS writes
879// as it commits, and nothing removes), confirms the row (deliver). While the
880// move can still be declared lost (the claim's op is still the move, not
881// released to serving) the row is kept, however long, with the failed
882// delivery's backoff and its one warning. It is dropped only once the claim
883// shows the move closed or released with no such loss, or the instance's
884// incarnation gone (no claim, or another holder); the incarnation's purge
885// deletes it too (PurgeMoveLogIncarnation). The drop deletes only a row
886// still unconfirmed, so a declaration that confirmed its row after the claim
887// was read here keeps it. A claim or loss record that cannot be read waits.
888func (s *Server) settleDeclaredLost(ctx context.Context, r MoveLogPendingEntry) (deliver bool, wait error) {
889 o, err := hostclaim.Read(ctx, s.cfg.Releases, r.Host)
890 if err != nil {
891 return false, fmt.Errorf("unconfirmed, and the claim cannot be read: %w", err)
892 }
893 if f := s.moveLogSettleHook; f != nil {
894 f()
895 }
896 drop := func(why string) (bool, error) {
897 log.Printf("console: moves: %s: the log of move %s at %s was never confirmed and %s; dropped", r.Host, r.Op, r.Name, why)
898 if err := s.store.DropUnconfirmedMoveLog(r.Host, r.Op, r.Name); err != nil {
899 log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
900 }
901 return false, nil
902 }
903 if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Incarnation != r.Incarnation {
904 return drop("the claim no longer holds the instance it was queued for")
905 }
906 if len(o.Claim.LossRefs) > 0 {
907 pub := s.claimRootKey()
908 if len(pub) != ed25519.PublicKeySize {
909 return false, errors.New("unconfirmed, and there is no key to read the claim's loss records")
910 }
911 for _, id := range o.Claim.LossRefs {
912 l, err := hostclaim.ReadLossRecord(ctx, s.cfg.Releases, pub, id)
913 if errors.Is(err, hostclaim.ErrLossRecordAbsent) || errors.Is(err, hostclaim.ErrLossRecord) {
914 continue
915 }
916 if err != nil {
917 return false, fmt.Errorf("unconfirmed, and a loss record cannot be read: %w", err)
918 }
919 // Only a loss of the move's target, signed as such, is the
920 // row's evidence: a destroyed-local-only loss of the same op is
921 // not (final re-review 1, N1).
922 op, inc, target := hostops.MoveTargetLossOf(l.Record)
923 if !target || op != r.Op || inc != r.Incarnation || l.Record.Host != r.Host || l.Record.OpID != r.Op {
924 continue
925 }
926 if err := s.store.ConfirmMoveLog(r.Host, r.Op, r.Name); err != nil {
927 return false, fmt.Errorf("unconfirmed: %w", err)
928 }
929 return true, nil
930 }
931 }
932 if o.Claim.Op.ID == r.Op && o.Claim.Op.Kind != hostclaim.KindServing {
933 return false, errors.New("unconfirmed: no loss of the move's target has committed, and the move can still be declared lost")
934 }
935 return drop("the claim shows the move closed or released with no loss of its target")
936}
937
938// retryMoveLog moves a row's next attempt on after one that did not settle
939// it (why), and warns of it once in the journal when it is an hour old.
940func (s *Server) retryMoveLog(r MoveLogPendingEntry, now time.Time, why error) {
941 attempts, warned := r.Attempts+1, r.Warned
942 if !warned && now.Sub(r.QueuedAt) >= moveLogWarnAfter {
943 log.Printf("console: moves: warning: %s: the log of move %s at %s has been unwritten for over an hour: %v", r.Host, r.Op, r.Name, why)
944 warned = true
945 }
946 if err := s.store.RetryMoveLog(r.Host, r.Op, r.Name, attempts, now.Add(moveLogBackoff(attempts)), warned); err != nil {
947 log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
948 }
949}
950
951// deliverMoveLog writes one row, and settles it or schedules its retry.
952func (s *Server) deliverMoveLog(ctx context.Context, lw *hostops.MoveLogWriter, r MoveLogPendingEntry, now time.Time) {
953 settle := func() {
954 if err := s.store.SettleMoveLog(r.Host, r.Op, r.Name); err != nil {
955 log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err)
956 }
957 }
958 _, err := lw.WriteMoveLog(ctx, hostops.MoveLogEntry{Host: r.Host, Op: r.Op, Incarnation: r.Incarnation, Name: r.Name, Body: r.Body})
959 switch {
960 case err == nil:
961 settle()
962 case errors.Is(err, hostops.ErrMoveLogTaken):
963 log.Printf("console: moves: %s: the log of move %s at %s was written by another writer first", r.Host, r.Op, r.Name)
964 settle()
965 case errors.Is(err, hostops.ErrIdentity):
966 // The writer refuses it (its claim's incarnation or record no
967 // longer vouches for it: a later move's close, a recreation, a
968 // retirement outside the console's door). The row is never deleted
969 // for that: it is held, its bytes kept and no longer retried, until
970 // its incarnation's purge (final fix, ruling 1).
971 log.Printf("console: moves: warning: %s: the log of move %s at %s is held: the writer refuses it (%v); its entry is kept until its incarnation is purged", r.Host, r.Op, r.Name, err)
972 if herr := s.store.HoldMoveLog(r.Host, r.Op, r.Name); herr != nil {
973 log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, herr)
974 }
975 default:
976 s.retryMoveLog(r, now, err)
977 }
978}
979
980// abortTransition is the six-hour abort's entry: the record the pass acted
981// on, as the abort leaves it (aborting).
982func abortTransition(rec provision.Record) (moves.Transition, bool) {
983 if rec.Move == nil {
984 return moves.Transition{}, false
985 }
986 after, m := rec, *rec.Move
987 m.State = provision.MoveAborting
988 after.Move = &m
989 return moves.TransitionOf(rec, after)
990}
991
992// claimTransition is the entry for a claim write of the console's: the move's
993// regions and incarnation from the record the pass acted on (a closed
994// move's, from the grant's observation of the same instance).
995func claimTransition(o hostops.ConsoleMoveObservation, rec provision.Record, event, identity string) moves.Transition {
996 t := moves.Transition{Op: o.OpID, Event: event, Identity: identity, Incarnation: o.Incarnation}
997 if m := rec.Move; m != nil {
998 t.Incarnation = m.Incarnation
999 t.Fields = map[string]string{"from": m.From, "to": m.To}
1000 }
1001 return t
1002}
1003