A controller restart lost every call's outcome, `status` composed the mesh while its caller waited (18.6s live on 2026-10-06, past the 10s window), a repair by hand left no trace, and the core's bounds had nothing measured to be set from. - calls: kept in the controller's bucket mesh-controller_calls (last 1000 or 14 days, answers bounded to 64 KiB), read by id across a restart; a controller starting marks a stopped one's running calls abandoned; each call names its caller from the inbox its answer goes to. - status: the serving controller composes it at start, after news from a machine, a build or an acting verb, and every minute; the verb answers the last composition at once with when and how long it took. Composing resolves each machine once instead of twice. - hand-act log in mesh-controller_hand-acts: push (required through the seat), plans stop/close, broker consumer-reset and the new hand-act record take --why/--cause/--condition; `hand-acts` lists them and repeated causes; status counts the week's. - durations (migration 0066): apply (send to first report), heartbeat gap, plan tier and build, recorded as heard; `durations` summarises them. - the controller's seat row takes this binary's definition of its own verbs, so the console no longer judges calls against an older build's schema. - the controller is granted its two buckets' subjects.
147 lines
5.5 KiB
Go
147 lines
5.5 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// The durations the core's bounds are set from (novox/hq to-be 45 Phase 0): see migration 0066.
|
|
|
|
// The kinds of duration recorded.
|
|
const (
|
|
DurationApply = "apply"
|
|
DurationHeartbeatGap = "heartbeat-gap"
|
|
DurationPlanTier = "plan-tier"
|
|
DurationBuild = "build"
|
|
)
|
|
|
|
// DurationKinds are every kind, in the order `durations` shows them.
|
|
var DurationKinds = []string{DurationApply, DurationHeartbeatGap, DurationPlanTier, DurationBuild}
|
|
|
|
// DurationsKeptFor is how long a duration is kept: long enough to set a bound from, and to correct it
|
|
// in Phase 1's first live week.
|
|
const DurationsKeptFor = 30 * 24 * time.Hour
|
|
|
|
// Duration is one measurement.
|
|
type Duration struct {
|
|
Kind string `json:"kind"`
|
|
Subject string `json:"subject"`
|
|
Node string `json:"node,omitempty"`
|
|
Ref string `json:"ref"`
|
|
Started time.Time `json:"started"`
|
|
Took time.Duration `json:"took"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// RecordDuration keeps one measurement, once: the same thing measured again is not a second row.
|
|
func (i *Inventory) RecordDuration(ctx context.Context, d Duration) error {
|
|
if d.Took < 0 {
|
|
return nil // a clock that went back measures nothing
|
|
}
|
|
_, err := i.store.Pool().Exec(ctx,
|
|
`insert into duration (kind, subject, node, ref, started, took_ms, detail)
|
|
values ($1, $2, $3, $4, $5, $6, $7) on conflict do nothing`,
|
|
d.Kind, d.Subject, d.Node, d.Ref, d.Started, d.Took.Milliseconds(), d.Detail)
|
|
return err
|
|
}
|
|
|
|
// RecordApplyDuration measures a machine's report of the declaration it was last sent: from the send
|
|
// to the first report of it. A report of anything else, or of a send already measured, measures
|
|
// nothing — a machine reconciling reports the same declaration every few minutes.
|
|
func (i *Inventory) RecordApplyDuration(ctx context.Context, node, declared, outcome string) error {
|
|
if declared == "" {
|
|
return nil
|
|
}
|
|
_, err := i.store.Pool().Exec(ctx,
|
|
`insert into duration (kind, subject, node, ref, started, took_ms, detail)
|
|
select $1, n.name, n.name, n.sent || '@' || to_char(n.sent_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US'),
|
|
n.sent_at, (extract(epoch from (now() - n.sent_at)) * 1000)::bigint, $4
|
|
from node n
|
|
where n.name = $2 and n.sent = $3 and n.sent_at is not null
|
|
on conflict do nothing`,
|
|
DurationApply, node, declared, outcome)
|
|
return err
|
|
}
|
|
|
|
// RecordHeartbeatGap measures the silence before a machine's word: from the last word heard before
|
|
// it, which the caller read before recording this one.
|
|
func (i *Inventory) RecordHeartbeatGap(ctx context.Context, node string, before time.Time) error {
|
|
if before.IsZero() {
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
return i.RecordDuration(ctx, Duration{Kind: DurationHeartbeatGap, Subject: node, Node: node,
|
|
Ref: before.UTC().Format(time.RFC3339Nano), Started: before, Took: now.Sub(before)})
|
|
}
|
|
|
|
// Durations is every measurement of a kind since a moment, oldest first; every kind when kind is empty.
|
|
func (i *Inventory) Durations(ctx context.Context, kind string, since time.Time) ([]Duration, error) {
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select kind, subject, node, ref, started, took_ms, detail from duration
|
|
where ($1 = '' or kind = $1) and recorded >= $2 order by recorded`, kind, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Duration
|
|
for rows.Next() {
|
|
var d Duration
|
|
var ms int64
|
|
if err := rows.Scan(&d.Kind, &d.Subject, &d.Node, &d.Ref, &d.Started, &ms, &d.Detail); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Took = time.Duration(ms) * time.Millisecond
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ForgetOldDurations removes what is older than DurationsKeptFor, and says how many.
|
|
func (i *Inventory) ForgetOldDurations(ctx context.Context) (int64, error) {
|
|
tag, err := i.store.Pool().Exec(ctx, `delete from duration where recorded < $1`,
|
|
time.Now().Add(-DurationsKeptFor))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// planTierLeft is the measurement a plan's save makes when it leaves a tier: the tier moved on, or
|
|
// the plan ended. Read in the save's own transaction, so two saves cannot both measure one tier.
|
|
func planTierLeft(ctx context.Context, tx pgx.Tx, p Plan, now time.Time) (entered time.Time, err error) {
|
|
var oldTier int
|
|
var oldState string
|
|
var since time.Time
|
|
err = tx.QueryRow(ctx,
|
|
`select tier, state, coalesce(tier_entered, created) from release_plan where id = $1 for update`,
|
|
p.ID).Scan(&oldTier, &oldState, &since)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return now, nil // a new plan enters its first tier now
|
|
}
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
wasOpen := oldState == PlanBuilding || oldState == PlanRolling
|
|
if !wasOpen || (oldTier == p.Tier && p.Open()) {
|
|
return since, nil // still in the tier, or already ended
|
|
}
|
|
var modules []string
|
|
if oldTier >= 0 && oldTier < len(p.Tiers) {
|
|
modules = p.Tiers[oldTier]
|
|
}
|
|
detail := fmt.Sprintf("plan %s, tier %d of %d (%v), left %s", p.ID, oldTier, len(p.Tiers), modules, p.State)
|
|
if p.Open() {
|
|
detail = fmt.Sprintf("plan %s, tier %d of %d (%v), moved on to tier %d", p.ID, oldTier, len(p.Tiers), modules, p.Tier)
|
|
}
|
|
_, err = tx.Exec(ctx,
|
|
`insert into duration (kind, subject, node, ref, started, took_ms, detail)
|
|
values ($1, $2, '', $3, $4, $5, $6) on conflict do nothing`,
|
|
DurationPlanTier, p.Repository, fmt.Sprintf("%s/tier-%d", p.ID, oldTier), since,
|
|
now.Sub(since).Milliseconds(), detail)
|
|
return now, err
|
|
}
|