| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/controlname" |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | ) |
| 13 | |
| 14 | // The carry fence (settings plan 4, ruling R-CARRY-FENCE; Astra round 2 |
| 15 | // finding 2). Root publishes its request record for a host in |
| 16 | // /opt/carlos/consumed/<host> (WriteConsumed); before a graceful move seals |
| 17 | // the build state, the agent carries that record into it (CarryRequest). |
| 18 | // Root's pass decides from a registry snapshot taken before the pass, so |
| 19 | // without a fence a pass already running when a cut begins could publish a |
| 20 | // resolution or a failed completion after the carry read the file: the cut |
| 21 | // seals a state without it, and the destination resolves the request again. |
| 22 | // |
| 23 | // The two serialize on one cross-process lock per host, flock on |
| 24 | // ConsumedLockPath. Root takes it, re-reads the route and publishes only |
| 25 | // when the route is in no move hold (publishGate), then releases. The cut |
| 26 | // takes it only once its move hold is effective (the row's status is a hold: |
| 27 | // registry.IsHold), carries, then releases. So a root publication lands |
| 28 | // before the carry and is carried, or sees the hold and is not made at all |
| 29 | // (nothing is installed for that route on that pass). Neither side waits on |
| 30 | // the other for more than one record write or one build-state write. |
| 31 | |
| 32 | // consumedFenceWait bounds a wait for the carry fence, on either side. A |
| 33 | // var so tests can shrink it. |
| 34 | var consumedFenceWait = 30 * time.Second |
| 35 | |
| 36 | // ErrMoveHeld is root's refusal to publish a request record for a route a |
| 37 | // move holds (publishGate): nothing is written and nothing installed for it |
| 38 | // on that pass. |
| 39 | var ErrMoveHeld = errors.New("the route is held by a move, so root publishes no request record for it") |
| 40 | |
| 41 | // ConsumedLockPath is host's carry-fence lock file, beside its consumed file. |
| 42 | // The leading dot is no host's shape, so it never reads as a consumed file. |
| 43 | // The caller validates host (controlname.ValidHostShape). |
| 44 | func ConsumedLockPath(optDir, host string) string { |
| 45 | return filepath.Join(optDir, "consumed", "."+host+".lock") |
| 46 | } |
| 47 | |
| 48 | // publishGate is root's re-check inside the carry fence: nil when root may |
| 49 | // publish a request record for rt, the route as the registry holds it now. |
| 50 | // A row a move holds (any of registry.HoldStatuses: the source moving, a |
| 51 | // rollback's restoring or releasing, a landing) or a row gone from this box |
| 52 | // is refused with ErrMoveHeld. The update simulation calls it too. |
| 53 | func publishGate(rt *registry.Route) error { |
| 54 | if rt == nil { |
| 55 | return fmt.Errorf("%w: the row is gone from this box", ErrMoveHeld) |
| 56 | } |
| 57 | if registry.IsHold(rt.Status) { |
| 58 | return fmt.Errorf("%w: its status is %q", ErrMoveHeld, rt.Status) |
| 59 | } |
| 60 | return nil |
| 61 | } |
| 62 | |
| 63 | // beforeConsumedWrite, when set (tests only), runs inside root's fenced |
| 64 | // publication: at "fence" before the lock is taken, and at "write" with the |
| 65 | // lock held and the re-check passed, immediately before WriteConsumed. |
| 66 | var beforeConsumedWrite func(host, at string) |
| 67 | |
| 68 | // publishConsumed is root's publication of c under the carry fence: the |
| 69 | // lock, the route re-read and re-checked (publishGate), then WriteConsumed. |
| 70 | // route reads the registry row now. A refusal (ErrMoveHeld), a fence that |
| 71 | // could not be taken, or a row that does not read writes nothing. |
| 72 | func publishConsumed(ctx context.Context, optDir string, route func(host string) (*registry.Route, error), c Consumed) error { |
| 73 | if !controlname.ValidHostShape(c.Host) { |
| 74 | return fmt.Errorf("consumed: invalid host %q", c.Host) |
| 75 | } |
| 76 | if hook := beforeConsumedWrite; hook != nil { |
| 77 | hook(c.Host, "fence") |
| 78 | } |
| 79 | unlock, err := lockConsumedPublish(ctx, optDir, c.Host) |
| 80 | if err != nil { |
| 81 | return fmt.Errorf("the carry fence: %w", err) |
| 82 | } |
| 83 | defer unlock() |
| 84 | if route == nil { |
| 85 | return errors.New("no registry to re-check the route under the carry fence") |
| 86 | } |
| 87 | rt, err := route(c.Host) |
| 88 | if err != nil { |
| 89 | return fmt.Errorf("re-read the route under the carry fence: %w", err) |
| 90 | } |
| 91 | if err := publishGate(rt); err != nil { |
| 92 | return err |
| 93 | } |
| 94 | if hook := beforeConsumedWrite; hook != nil { |
| 95 | hook(c.Host, "write") |
| 96 | } |
| 97 | return WriteConsumed(optDir, c) |
| 98 | } |
| 99 | |
| 100 | // FenceCarry is the cut's side of the carry fence for host, taken once the |
| 101 | // move hold on its row is effective and held across the carry: unlock |
| 102 | // releases it. A host whose lock file does not exist (root has never |
| 103 | // published under the fence for it, or the consumed directory does not |
| 104 | // exist) is carried without the lock: any later publication takes the lock |
| 105 | // first and then sees the hold. A lock file that does not open, or a fence |
| 106 | // not taken within its bound, is an error, and the cut does not carry. |
| 107 | func FenceCarry(ctx context.Context, optDir, host string) (unlock func(), err error) { |
| 108 | if !controlname.ValidHostShape(host) { |
| 109 | return nil, fmt.Errorf("consumed: invalid host %q", host) |
| 110 | } |
| 111 | return lockConsumedCarry(ctx, optDir, host) |
| 112 | } |
| 113 | |