Add the chain package and its buckets: builds, rollouts and assignments with history 64 (issue 1089)

internal/chain holds typed Build, Rollout and Assignment and a Backend with no delete or purge, in memory and on the bus. The builds and rollouts buckets are asserted with history 64, no age and values up to just under a mebibyte; the assignments bucket gets the same, keeping its marker age because a stream cannot be made to disallow message ages. Migration 0104 makes the one-row chain_moved switch. The controller keeps no consolidated schema, so 0104 is the whole schema change. Nothing reads the buckets yet.
This commit is contained in:
2026-10-18 02:39:11 +02:00
parent 54070f5bc3
commit fc55785956
15 changed files with 1175 additions and 9 deletions
+77 -6
View File
@@ -92,9 +92,41 @@ const (
MirrorPredefined = "predefined"
)
// ControllerMirrors are the local names of the controller's record mirrors, which a module may read.
// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus
// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is
// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep
// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6).
var (
BuildsBucket = BucketName(ControllerSeat, ChainBuilds)
RolloutsBucket = BucketName(ControllerSeat, ChainRollouts)
)
// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`.
const (
ChainBuilds = "builds"
ChainRollouts = "rollouts"
)
// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes,
// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6):
// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of
// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two
// that grow, and the assignments, 285 of under a kilobyte, 64 MiB.
const (
ChainHistory = 64
ChainValueBytes = 1<<20 - 8<<10
ChainBuildsBytes = 512 << 20
ChainRolloutsBytes = 512 << 20
ChainAssignmentBytes = 64 << 20
)
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
// ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the
// chain's buckets.
func ControllerMirrors() []string {
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined}
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts}
}
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
@@ -151,14 +183,14 @@ func IsControllerBucket(bucket string) bool {
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
bucket == DeliveryMergesBucket
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket
}
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
func ControllerBuckets() []string {
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
DeliveryMergesBucket}
DeliveryMergesBucket, BuildsBucket, RolloutsBucket}
}
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
@@ -315,11 +347,36 @@ func (j *JetStream) EnsureControllerBuckets() error {
}); err != nil {
return fmt.Errorf("asserting bucket %s: %w", DeliveryMergesBucket, err)
}
// **The chain: no age, no removal, history 64, no cut** (ADR 0408 §4 to §6). A key is never removed, so no purge
// marker needs an age and a key that expired would be a record the bus forgot.
for _, c := range []struct {
bucket, why string
bytes int64
}{
{BuildsBucket, "every build, one key each by its id, with its repository, commit, reuse key and what it " +
"published (novox/hq ADR 0408, design 47): written by the controller alone, never removed, read through `reads`",
ChainBuildsBytes},
{RolloutsBucket, "every rollout, one key each by its id: the commit it fixed per repository, its tiers, the " +
"builds it carried and the assignments it changed (novox/hq ADR 0408, design 47): written by the " +
"controller alone, never removed, read through `reads`", ChainRolloutsBytes},
} {
if _, err := js.CreateOrUpdateKeyValue(ctx, chainBucket(c.bucket, c.why, c.bytes, 0)); err != nil {
return fmt.Errorf("asserting bucket %s: %w", c.bucket, err)
}
}
// **The assignments keep the marker age they had as a mirror.** A stream that allows messages an age cannot be
// made to disallow them (the server refuses: "message TTL status can not be disabled"), so the bucket that was
// the assignments mirror is asserted with the age it was made with. Nothing is purged from it, so no marker is ever
// written and the age ends nothing.
if _, err := js.CreateOrUpdateKeyValue(ctx, chainBucket(AssignmentsBucket,
"which module is assigned to which node, keyed <node>.<module>, with the build, commit and rollout it runs "+
"there (novox/hq ADR 0408, design 47): written by the controller alone, never removed, read through `reads`",
ChainAssignmentBytes, MirrorMarkerTTL)); err != nil {
return fmt.Errorf("asserting bucket %s: %w", AssignmentsBucket, err)
}
// **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose
// marker lives a day; a key that expired would be a record the bus forgot while the store still holds it.
for _, m := range []struct{ bucket, why string }{
{AssignmentsBucket, "which module is assigned to which node, keyed <node>.<module> (novox/hq ADR 0289, " +
"design 54): written by the controller alone from its store when an assignment changes, read through `reads`"},
{DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " +
"the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " +
"registration changes them, read through `reads`"},
@@ -342,6 +399,20 @@ func (j *JetStream) EnsureControllerBuckets() error {
return nil
}
// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to
// ChainValueBytes, on disk.
func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: bucket,
Description: why,
History: ChainHistory,
MaxValueSize: ChainValueBytes,
MaxBytes: bytes,
Storage: jetstream.FileStorage,
LimitMarkerTTL: markers,
}
}
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of