Move a module on a merge only when its build source holds a changed file (hq ADR 0267, issue 363)

Every merge to the controller's repository planned the controller, the
build seat's holder and the route proxy in three gated tiers, whatever it
changed (issue 338). The planner now maps a merge's files onto the build
source each module's newest trunk build said: a README moves nothing, the
controller's command the controller alone, the proxy's program the proxy
alone. A module with none said, or one an open plan has yet to build, is
read whole as before. Sharing a repository draws no packages edge any more,
and one recorded before neither widens nor orders a plan.
This commit is contained in:
jochen
2026-10-10 03:11:11 +02:00
parent abe5f7dc17
commit 8da82dbe38
18 changed files with 543 additions and 140 deletions
+75 -12
View File
@@ -45,6 +45,9 @@ type Build struct {
// Read is every repository this build read source from besides the module's own (novox/hq
// 04-ISSUES/131), at the ref it read.
Read []ReadRepository
// Sources are what the build was made from, as files, per repository (novox/hq ADR 0267): said by the
// builder for a build of a trunk commit; nil where it said none.
Sources []BuildSource
// SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq
// issue 280); empty from a builder that predates it, or where the source does not pin the build.
SourceFingerprint string
@@ -75,9 +78,27 @@ func (b Build) AskedOrAt() time.Time {
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
// ReadRepository is a repository a build read source from besides the module's own.
//
// Paths and Own are never stored in a build's `built_contexts` (a controller that predates them would read
// an own entry as a context, and every module of a repository as packaging every other): ReadRepositories
// lays them over what it reads, from the build's build sources (novox/hq ADR 0267).
type ReadRepository struct {
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
// Paths are the build source the build read in this repository (builder.SourceHolds): a changed file
// outside them is no change to the module. Empty is the whole repository, as before.
Paths []string `json:"paths,omitempty"`
// Own is the module's own repository, whose Paths narrow what its own directory — or, for a module
// built from its repository's root, the whole repository — would otherwise be.
Own bool `json:"own,omitempty"`
}
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267): Repository and Ref
// a context's, empty for the module's own.
type BuildSource struct {
Repository string `json:"repository,omitempty"`
Ref string `json:"ref,omitempty"`
Paths []string `json:"paths"`
}
// Artifact is one thing a build published.
@@ -108,6 +129,14 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
if err != nil {
return err
}
var sources any
if len(b.Sources) > 0 {
raw, err := json.Marshal(b.Sources)
if err != nil {
return err
}
sources = raw
}
var module *string
if b.Module != "" {
module = &b.Module
@@ -118,11 +147,12 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
}
_, err = i.store.Pool().Exec(ctx,
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
source_path, manifest, built_against, built_contexts, asked, source_fingerprint)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
source_path, manifest, built_against, built_contexts, asked, source_fingerprint,
build_sources)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
on conflict (id) do nothing`,
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint)
b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint, sources)
if err != nil {
return err
}
@@ -302,9 +332,14 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
// The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a
// repository a module only packages is a change to that module, and the manifest the mesh keeps
// carries nothing that would say so (novox/hq 04-ISSUES/131).
//
// **With the build source that build said** (novox/hq ADR 0267): a context's entry carries the paths the
// build read there, and the module's own repository an entry marked Own with its paths. A build that said
// none — off the trunk, from a builder that predates it, or of a source not known — leaves its module read
// as before: every file of a context, and its own directory or root.
func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) {
rows, err := i.store.Pool().Query(ctx,
`select distinct on (module) module, built_contexts
`select distinct on (module) module, built_contexts, build_sources
from build
where module is not null and module <> '' and failed = ''
order by module, `+newestRequestFirst)
@@ -316,24 +351,52 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
read := map[string][]ReadRepository{}
for rows.Next() {
var module string
var raw []byte
if err := rows.Scan(&module, &raw); err != nil {
var raw, rawSources []byte
if err := rows.Scan(&module, &raw, &rawSources); err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
var of []ReadRepository
if err := json.Unmarshal(raw, &of); err != nil {
continue
if len(raw) > 0 {
if err := json.Unmarshal(raw, &of); err != nil {
of = nil
}
}
if len(of) > 0 {
var sources []BuildSource
if len(rawSources) > 0 {
if err := json.Unmarshal(rawSources, &sources); err != nil {
sources = nil
}
}
if of = WithBuildSources(of, sources); len(of) > 0 {
read[module] = of
}
}
return read, rows.Err()
}
// WithBuildSources lays a build's build sources over the repositories it read: a context's paths on its
// entry, and the module's own as an entry of its own. A context the build read that its sources do not
// name stays whole; a source naming a context the build did not say it read is dropped, since nothing
// moves a module through a repository it is not recorded as reading.
func WithBuildSources(read []ReadRepository, sources []BuildSource) []ReadRepository {
out := make([]ReadRepository, 0, len(read)+1)
for _, r := range read {
r.Paths, r.Own = nil, false
for _, s := range sources {
if s.Repository != "" && s.Repository == r.Repository && s.Ref == r.Ref && len(s.Paths) > 0 {
r.Paths = append([]string(nil), s.Paths...)
}
}
out = append(out, r)
}
for _, s := range sources {
if s.Repository == "" && len(s.Paths) > 0 {
out = append(out, ReadRepository{Ref: s.Ref, Paths: append([]string(nil), s.Paths...), Own: true})
}
}
return out
}
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
//
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
+45
View File
@@ -2,6 +2,7 @@ package inventory
import (
"context"
"reflect"
"strings"
"testing"
)
@@ -169,3 +170,47 @@ func TestWhatABuildReadComesBackForTheNewestBuildOfEachModule(t *testing.T) {
t.Fatal("a failed build's reading was kept as what that module reads")
}
}
// **What a build said it was made from comes back laid over what it read** (novox/hq ADR 0267): a context's
// paths on its entry, the module's own as an entry marked Own — and never in the stored `built_contexts`,
// where a controller that predates them would read an own entry as a context of its own repository.
func TestABuildsBuildSourceComesBackOverWhatItRead(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
said := aBuild("said", "route-proxy", "")
said.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}, {Repository: "novox/other"}}
said.Sources = []BuildSource{
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
{Repository: "novox/unread", Paths: []string{"x"}},
}
unsaid := aBuild("unsaid", "build-agent", "")
unsaid.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
for _, b := range []Build{said, unsaid} {
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
want := []ReadRepository{
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
{Repository: "novox/other"},
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}, Own: true},
}
if !reflect.DeepEqual(read["route-proxy"], want) {
t.Fatalf("read back %+v\nwanted %+v", read["route-proxy"], want)
}
if got := read["build-agent"]; len(got) != 1 || got[0].Paths != nil || got[0].Own {
t.Fatalf("a build that said no build source reads as %+v", got)
}
var stored string
if err := inv.store.Pool().QueryRow(ctx, `select built_contexts::text from build where id = 'said'`).Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, "paths") || strings.Contains(stored, "own") {
t.Fatalf("the build source was stored among what the build read: %s", stored)
}
}
+13 -25
View File
@@ -13,7 +13,10 @@ import (
const (
// EdgeStandsOn: the module's artifact is built on the other's.
EdgeStandsOn = "stands-on"
// EdgePackages: the module's build reads the other's repository.
// EdgePackages: the module's build read the other's repository. **No longer drawn** (novox/hq ADR 0267
// rule 4): sharing a repository is no dependency, and a changed file moves every module whose build
// source holds it, directly. Kept so an edge recorded or snapshotted before is read, and then neither
// widens nor orders a plan.
EdgePackages = "packages"
// EdgeBuiltBy: the module is built by the holder of the build-machine seat.
EdgeBuiltBy = "built-by"
@@ -42,10 +45,10 @@ type Edge struct {
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
// overview all read this.
//
// Four sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
// against (an `artifact-store://<module>/…` reference is an edge to that module); the repositories
// the latest build read (an edge to the module whose source that is); and the build machine, which
// every source-built module is built by.
// Three sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
// against (an `artifact-store://<module>/…` reference is an edge to that module); and the build machine,
// which every source-built module is built by. The repositories a build read are no edge (novox/hq ADR
// 0267 rule 4).
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
entries, err := i.Catalogued(ctx)
if err != nil {
@@ -55,24 +58,19 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
if err != nil {
return nil, err
}
read, err := i.ReadRepositories(ctx)
if err != nil {
return nil, err
}
return dependenciesOf(entries, against, read), nil
return dependenciesOf(entries, against, nil), nil
}
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
func dependenciesOf(entries []Entry, against map[string][]string, read map[string][]ReadRepository) []Edge {
//
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
// the planner, never through the relation.
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
known := map[string]bool{}
byRepository := map[string][]string{}
var builders []string
for _, e := range entries {
name := e.Manifest.Module
known[name] = true
if r := repositoryKey(e.Source.Repository); r != "" {
byRepository[r] = append(byRepository[r], name)
}
if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") {
builders = append(builders, name)
}
@@ -125,11 +123,6 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
}
}
}
for _, r := range read[name] {
for _, other := range byRepository[repositoryKey(r.Repository)] {
add(name, other, EdgePackages)
}
}
if e.Source.Repository != "" {
for _, b := range builders {
add(name, b, EdgeBuiltBy)
@@ -154,8 +147,3 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin
})
return out
}
// repositoryKey is a repository as compared: lower-cased, without a trailing `.git`.
func repositoryKey(repository string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
}
+4 -1
View File
@@ -44,7 +44,6 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
{"shop", "mesh-tools", EdgeStandsOn},
{"builder", "mesh-tools", EdgeStandsOn},
{"shop-plugin", "shop", EdgeDeclared},
{"route-proxy", "mesh-controller", EdgePackages},
{"shop", "builder", EdgeBuiltBy},
{"mesh-controller", "builder", EdgeBuiltBy},
{"mesh-tools", "builder", EdgeBuiltBy},
@@ -53,6 +52,10 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
t.Errorf("missing %+v in %+v", want, got)
}
}
// Sharing a repository is no dependency (novox/hq ADR 0267 rule 4): what a build read draws no edge.
if has("route-proxy", "mesh-controller", EdgePackages) {
t.Error("a packages edge was drawn from what a build read")
}
if has("builder", "builder", EdgeBuiltBy) {
t.Error("the builder is not built by itself")
}
@@ -0,0 +1,9 @@
-- A build says what it was made from, as files (novox/hq ADR 0267, issue 363).
--
-- A merge to a repository moved every module whose build read it, whatever the files: the controller,
-- built from its repository's root, and the two images whose context is that repository (issue 338).
-- A build of a trunk commit now says its build source per repository — a Go program's import closure,
-- an archive's directory, an image's recipe — and the planner maps the next merge's changed files onto
-- the build source of the module's newest build. Null for a build that said none (one off the trunk,
-- one from a builder that predates this, one whose source is not known): its module is read as before.
alter table build add column build_sources jsonb;