package release import ( "context" "errors" "fmt" "path/filepath" "time" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/registry" ) // The carry fence (settings plan 4, ruling R-CARRY-FENCE; Astra round 2 // finding 2). Root publishes its request record for a host in // /opt/carlos/consumed/ (WriteConsumed); before a graceful move seals // the build state, the agent carries that record into it (CarryRequest). // Root's pass decides from a registry snapshot taken before the pass, so // without a fence a pass already running when a cut begins could publish a // resolution or a failed completion after the carry read the file: the cut // seals a state without it, and the destination resolves the request again. // // The two serialize on one cross-process lock per host, flock on // ConsumedLockPath. Root takes it, re-reads the route and publishes only // when the route is in no move hold (publishGate), then releases. The cut // takes it only once its move hold is effective (the row's status is a hold: // registry.IsHold), carries, then releases. So a root publication lands // before the carry and is carried, or sees the hold and is not made at all // (nothing is installed for that route on that pass). Neither side waits on // the other for more than one record write or one build-state write. // consumedFenceWait bounds a wait for the carry fence, on either side. A // var so tests can shrink it. var consumedFenceWait = 30 * time.Second // ErrMoveHeld is root's refusal to publish a request record for a route a // move holds (publishGate): nothing is written and nothing installed for it // on that pass. var ErrMoveHeld = errors.New("the route is held by a move, so root publishes no request record for it") // ConsumedLockPath is host's carry-fence lock file, beside its consumed file. // The leading dot is no host's shape, so it never reads as a consumed file. // The caller validates host (controlname.ValidHostShape). func ConsumedLockPath(optDir, host string) string { return filepath.Join(optDir, "consumed", "."+host+".lock") } // publishGate is root's re-check inside the carry fence: nil when root may // publish a request record for rt, the route as the registry holds it now. // A row a move holds (any of registry.HoldStatuses: the source moving, a // rollback's restoring or releasing, a landing) or a row gone from this box // is refused with ErrMoveHeld. The update simulation calls it too. func publishGate(rt *registry.Route) error { if rt == nil { return fmt.Errorf("%w: the row is gone from this box", ErrMoveHeld) } if registry.IsHold(rt.Status) { return fmt.Errorf("%w: its status is %q", ErrMoveHeld, rt.Status) } return nil } // beforeConsumedWrite, when set (tests only), runs inside root's fenced // publication: at "fence" before the lock is taken, and at "write" with the // lock held and the re-check passed, immediately before WriteConsumed. var beforeConsumedWrite func(host, at string) // publishConsumed is root's publication of c under the carry fence: the // lock, the route re-read and re-checked (publishGate), then WriteConsumed. // route reads the registry row now. A refusal (ErrMoveHeld), a fence that // could not be taken, or a row that does not read writes nothing. func publishConsumed(ctx context.Context, optDir string, route func(host string) (*registry.Route, error), c Consumed) error { if !controlname.ValidHostShape(c.Host) { return fmt.Errorf("consumed: invalid host %q", c.Host) } if hook := beforeConsumedWrite; hook != nil { hook(c.Host, "fence") } unlock, err := lockConsumedPublish(ctx, optDir, c.Host) if err != nil { return fmt.Errorf("the carry fence: %w", err) } defer unlock() if route == nil { return errors.New("no registry to re-check the route under the carry fence") } rt, err := route(c.Host) if err != nil { return fmt.Errorf("re-read the route under the carry fence: %w", err) } if err := publishGate(rt); err != nil { return err } if hook := beforeConsumedWrite; hook != nil { hook(c.Host, "write") } return WriteConsumed(optDir, c) } // FenceCarry is the cut's side of the carry fence for host, taken once the // move hold on its row is effective and held across the carry: unlock // releases it. A host whose lock file does not exist (root has never // published under the fence for it, or the consumed directory does not // exist) is carried without the lock: any later publication takes the lock // first and then sees the hold. A lock file that does not open, or a fence // not taken within its bound, is an error, and the cut does not carry. func FenceCarry(ctx context.Context, optDir, host string) (unlock func(), err error) { if !controlname.ValidHostShape(host) { return nil, fmt.Errorf("consumed: invalid host %q", host) } return lockConsumedCarry(ctx, optDir, host) }