package inventory import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // A Plan is what a merge produces (novox/hq ADR 0162): the modules it changed and everything // standing on them, sorted into tiers, each module's state, and the tier the plan is at. Kept in // the store so a controller replaced mid-plan resumes it, and so `status` can say what a merge // still waits for. type Plan struct { ID string `json:"id"` Repository string `json:"repository"` // Branch is the branch the merge went into (novox/hq issue 254): a newer plan supersedes the open // ones of the same repository and branch. Empty for a plan from before it was kept. Branch string `json:"branch,omitempty"` Commit string `json:"commit"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` State string `json:"state"` Tier int `json:"tier"` Tiers [][]string `json:"tiers"` Modules map[string]*PlanModule `json:"modules"` Note string `json:"note,omitempty"` // TierEntered is when the plan entered the tier it is at (novox/hq to-be 45 Phase 0), read and // never written from here: a save measures the tier it leaves and stamps the next. What the // watchdog of a plan's progress (S3) reads. TierEntered time.Time `json:"tier_entered,omitempty"` // Revision is the plan's as it was read, and the one a save must find (novox/hq to-be 45 §6): a // plan is written by compare-and-set, so a write against a plan another writer moved since is // refused rather than laid over it. Zero is a plan never saved. SavePlan moves it. Revision int64 `json:"revision"` // Epoch is the controller lease epoch that wrote it last; zero for a write that claimed none. Epoch uint64 `json:"epoch,omitempty"` } // ErrPlanMoved is a save against a plan written by somebody else since it was read. var ErrPlanMoved = errors.New("the plan was written by somebody else since it was read") // PlanModule is one module's state within a plan. type PlanModule struct { // State: asked, built, failed; empty for a module whose tier has not been asked yet. State string `json:"state,omitempty"` AskedAt *time.Time `json:"asked_at,omitempty"` BuiltAt *time.Time `json:"built_at,omitempty"` // SentAt is when the plan sent the machines running this module its new build, because a // later tier is built by it (ADR 0163's gate): the reports that open the gate are the ones // after this. SentAt *time.Time `json:"sent_at,omitempty"` // First is the machines the plan sent the new build to first, and FirstAt when (novox/hq issue // 249, ADR 0218): unless the module's policy rolls it out together, one machine takes it before // the rest, and the rest are sent once that one reports it applied. Kept so a controller // replaced while the plan waits on that report resumes the wait rather than sending again. The // machine holding the bus is among them when its user list had to go first. First []string `json:"first,omitempty"` FirstAt *time.Time `json:"first_at,omitempty"` Commit string `json:"commit,omitempty"` Why string `json:"why,omitempty"` // Build is the id of the build the plan asked for this module (novox/hq ADR 0219), so the plan // matches its outcome by id — the one thing every outcome echoes, a failed one that never learnt // its module's name included. Empty in a plan from before it was kept, which is matched by // module, or by repository and path, as before. Build string `json:"build,omitempty"` } // The states a plan passes through. const ( PlanBuilding = "building" PlanRolling = "rolling" PlanDone = "done" PlanFailed = "failed" // PlanSuperseded is a plan a newer merge of the same repository and branch took over (novox/hq // issue 254, ADR 0218): what it had not built is in the newer plan, and its note names it. PlanSuperseded = "superseded" ) // Open says whether the plan is still being worked. func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRolling } // SavePlan writes a plan, new or changed, whole: the plan is small and read as one thing. // // **By compare-and-set on its revision, carrying the epoch** (novox/hq to-be 45 §6): written only if // the plan is still at the revision it was read at — a new one only if it does not exist — and refused // with ErrPlanMoved otherwise; and only by a process that may act (ActsUnder), whose epoch it records. // On success p's revision and epoch are the ones written, so the caller may save it again. func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error { epoch, err := i.actingEpoch(ctx) if err != nil { return fmt.Errorf("the plan for %s %s is not written: %w", p.Repository, p.Commit, err) } tiers, err := json.Marshal(p.Tiers) if err != nil { return err } modules, err := json.Marshal(p.Modules) if err != nil { return err } // **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the // plan moves, in the same transaction as the move, so no save can move a tier unmeasured or // measure one twice. tx, err := i.store.Pool().Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() entered, err := planTierLeft(ctx, tx, *p, time.Now()) if err != nil { return err } var revision int64 err = tx.QueryRow(ctx, `insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch, tier_entered, revision, epoch) values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13) on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier, tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch, tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch where release_plan.revision = $12 returning revision`, p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered, p.Revision, epoch).Scan(&revision) if errors.Is(err, pgx.ErrNoRows) { // The row is there and at another revision — moved since this was read, or there already // when this one is new: either way not this writer's to overwrite. (A plan saved before plans // had revisions is at zero, and its first save here is from a read at zero.) return fmt.Errorf("the plan for %s %s (%s) is not written: %w", p.Repository, short(p.Commit), p.ID, ErrPlanMoved) } if err != nil { return err } if err := tx.Commit(ctx); err != nil { return err } p.Revision, p.TierEntered = revision, entered if epoch != nil { p.Epoch = uint64(*epoch) } else { p.Epoch = 0 } return nil } // short is a commit as a person reads it. func short(commit string) string { if len(commit) > 8 { return commit[:8] } return commit } // OpenPlans is every plan still being worked, oldest first. func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) { return i.plans(ctx, `where state in ('building', 'rolling') order by created`) } // RecentPlans is the last few plans, newest first, open or not — what the overview shows. func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) { return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit)) } // PlanByID is one plan. func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) { plans, err := i.plans(ctx, `where id = '`+id+`'`) if err != nil { return Plan{}, err } if len(plans) == 0 { return Plan{}, fmt.Errorf("no plan %s", id) } return plans[0], nil } func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) { rows, err := i.store.Pool().Query(ctx, `select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch, coalesce(tier_entered, created), revision, coalesce(epoch, 0) from release_plan `+tail) if err != nil { return nil, err } defer rows.Close() var out []Plan for rows.Next() { var p Plan var tiers, modules []byte var epoch int64 if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State, &p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch); err != nil { return nil, err } p.Epoch = uint64(epoch) if err := json.Unmarshal(tiers, &p.Tiers); err != nil { return nil, err } if err := json.Unmarshal(modules, &p.Modules); err != nil { return nil, err } if p.Modules == nil { p.Modules = map[string]*PlanModule{} } out = append(out, p) } if errors.Is(rows.Err(), pgx.ErrNoRows) { return nil, nil } return out, rows.Err() }