Compare commits

...
149 Commits
Author SHA1 Message Date
jschoubben 83b962cc6d Pin mesh-sdk 05b3e2e1b94f, mesh-contracts ae5266e77639 and mesh-catalog's buildstep 125929dbc459, the group's final heads (issue 1966)
mesh/delivery ready: it is merged on the Approve
mesh/check failed (13): ace: novox/mesh-catalog#612: modules/minio/object-store: a compiled program added …; and 12 more | 83b962cc6d6d on ae31b66a9e33
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (3a038b9f8b4d): ace
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:47:39 +01:00
jschoubben b3b6456ab8 Pin mesh-sdk e5d89c5eedb5, mesh-contracts 347a3053580a and mesh-catalog's buildstep fdc3331465dc, the group's heads after review round 2 (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:26:33 +01:00
jschoubben 4ba4cacc66 G-delivery 7: the indexer's and the assigner's views emitters write view-unsaid
mesh-sdk's views.Emitter writes the owner's finding of a change not said past its bound only when Findings is
set; both owners of views in mesh-delivery now set it to their findings state.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:20:53 +01:00
jschoubben 24f9b6f602 G-delivery 6: gofmt
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:18:39 +01:00
jschoubben 3a3611aa41 G-delivery 6: the builder ends a remove on kept and records the version kept
A seat that cannot remove a tag alone (another tag names its digest) now answers and says kept. The builder ends
that remove there, with no retry, and marks the version kept on its store with the reason and the other tags;
builder_versions shows it, and a later branch end asks it no more. The four seats' kept are consumed.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:18:33 +01:00
jschoubben d0983a8b97 G-delivery 5: the judge refuses a read-only state no process names; grants read it from the manifest
mesh-sdk's state read-only (d420491c) is mapped into the grants' buckets, so the module's account and the tool
runner read it and never write it; the judge refuses a read-only state no process's bus state names, since
nothing would write it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:16:38 +01:00
jschoubben dafb8e1ce6 G-delivery 5: gofmt
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:48 +01:00
jschoubben 276886d8f6 G-delivery 5 (grants half): a read-only state is written by the process naming it alone
The bus module's spend record must not be writable by the tool runner. A state declared read-only gets read on
the module's own account and the tool runner carrying it (direct get, a watch's consumer) and no publish on its
$KV subjects; the process whose bus state names it writes it. The judge rule and the manifest field follow
with mesh-sdk's read-only.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:41 +01:00
jschoubben 441b6d2922 G-delivery 3, 4: family-not-heard says it clears at the part's next start; views.Bound is two minutes
Bind runs once at a part's start, so the finding's words no longer promise it clears when the grants part makes
the consumer. The assigner's comment on firstViewBound named five seconds for views.Bound, which is two minutes.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:10:51 +01:00
jschoubben 121c6d9c48 G-delivery 2: sdk/heard's cases are rows of the watchman's findings table
mesh-delivery is over its unit budget, so the new function goes; its three cases run as rows of an existing
table of a user of the package.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:10:16 +01:00
jschoubben e5011e1f83 G-delivery 1: the builder's bounds not watched, or a bound not written, is a finding
A watch of bounds that cannot start lost every bound's end with a log line only: it is now family-not-heard
naming the state. A publish bound that cannot be written would never end: the version is publish-unanswered
at once, saying why.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:09:48 +01:00
jschoubben 4be1d7bae1 Pin mesh-sdk c54648e74346, mesh-contracts ec9529022c44 and mesh-catalog's buildstep ccb7eaff1382, the group's heads (issue 1966)
The placeholder versions of the contracts' seats and clients are gone; the repository's own sdk and buildrun keep their
relative replaces.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:44:36 +01:00
jschoubben 3e96e977bc mesh-grants: the bus module's keeper reads its warrant on the account composed from nats' manifest
Review round 1, running F4 (design 25 § 12.7), moved here from the nats test by the lead: in the composed-grants case
of the whole test, the part composes nats and messenger from their manifests (testdata copies of the catalogue's at
the join); the keeper connects on its own process account to a server loading the composed list, reads the router's
record of its ask, and is refused a write to the router's asks and the part's state and another asker's record.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:28:21 +01:00
jschoubben bf8f5c5c25 mesh-grants: the bus module's process consumer hears the seat events said to it by name
Its durable consumer filtered only its consumes, composed as module subjects, so the operator channel's
decided for its own asks could not be heard there (design 25 § 12.3: it wakes on the warrant).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:11:53 +01:00
jschoubben 4f7ceb61ea mesh-grants: a first view's condition the keeper did not take is said again at the next pass
The raise and clear of first-view-unanswered dropped a failed publish (_ = say). The binding comment
named the bus module's own stream and 30 days; the part hears the seat's events on the shared stream, 7 days.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:08:23 +01:00
jschoubben 4dbb6b2aec F-delivery: builder_bounds reads the builder's state bounds
Every state a module keeps has a read tool (the lead's ruling). Each key of bounds now holds what it bounds
and when it ends, and builder_bounds lists them with the time left.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:46:32 +01:00
jschoubben f9adc9d44c F-delivery 8: two claims never one number, on the bus, in the builder's whole-module test
The case ran only on the in-memory store. Eight claims of one name at once on the builder's state versions,
through its own bus adapter, get eight versions; with Create turned into Put they share one.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:43:06 +01:00
jschoubben 71b9737fc7 F-delivery 6: old-form-held is raised at a node's seventh day by an expiring key
The finding was written only at the window's next change. Each node holding the window has a key of the
builder's state bounds whose time to live ends at its seven days; the bus's marker of its end steps the
window, which raises the finding then; a node that no longer holds it loses its key.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:41:35 +01:00
jschoubben 00903e9149 F-delivery 7: the builder's old-form window steps by mesh-sdk's delivery.StepWindow
A read naming no node (an empty node_haves, the placement not read yet) closed the window for good (security
review F3, second of a kind). The window embeds delivery.WindowHeld and steps by the shared rule, named being
the placement's nodes before any is dropped. The builder's expiring keys share one state, bounds (the publish
bound of F-delivery 5 renamed), for the old-form bound next.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:40:44 +01:00
jschoubben 78fc3ffa35 F-delivery 5: the builder raises publish-refused, publish-unanswered and seat-outcome-unread
A holder's refused, and a version no holder answered, raised no finding outside the floor, so a build said done
while its file was not in the registry; a seat's outcome that does not read was only logged (design 51 Health
and conditions). The bound is a key of the new state publish-bounds with its own time to live, armed when a
built version is still numbered; a later published of the name clears both findings.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:38:17 +01:00
jschoubben 0d39254ebf F-delivery 4: gofmt sdk/heard
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:34:06 +01:00
jschoubben d4598f5772 F-delivery 4: a consumer that does not hear its family is a finding until it binds
The indexer's, the assigner's, the builder's and the watchman's consumers that were not there or could not be
heard only said so in the log, so the part stayed healthy while it heard nothing (ADR 0615 §5). sdk/heard is
the one shared helper: the finding family-not-heard names the observer and the family, written when the
consumer does not bind and deleted when it does.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:34:02 +01:00
jschoubben 395a1a0f40 F-delivery 3: the watchman waits for created only for entries not yet said created
The bus module says created once per entry, so every later accepted manifest of the module raised a false
bus-state-overdue five minutes on. The watchman keeps a mark per entry said created, on its pulse state with no
time to live and read back at a restart; a refused or an entry no longer asked drops it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:29:21 +01:00
jschoubben 2a32721b80 F-delivery 2: the assigner takes the module graph by the indexer's first view (ADR 0631)
The assigner read the indexer's graph state itself, the one reader left after ADR 0615 §1. The indexer serves
graph as a seventh first-view tool to the assigner alone; the assigner asks it, follows views.graph, raises the
first-view condition past the bound while the indexer is down, and its reads of graph goes.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:27:07 +01:00
jschoubben 9674710503 F-delivery 1: the checker judges names against an answered empty packages state; judgeOver judges one digest
An answered packages state with no name handed the judge nil, which judges no name, so a manifest naming an
unknown package passed (DLV-I14, design 32). PackageNames is nil only until the state's first reading ends.
judgeOver takes one module and digest, so dropping the known packages for a digest the trunk does not hold
cannot drop them for another (J7's note).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:22:59 +01:00
jschoubben 0e44467b70 J8b: sdk/forms reads the seat form through catalogue, upstream naming included
catalogue.InRegistrySeat now reads the holder's upstream naming and InArtifactStore derives its store path, so forms'
own reader goes, and the upstream rows run the encode check again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:58:59 +01:00
jschoubben bf92a37730 J8: sdk/forms names an image and an upstream copy in the seat reference form, each deriving its artifact-store form
ADR 0624: container-image-registry/<module>/<artifact>@<digest> and the holder's container-image-registry/upstream/<host>/<path>@<digest> are form:seat-reference; Older derives artifact-store://... so a key keeps both. The registry form now precedes the artifact-store form, so a node stating nothing keeps the recorded store form of an image.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:55:45 +01:00
jschoubben c47f0af0ab J7 (3): the indexer's verdict on a pull request's candidate does not refuse on a package's name
The checker, which knows the delivery group, judges a change's names; the indexer's state knows only the trunk's packages
and refused a package new in the group. The trunk's manifests are still judged on them, and a merge's manifest in full,
though its candidate was accepted without them (UnknownPackageNames).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:44:36 +01:00
jschoubben 2bb0ccd3c6 J7 (2): the checker hands the judge the known packages in composer.Request.Packages, not in Input
Input is the composition's input; the judge's names now have their own field (mesh-sdk), so packagename.Input and the
judge's inputOf are gone and mesh-judge reads Packages.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:42:03 +01:00
jschoubben e91a3923d0 J7 (1): the assigner, the teller and the view read a kept ask through the operator-channel client
Their three copies of the id and option checks (askjson.go checkedAsk, the teller's routerAskOf, the view's routerAsk)
are deleted; each calls occ.Carried with the id the key and the asking give.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:39:47 +01:00
jschoubben a09f5bde38 Join: gofmt sdk/enginereads (L3 left it unformatted)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:35:30 +01:00
jschoubben fa27137036 Join: a look at a done build key logs no "kept" narration
After the join the builder looks at a done key again whenever a first view is answered late or a package's name is
heard (lead's i), and each look's "version kept" narration was written to the build log after the build's end, with no
start or end around it. A look at a done key builds nothing: a kept version says nothing new there and stays in the
process log only. TestBuilderMakesADoneKeyWithTheDigests failed at about one run in three (kept narrations of
tools-go and tools-typescript after the end); 8 of 8 pass.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:35:22 +01:00
jschoubben 71f4e8d128 Join (lead's i): the builder follows the indexer's packages and numbers a package once its name is known
The indexer's packages state is read through its read tool's first view and its views family (ADR 0615 §1), as the
other four states the builder follows, not by its bucket: the manifest drops the read, invokes indexer_read_packages and
consumes views.packages.

A package built unnumbered because its name was not known at its claim was numbered only at its module's next claim.
Now a series key heard naming a name looks at its module again (Builder.PackageNamed), and a look at a done trunk build
numbers what it left unnumbered with the done key's digests (BuildClaim.Done), so version.numbered carries the digest
for the build module's late publish. Woken by the views family, never a timer.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:32:57 +01:00
jschoubben 5c4016c960 Join item 5: the judge's packages rule takes the known packages by their ecosystem names
Design 32 names a package in a manifest's packages by its name in its own
language, so the judge (DLV-I14) now refuses a name no known package carries
instead of any name that is not a declared <module>/<artifact>. The indexer
hands it its packages state's names, kept before the merge's judge pass; the
checker hands it its followed packages picture plus, for a package new in the
group, the name read at the member's head. Both read a name with one reader,
sdk/packagename.Of, and the judge request carries the names in its Input
(packagename.Input). The series-form check stays.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:30:11 +01:00
jschoubben 12f5e24e7f Join item 7: a key's history through the owners' read tools; the verdict and its event name each series change
History (A2j finding 1, J4): the indexer's and the assigner's read tools are views.ReadTool over views.KeptSource, which
answers `{key, history: true}` now; modules (20), heard (64) and wants (20) keep more than one revision. Rows in the
view's "each tool answers through the owners' read tools as before": indexer_module_history and indexer_heard answer
the owner's kept history, and a want's history (view_tiers' Since) is read through assigner_read_wants (the row it
replaces held that the owner answered no history).
Series (A2j finding 2, J4): the checker keeps each series change as a delivery.SeriesChange {module, artifact, from,
to} and renders the status line from it; delivery.Verdict.Series holds the case's changes (each module's artifact once),
and the verdict event's series the pull request's (meshdeliverychecker.VerdictHolderEventSeriesItem). An artifact new
on the head is no series change (the lead's ruling). Rows: TestTheIndexersJudgeJudgesTheChangesManifests (the verdict's
series; failed first, empty) and TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure (the event's; failed
with the fill cut).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:17:50 +01:00
jschoubben c8eccf299a Join (lead's iii): a series key's value is mesh-sdk's delivery.SeriesPackage; its readers decode it by the contract
The indexer's SeriesPackage and SeriesVersion are aliases of delivery.SeriesPackage and SeriesVersion (J4), SeriesKey and
IsSeriesKey are delivery.SeriesPackageKey and IsSeriesPackageKey; the indexer's readers (the series read for a user's
fingerprint, indexer_package_users) and the builder's NamesIn decode with delivery.DecodeSeriesPackage (the builder no
longer reads the indexer's value by field names). Found by the contract: a published newest's Contents named no time,
which DecodeSeriesPackage refuses, so the accepted series was left out and its users kept the commit form; the
indexer now stamps the time the contents were read (TestImportsReportedMoveTheFingerprintOnlyWithAnImportedPackage,
"an accepted series' newest ... pins it", failed with the decode before the stamp).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:14:41 +01:00
jschoubben 7ae4f2bb7f Join item 6: the builder says version.numbered as delivery.VersionNumbered and reads out-of-step by BuildAnswer's field
versions.NumberedBody and NumberedEvent are mesh-sdk's delivery.VersionNumbered and VersionNumberedEvent now (J3), the
contract a build module consumes for BG2's late publish; the body's JSON is unchanged. The lane reads a step's
out-of-step files through delivery.BuildAnswer.OutOfStep, a failed step's result too (as before, A2k's d15d66fc: read
before the step's state; its three rows hold it), in place of a local decode by name. The integration test hears
version.numbered on delivery.VersionNumberedSubject and reads it with DecodeVersionNumbered, as a build module does.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:12:13 +01:00
jschoubben 91b24a5426 Join item 8: versions.WaitsOn dropped (A3i ruling: dead code)
One modules value per module means a trunk claim has no earlier merge of the same name to wait on, so nothing called
it; its subtest "merge order" goes with it. Design 51's merge-order line holds by that (a review check).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:10:58 +01:00
jschoubben 952dedd0ed Join item 2: the readers follow through mesh-sdk's views.ProcessReader; sdk/firstview is gone
mesh-delivery's sdk/firstview moved whole into mesh-sdk go/views (J3): the builder, checker, watchman, teller and view
follow each owner's state with views.ProcessReader.Follow(owner, state, tool, stream), naming the owner's read tool as
their invokes name it and its views family as the owner's manifest's `streams` names it ("indexer-views",
"assigner-views"); a refused Follow stops the start. Their beds serve the owners with viewstest.Serve and grant with
viewstest.Grants by the same names. The view's tools ask the owners' read tools by their literal names. The assigner
names its own views family and the indexer's itself (views.AssignerStream and IndexerStream are gone).
Every views.StatusTools call takes handacts.Recorder{Ask: stdio.Ask}: a first view asked again by hand is a hand act,
recorded through seat:mesh-hand-acts.hand-act, which the builder's, checker's, watchman's, teller's and view's
manifests now invoke (a row in each manifest test, each failed first).
Row (A2j's written-time switch): TestRunNeedsItsCredential "a want stood since the assigner's bus wrote it": the
watchman's want time (createdOr) is the time the owner's bus wrote the key, through ProcessReader's first view (failed
with createdOr ignoring the entry's time). sdk/go.mod: nats.go is indirect now.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:10:36 +01:00
jschoubben 2fe8d01e86 Join item 1: the rewrite map, asks/asker, the vault's types and compose-candidate to mesh-contracts
mesh-sdk holds no go/asks, go/asker, vault answers or compose-candidate types (A1d, J1): each module now names the
operator-channel contract and client, the channel benches (mesh-sdk/go/channel), mesh-vault's contract, and node-engine's
contract and client (a1-progress.md, the A1d and J1 rewrite maps).
- assigner, teller, view: delivery.Ask.Ask and AssignerAsk.Ask are the router's ask as JSON; the refusals mesh-sdk
  dropped (an id other than AskingID(key, asking), no option) are each reader's and writer's: assign.EncodeAsk/DecodeAsk
  (askjson.go), the teller's routerAskOf (teller.go) and the view's routerAsk (view.go); the teller refuses an option
  the asking does not offer and one whose level is not acknowledge (viewanswers.go), the two refusals ViewAnswerFits
  gave. Row: subtest of TestTheHoldAskIsValid (another id, no option, no ask; each written before).
- enrolment, grants: the vault's verbs and answers from meshvault; asks' conditions from the contract and client.
- checker, assigner's judge: compose-candidate from nodeengine/nodeengineclient (nec.Own for "node").
- mesh-grants' user-lists golden: the node-engine's users now subscribe to mesh-delivery-view's settled (mesh-sdk's
  NodeAgentHears, A7's ENG-8 retry).
go.mod: each module requires the mesh-contracts modules it imports (and nodeengineclient, which mesh-sdk imports) at the
zero version, for the group pin.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:05:12 +01:00
jschoubben 5385d6844d Join item 4: the indexer serves indexer_read_seat_definitions, the checker's seventh first view
The checker invokes it (A2j 5700edf) and the indexer said the state on its views family without a read tool for it.
Row in TestManifestDeclarations: every indexer tool a module of this repository invokes is one the indexer serves
(failed first, naming the checker's invoke).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:04:34 +01:00
jschoubben e648955a94 Join item 3: the indexer's empty manifest-accepted takes its id from the event (EventID names module and revision)
delivery.ManifestAccepted.EventID now names, for an event with no digest, the module and the event's revision (J3,
ruling A5(d)), so the indexer drops its own left id. The revision is the modules state's revision of the value that left
(write hands the revision it read), so two leaves of one module are two events; asked again by hand for a module no
longer held it is 0, and the again suffix keeps it unique. Row in TestAMergeThatRemovesAModuleDeletesItsKey (failed
first: Revision 0, id naming the last digest).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:03:35 +01:00
jschoubben 5c4486d512 Join item 2, indexer: its views family and the assigner's by their manifests' names; its ask-again records a hand act
views.IndexerStream and views.AssignerStream are gone from mesh-sdk (J3 lead's (a)): the indexer names its own views
family "indexer-views" and the assigner's "assigner-views", as each manifest's `streams` names them. Its first-view
ask-again records the hand act through seat:mesh-hand-acts.hand-act (views.StatusTools takes a handacts.Recorder;
the manifest already invokes the verb). judge_test.go gofmt'd (c6c6687 left it unformatted).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:01:42 +01:00
jschoubben 1cd0d83f41 Join: merge A3's builder and L3 (wip/registry-a3 88a9d19)
L3 (710c4032) arrives with it. Conflicts, each kept from both sides:
- assigner module.json, manifest_test.go, main.go, tools.go, integration_test.go: A2's first-view tools,
  read tools and states beside L3's assigner_engine_reads and engine-reads; the two "consumes" lists
  joined into one (the indexer's views and node-engine.manifest-reads-changed).
- builder module.json and manifest_test.go: A3's tools, states, consumes and invokes beside A2g's first
  views (DLV-M1); one findings state (A3's, history 5) carries both; reads keep A3o's
  mesh-delivery-indexer.packages until the builder follows it (join item).
- builder cmd main.go: rebuilt from A3's with A2g's changes applied (the reader follows modules,
  manifests, candidates and placement; one calls connection; one findings bucket, A3's
  health.FindingsBucket; FollowPlacement on a goroutine once placement's first view is answered;
  buildFromModules in place of the start's settle). Tools main.go: A3's tools then the first-view tools.
- builder run.go: A2k's out-of-step on the end entry, then A3's numbering of a done build.
- view module.json and manifest_test.go: node_manifest_reads and seat:node-engine.manifest-reads beside
  A2j's first views and read tools; one invokes list.
- indexer judge rules_test.go: L3's manifest-reads verb and event, without pull-waits (J1 dropped it).
- sdk/go.mod and builder go.mod: both requires kept.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:58:45 +01:00
jschoubben 84a5094684 Join: merge A5's grants part, keeper and watchman consumers (wip/registry-a5 d53986f)
Conflict: modules/mesh-delivery-watchman/manifest_test.go, both sides asserting m.Consumes,
and both adding a Consumes field to the test's manifest struct (kept once).
Git also auto-merged module.json into two "consumes" keys (A2g's first views and A5's
manifest-accepted and mesh-broker events): joined into one list, and the test asserts all five.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:51:50 +01:00
jschoubben 88a9d19b4f DLV-B2: a package is numbered under its name in its ecosystem, read from the indexer's packages state
The builder numbers, asks the floor of, records and tags a package under the name the indexer's series key names for
its <module>/<artifact> (a Go module path, an npm package name; design 32), so builder_versions answers the holder's
question by the name a registry seat publishes it under. A package the state names nothing for, or a state not readable,
is built unnumbered and said; the rest of the claim is numbered.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:51:27 +01:00
jschoubben d266a4c839 Join: merge A2g's checker, view, watchman, builder, teller and enrolment readers (wip/registry-a2g aafd90b)
No conflicts.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:48:00 +01:00
jschoubben 3caa8291b0 DLV-B9: a bundle answered in the registry seat's form keeps the artifact-store form it derives for each counted engine that does not read it
The builder's forms gain seat-reference for bundles and archives (oci-artifact-registry/<module>/<artifact>@<digest>);
the old-form window writes the older artifact-store reference beside it (catalogue.InArtifactStore), the oldest kept
in the single reference (ADR 0624 §1, §3), so mesh-build-go's seat-form answer no longer strands an engine stating nothing.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:45:00 +01:00
jschoubben 8d7e8d3cb9 DLV-I6 ruling: a package is named in its own ecosystem, and its series key with it
At each trunk merge the indexer reads every package artifact's name from the merge commit (a Go package's go.mod module
path, a TypeScript package's package.json name) and writes it into the series key as name, beside the module and artifact
it is built from, so the builder numbers it under that name before its first version. Series keys, accepted series and
indexer_package_users go by the name (the lead's design limit for design 32, replacing the builder-name fallback).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:44:38 +01:00
jschoubben aafd90b8cf DLV-M5r1: the watchman's want time is the picture's written time once the join's views name it
The lead's ruling on A2h's finding 1: J3 builds each first-view key's written time into mesh-sdk views
(Answer.Written, a Picture entry's Created) and J2 the tool runner's state reads' at. The watchman and
the builder's toolchain gate already read the entry's Created(), so neither changes; this says so where
the fallback stands.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:42:26 +01:00
jschoubben 5700edf955 DLV-M2r1: the checker fails a pull request whose head's seat definition does not parse, naming the seat and where
The lead's ruling on A2i's finding 1 (design 26): the indexer keeps an unparsed definition of a pull
request's head as its entry of the state seat-definitions with `unparsed`, and cannot refuse the manifest.
The checker follows that state as its seventh first view (invokes indexer_read_seat_definitions, consumes
views.seat-definitions; checks start once it is answered too, as M2's six) and fails each pull request
with an entry holding `unparsed`, naming the seat, its module, its directory and the indexer's words; a
state that cannot be read fails it too. Rows: TestTheIndexersJudgeJudgesTheChangesManifests (fails
first without the refusal), the adapter's key match in TestThePublishedVersionsAreTheIndexersPackagesState
(fails first matching every key), the manifest's invokes and consumes; the integration bed serves the
state (without it every check waits and five tests fail).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:40:02 +01:00
jschoubben f7a6118e61 DLV-I19, I20 [i]: the indexer's whole-module test keeps a verdict's and a trunk build's out-of-step files and reads a pull request again by hand
Cases in TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage: the checker's verdict event keeps a pull request's
files, a trunk build end raises generated-types-out-of-step and a clean one clears it, as indexer_out_of_step reads them;
indexer_seat_definitions_again, asked through takeins, reads a pull request's head and indexer_seat_definition answers it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:39:32 +01:00
jschoubben d15d66fc47 DLV-I20 (builder): a build's end names the generated files its build module found out of step
The builder reads out-of-step from each lane's step result, a failed step's too, and writes it on the build's end
entry (BuildLogEntry.OutOfStep, J3's mesh-sdk field), where the indexer raises generated-types-out-of-step for a trunk
(the lead's ruling on A2i's finding 2). BuildAnswer names no such field yet, so it is read by name and shape.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:35:59 +01:00
jschoubben 20269818c9 DLV-C5: gofmt of the checker's examiner test
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:32:46 +01:00
jschoubben 30ee47caa6 DLV-C5: the checker asks the seat mesh-config's verb renames for every change that brings or drops a renamed-from
Design 32 (A config value renamed): the checker invokes seat:mesh-config.renames, never the module. A
change bringing renamed-from to a holder that serves no renames (nothing answers the optional verb) is
refused naming it; dropping a renamed-from while its window is open is refused naming the nodes holding it
open; a check that cannot ask fails, saying so. The pull request's trunk and head manifests are read once
for this and for the series changes (manifestPair). Rows in TestTheIndexersJudgeJudgesTheChangesManifests
with the verb faked from the seat's definition (its fixture's answer): not served, served, open on g14,
closed, an ask that fails, no seat to ask; the four refusals failed first. Manifest row for the invokes.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:32:41 +01:00
jschoubben 1d31ef1e85 DLV-I17, I19 rulings: a seat has one trunk definition, and a pull request is read again with its head's manifest
A second repository's trunk defining a seat another trunk holds is refused and kept nowhere, with the finding
seat-defined-twice naming both repositories; indexer_seat_definitions_again for a pull request reads the head's own
generated-from, so a pull request changing that field is read right by hand too (the lead's rulings on A2i 3 and 4).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:31:39 +01:00
jschoubben f9e0e6544c DLV-F5r: the assigner answers its readers' first views before any new-form state, with no binding of the bus module
The case in the assigner's whole-module test serves the tools bundle's first-view tools as the runtime of its own does,
with the callers its manifest writes, while the assigner still waits on the indexer: the grants part, the tool runner and
the indexer each get theirs, every other account (the assigner's own included) is refused (ADR 0621 §1-§3).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:29:10 +01:00
jschoubben 890b8f36e9 DLV-C4: the checker names each change of a package series a pull request makes in its status line
Design 32 (A package, its series): the review of a change sees every series it raises. For each module a
pull request touches, every artifact its trunk manifest and its head's manifest both declare whose series
differs is named as "<module>/<artifact> series <trunk> → <head>" in the pull request's mesh/check line,
never dropped when the line is cut; a manifest that cannot be read is said in the same place. Rows in
TestTheIndexersJudgeJudgesTheChangesManifests (raised: named; kept: not named); the raised row failed
first. gofmt of the integration test's imports (M2).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:28:18 +01:00
jschoubben 8afd34525d DLV-M4, DLV-V1: the view reads the indexer's and the assigner's twelve states through their read tools, never their buckets
ADR 0615 §1, design 25 § 12.2: the manifest drops its twelve reads of the two owners' states and invokes
their twelve read tools. The process follows the six it watches (placement, wants, asks, candidates,
modules, taken) by the first view and then the views families (consumes), through firstview pictures, so
its watch code is unchanged; an owner that is down holds no other watch, and the unanswered first view is
the view's finding (states first-views and findings; tools view_first_views, view_first_view_again).
The tools keep their names, arguments and answers and ask the owners' read tools on each call
(view.OwnerRead; a read of many keys is one paged values read). DLV-V1: a table subtest answers each
tool through the owner's own Read over a fake state and matches what reading the state answered.

The owners' read tools answer no history of a key: indexer_module_history, indexer_heard and view_tiers'
times say so (an error, never none) until the read tool takes a history argument (a request to the join).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:24:17 +01:00
jschoubben 24dd2ec29a DLV-F5r (assigner's case): the indexer's runtime answering after the assigner asked is followed within the bound
A case of the assigner's existing whole-module test: the indexer's first views are held until the
assigner has started, and the wants are written from what they answer (ADR 0621 §3).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:22:31 +01:00
jschoubben b84db41198 DLV-F5r (indexer's case): the assigner's runtime answering after the indexer asked is followed within the bound
A case of the indexer's existing whole-module test: the assigner's first view is held until the
indexer has started, and only the placed node's toolchain counts once it answers (ADR 0621 §3).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:21:13 +01:00
jschoubben 598204b4c4 DLV-I17, I18: the indexer's whole-module test keeps a contracts module's definitions, a pull request's unparsed one, and drops it on close
Research 081 test 21 as a case of the indexer's existing whole-module test (no new test function:
mesh-delivery is over its test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:20:01 +01:00
jschoubben aa647d0eda DLV-I17, I18, I19, I20: the indexer keeps every contracts module's seat definitions on the bus
Design 26 and ADR 0608 §2-§4: the definitions are read at each trunk merge and pull request head
through the forge's get-file tool, parsed by mesh-sdk seatdef and kept in the state seat-definitions
by seat and source; seat-definition-changed is said, kept seven days, only when a digest moved. A
trunk definition that does not parse leaves the trunk's entry and writes seat-definition-unparsed;
a pull request's entries go when it closes or merges. A trunk build end naming out-of-step files
writes generated-types-out-of-step, cleared only by a clean build of the same module; the checker's
verdict keeps a pull request's. Tools indexer_seat_definition, indexer_seat_definitions_again (a hand
act) and indexer_out_of_step.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:18:17 +01:00
jschoubben c6c66873f4 DLV-M6r5: the judge takes a claimed seat's module with tools of its own beside its seat's verbs
The lead's ruling 5: one row in the judge's table, the enrolment's shape (a claim serving the seat's
verbs and the first-view tools in its own tools list); the judge accepts it as it stands.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:14:37 +01:00
jschoubben a6fdb121fe DLV-M2: the checker follows the indexer's candidates, modules, manifests, packages and predefined keys and the assigner's placement by first views
ADR 0615 §1 and ADR 0596: the checker invokes the six read tools, consumes their views families and
reads none of the six buckets; this ends its direct read of the packages state (the lead's ruling 2).
It keeps first-views and findings and announces both as its own. The lead's ruling: an owner that is
down never holds the start; the checks, the tries and the hand asks start once every first view is
answered, the placement is followed into the toolchain gate once its first view is. firstview.Reader
opens a followed state's picture as a process.BucketOpener (the builder uses it too). Tools
checker_first_views and checker_first_view_again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:13:28 +01:00
jschoubben 28dd86b451 DLV-M1: the builder follows the indexer's modules, manifests and candidates and the assigner's placement by their read tools' first views
ADR 0615 §1: the builder invokes indexer_read_modules, _manifests, _candidates and
assigner_read_placement, consumes their views families and reads none of the four buckets; it keeps
first-views and findings. The lead's ruling: an owner that is down never holds the start; the modules
are built once the modules', manifests' and candidates' first views are answered, the placement is
followed into the toolchain gate once its first view is, and until then the gate passes every report on
as before the placement settled. Tools builder_first_views and builder_first_view_again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:09:22 +01:00
jschoubben 72d8e2155f DLV-M5: the watchman follows the assigner's wants and the indexer's modules by their read tools' first views, read once answered
ADR 0615 §1: the watchman invokes assigner_read_wants and indexer_read_modules, consumes their views
families and never reads either bucket; it keeps first-views and writes an unanswered first view as a
finding of its own. The lead's ruling: an owner that is down never holds the start; each state is read
on a goroutine once its first view is answered, and what it holds (seeded pulses, stuck keys, the
findings it is the source of) is reconciled then (watch.Awaits, ReadLate), never dropped at the start's
end. Tools watchman_first_views and watchman_first_view_again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:06:04 +01:00
jschoubben ea6fbd31db DLV-I16: the judge refuses a contracts module claiming a seat, naming the seat
A module whose package carries generated-from holds contracts only (design 26).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:06:03 +01:00
jschoubben 2e5917535c DLV-I15: the judge refuses a change's renamed-from that the trunk's manifest does not allow
A rename moves a module's own value with its trust (design 32): the old name is a value the trunk's manifest of the
same module declares and the change does not, no secret and no key of the predefined list, and the old and new value
have the same trust, declared or by the predefined key they take. Judged for a change over the trunk; each refusal
names the module and the key.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:05:09 +01:00
jschoubben c22f9d25dc DLV-I14: the judge refuses a packages entry naming no declared package or an ill-formed series
Between the manifests (design 32): each name in a module's packages is a package some manifest declares, by the
builder's name <module>/<artifact>, and each accepted series is <major>.<minor>.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:02:57 +01:00
jschoubben 20a9322209 DLV-I13: the judge reads a package series, a package's language and the contracts marker
Design 32's words: a series is <major>.<minor> and only on the four kinds the builder numbers, a package is go or
typescript, generated-from only on a package. A package is published as its source, so it names no system: today's
judge refused mesh-sdk's Go package (no system) as a binary.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:01:38 +01:00
jschoubben 87ba69ed69 DLV-I12: the judge refuses a ledger type whose operations or indexes name what its schema lacks
Left by the bus-state review (ADR 0595 §8): a set-entry or remove names its into and key, an operation's fields and an
index's on name schema fields, and a digest-of names a text field; each refusal names module, entry, type, operation
or index, and the field.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:59:37 +01:00
jschoubben 7372447c68 DLV-I9, I9b, I9c: manifest-accepted for every accepted manifest and a module leaving the trunk, never lost silently
The bus module's orphans need to hear a module that asks nothing now (design 25 § 12.2): every newly accepted manifest
is said, its entries an empty list when it asks for none, and a module whose directory leaves the trunk gets an empty
one; a manifest set aside to wait gets none. A failed publish is published once more; one failing twice writes the
finding accepted-unsaid naming the module, said again at the next judging pass and cleared once said.
indexer_say_accepted says it again by hand from the held verdict, a hand act through mesh-hand-acts; it refuses a
module with no accepted verdict.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:58:02 +01:00
jschoubben 1ce394db97 DLV-M6: the enrolment follows the assigner's placement by its read tool's first view and its views family, never its state
ADR 0615 §1: views.Reader asks mesh-delivery-assigner.assigner_read_placement and hears views.placement;
the holder reads placement from the picture and every other state through the runtime. The manifest drops
the read, invokes the tool, consumes the event, keeps findings and serves enrolment_first_views and
enrolment_first_view_again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:56:31 +01:00
jschoubben 28c3b27fec DLV-M3: the teller reads the assigner's asks by its read tool's first view and its views family, never its bucket
ADR 0615 §1: sdk/firstview is a process's picture of an owner's state, read as the bucket it
replaces (Get, Keys, Watch; every write refused), asked through <owner>.<short>_read_<state> with
values and kept from the owner's views family; firstviewtest serves an owner in a whole-module test.
The teller declares invokes assigner_read_asks, consumes views.asks, states first-views and findings,
and the tools teller_first_views and teller_first_view_again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:54:50 +01:00
jschoubben 51829a6b14 DLV-I8: indexer_package_users lists each package's users and the series each accepts
So a user left on an older series is seen (design 32): per package its series with each one's newest published
version, and its trunk users with the series they accept, that series' newest and the version they are pinned to.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:51:43 +01:00
jschoubben eedd3271ce DLV-I6: a user accepting a package's series is fingerprinted against its newest published version, by that version's contents
A series' newest published Go package keeps its module's digests read at the version's commit; each module of another
repository whose series the user's manifest accepts enters the import form and the version line by them, pinned to the
version (Go's form). A series with no published version keeps the commit form; a group member keeps its head.
A publish that moves a series' newest recomputes at once.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:49:27 +01:00
jschoubben 1592abbf4f DLV-I5: the indexer keeps each package's series, its newest numbered and newest published trunk version
A user takes the newest of the series it accepts (ADR 0609 §1), so the
indexer learns versions from the builder's numbered and the registry seats'
published, one key <package>.<series> beside the per-module digests.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:42:33 +01:00
jschoubben ab502a7174 DLV-C3r1: a group's Go package version is keyed by the module path go-package-registry's published names
The seat's contract now carries the Go module path, so the checker no longer
works it out from the artifact's paths, which could disagree with it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:39:08 +01:00
jschoubben 753ea6a067 DLV-C3r3: name the packed group package the old form, kept while the running checker hands it
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:36:14 +01:00
jschoubben 2a3ce39590 DLV-C2, DLV-C6 (verdict): the checker says each pull request's verdict on its stream asks, with the out-of-step files its steps named
Design 26 has the indexer learn out-of-step generated files from the check's
verdict event, so it runs no generator; the test step's answer carries them.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:35:51 +01:00
jschoubben 8c3916bd7b DLV-C3, DLV-C6 (tag-wanted): the checker's whole-module test hears tag-wanted at the definition's subject and builds on the published
A case of the existing group test (no new integration test): the bed's streams are the manifest's (checks with
preview.written, asks with verdict and tag-wanted at the mesh-delivery-checker definition's subjects) and the shared
events stream with the registry seats' published. A group whose mesh-sdk member has no preview yet is read at its head,
says tag-wanted whose body decodes strictly into the definition's type, is not judged before the Go seat's published,
and then builds mesh-delivery against v0.1.15-feat-x.1. The verdict half of DLV-C6 follows DLV-C2.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:28:44 +01:00
jschoubben 3a64fde7d9 DLV-B10: the builder's three narrations are build.said entries of its build log
Design 51 says the build log tells a version kept and why, a tag ask unanswered after five minutes,
and the old form's window closing; the process log alone was read by nobody. Each numbering decision
is now a build.said of its module and fingerprint, its why one of numbered, kept, waiting, unnumbered;
the tag ask and the window, no one build's, are said under the builder's own key, the ask's or the
window's key standing for the fingerprint. Needs mesh-sdk 7efb173 (build.said). Each failed first.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:26:15 +01:00
jschoubben 28ef5af2df DLV-B9 readers: the indexer and the view read the builder's log kinds through one helper of mesh-delivery's sdk
The two copies of IsBuildEntry move into sdk/buildlog, which both modules already require from the repository's own
sdk module (the lead's ruling 5 on A2c's finding 5). The existing rows in the indexer's registered test and the view's
history test cover it unchanged.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:24:17 +01:00
jschoubben c04fa5ff62 DLV-C3r3, C3r4: a group's check builds its members against the group's tagged versions
Design 51 *Tagged versions* and ADR 0607 §3, §5: once each package's published is heard, a Go member's ask names the
group's tagged version of each package another member publishes (Go's form, v0.1.15-feat-x.2), a package new in the
group included, and a TypeScript member's Node fetch takes {name, version} from the registry instead of a packed head.
The published is matched by seat, head and name, so one head with several Go packages (mesh-contracts) gets each its
own version. With no preview of the merge, a member's manifest is read at its head on the forge, so a package new in
the group is asked for on the first check too (the lead's ruling 4 on A2c's finding 4).

Needs mesh-sdk's PackageVersions.Check to take a Go semantic version (a2d-sdk-tagged-version.patch, for A1).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:23:10 +01:00
jschoubben 321c4e4122 DLV-B4, DLV-B6: the seats' outcomes and a branch's end are cases of the builder's whole-module test
On the real bus the artifact seat's removed (retention) and refused (another digest) move a numbered
version, and the forge bridge's merged asks the Go seat to remove the version tagged for the branch,
reason tag-gone, whose removed turns it removed. Each failed first with its hearing off. No new test
function: the builder holds 16 integration tests.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:23:02 +01:00
jschoubben fb4fc8a602 DLV-B10: design 51's round-2 builder cases are cases of the builder's whole-module test
The bed holds the builder's versions, findings, floor-waits and reasks, EVENTS with the artifact
seat's outcomes, and the floor's three sources served on the bus. Two merges close together number
the second at once; builder_publish_again after published takes a new number; a step bound running
out raises build-failed, and the fingerprint built again keeps its number, said with why, and clears
it. Each case failed first with its behaviour off. The kept version is said in the process log until
the build log takes build.said.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:20:49 +01:00
jschoubben d53986f1f6 DLV-F5r: the grants part started before its owners answer waits, says so, and composes nothing from them
A case of the grants part's whole-module test (ADR 0615 §6, ADR 0621 §3): each first view refused once, the part's log
names the indexer's and the assigner's tools it waits on, no membership is written; once the owners answer it composes
as before.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:12:29 +01:00
jschoubben 6c94ff75e2 DLV-B8: the tag ask's narration is a case of the builder's whole-module test, and two instances together joins the archive table
The builder's integration tests fall from 17 to 16: the two-instance test is a row of the archive table; the bed holds the
tag asks and their key-ttl bounds and the builder's process log.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:11:57 +01:00
jschoubben d2e7754816 DLV-B6: a tag ask is marked numbered with its versions, and one unanswered is narrated when its bound ends on the bus
A check's build of the ask's head writes the ask numbered in tag-asks by compare-and-set. An unanswered ask has a key in the
new key-ttl state tag-ask-bounds (five minutes); the bus's marker of its end, heard on the watch, narrates the ask with what
it waits on (the check's build not claimed, names waiting on their floor) and writes the bound again; no timer (design 51
*Tagged versions*). A restart arms each unanswered ask whose bound ended meanwhile.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:11:57 +01:00
jschoubben 8e60a3e078 DLV-C3 follow: the builder hears tag-wanted on the checker's own stream, mesh.stream.<checker>.event.tag-wanted
The checker's family asks is its own stream, whose subjects are mesh.stream.<module>.event.* (ruled on A2c's finding 1);
its verdicts on the same stream are acknowledged and not taken as tag asks.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:11:57 +01:00
jschoubben e20eb05a9b DLV-J4: the judge refuses keeps on a tools bundle's bus, the bus module's included (ADR 0626 §1, §3)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:08:15 +01:00
jschoubben 273da7535b DLV-R1: a verdict below the picture's revision asks the verdicts' first view again, unless the picture holds it
ADR 0615 §5: a gap in revisions asks the first view once more. The indexer's verdict events carry no mark of when its
verdicts were made, so verdicts made again show only as revisions starting low, which the grants part passed over as
already held. A redelivery of a verdict the picture holds for its manifest is still passed over, so a consumer's backlog
after a restart asks nothing.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:07:57 +01:00
jschoubben b3552eafd6 DLV-C3: tag-wanted is the mesh-delivery-checker definition's holder event, branch included
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:07:35 +01:00
jschoubben 606e2b9212 DLV-B9 readers: the indexer and the view pass over build-log entries that are no build's start or end
The builder writes design 51's old-form close into its build log; the indexer's Builds.Put refused it
(no repository) and the view's history stopped on it. Both read the entry's event first and skip any other
kind, whatever its form.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:06:57 +01:00
jschoubben 8a78e00bb0 DLV-G1, DLV-W1: the grants part and the watchman hear mesh-broker's events on the seat's subject; the keeper is granted them
The lead's ruling of 13:0x (registry milestone): mesh-broker's events are mesh.seat.mesh-broker.event.<event> on the
shared events stream like every seat's. The grants part and the watchman consume mesh-broker.<event> and no longer hear
the bus module's own stream, which is its history for its tool. The keeper's process account is composed the seat's
event subjects, and the acknowledgement of a snapshot's chunks of each entry it keeps, which ADR 0626 §1 grants and the
composition lacked; a row holds it to no subscription of a restricted stream's subjects and no consumer of one.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:05:27 +01:00
jschoubben 5865d3c632 DLV-C3: a group's check says tag-wanted for each package its members declare and builds no member before its published
The checker's family asks (stream asks, seven days, in the old streams section) carries tag-wanted
{repository, branch, head, module, package, pull}; the tag is left to the builder, which makes it from the
branch. Each package's published is heard from the two package registry seats on the checker's durable
consumer of the shared events stream; none within the ten-minute bound fails the check naming the package,
before any member is built. A lone pull request and a try ask nothing (design 51 *Tagged versions*).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:03:42 +01:00
jschoubben 5fbd84091e DLV-B6: a check's build of a head a kept tag ask names numbers its packages tagged at its claim
The builder reads tag-asks for the candidate's repository and head (versions.TagFor), numbers only its packages under
the tag below the next version, keeps each with its repository for the branch's end, and says numbered with the head;
the same fingerprint claimed again keeps its number (design 51 *Tagged versions*).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:00:17 +01:00
jschoubben 0dc0cf9c9a DLV-B9: the old reference form's window over sdk/forms, kept in old-form, read and dropped by builder_old_form
The window opens while a counted node-engine reads, of some kind, none of the newest form sdk/forms knows, and closes
when none does: said once as version.old-form-closed, and a node holding it seven days is old-form-held about it. It
steps on a statement's change, the placement, a hand drop and each build key written, never on a timer. A retired node
dropped by hand leaves the count of the forms a build key keeps (design 51). internal/versions' own Forms goes: which
forms a key keeps is sdk/forms'.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:55:45 +01:00
jschoubben 20a4a79993 DLV-G2: a row that no tool runner, no tools bundle of the bus module and no account invoking every tool holds the bus's create, update, snapshot or delete subjects of a module's bus state (ADR 0626 §4, How it is checked)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:50:18 +01:00
jschoubben 5c8d59b6c7 Merge lead-47's L3 mesh-delivery head 710c4032 into the registry builder
The builder keeps both: the registry's versions, floor, tag asks and findings, and ADR 0618's engine-reads state,
its tool builder_engine_reads, the node-engine seat's manifest-reads verb and manifest-reads-changed event.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:48:42 +01:00
jschoubben cb1cf7fd73 DLV-R1: the grants part follows the verdicts on the indexer's manifest-accepted and manifest-refused at the revision each carries, not on its views family (ADR 0622 §2)
The next revision changes the verdicts' picture with no ask; one already held is passed over; an
event carrying no revision, past the next, or naming no manifest above the picture asks the first
view once more, said in the log. The manifest consumes the two events in place of views.verdicts.
Needs mesh-sdk 3ff694e (the events' Revision) and the indexer setting it (A2b).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:48:30 +01:00
jschoubben 0811861ede DLV-I7: the requirements state and indexer_requirements, what consumers' go.sum files require of each mesh Go module
Filled at each merge from the go.sum the indexer reads (the lead's ruling on I7), read once per consumer per process
even where its hash is unchanged; the builder's floor asks {package} and reads {requirements: [...]}, highest last.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:48:02 +01:00
jschoubben 27e3bb266f DLV-I11: the manifest test counts indexer_bus_state
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:45:04 +01:00
jschoubben b5a88fae9d DLV-I11: indexer_bus_state, the bus module's keeper's first view of every trunk module's accepted bus state
Answers {revision, modules:[{module, digest, revision, bus-state}]} from the indexer's own states, at the verdicts
state's mark, as A5's keeper reads it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:44:46 +01:00
jschoubben e612fbebe7 DLV-B3: a name waiting on its floor is the builder's finding floor-silent, cleared once all three sources answer
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:44:18 +01:00
jschoubben 627cb0c0c9 DLV-G2: the bus module's keeper is the process whose bus declares keeps, and only it is composed the create, update, snapshot and delete of every module's bus state (ADR 0626 §1, §3)
A process of the bus module without keeps, or keeps on any other module, is composed nothing
of it. mesh-sdk's ProcessBus.Keeps is asked of A1 (a5-sdk-keeps.patch).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:44:17 +01:00
jschoubben 2178db5a1d DLV-B7: a failed trunk build is the builder's finding build-failed, cleared by a later done build
Read from the builder's own build log entries at their end; a candidate's
build is the check's to say.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:43:51 +01:00
jschoubben 710c4032d2 The checker hands the composer the node-engine's input encoded again from the seat contract's object
mesh-sdk's compose-candidate answer is now the node-engine seat's generated type, whose input is a JSON object; the
composer's request takes bytes, so the checker encodes it again, its numbers kept as written (json.Number).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:43:11 +01:00
jschoubben bbe6ac6ef5 DLV-B6: a branch's end removes its tagged versions, acknowledged once every seat said removed; the checker's tag-wanted is kept in tag-asks
The builder hears the forge bridge's stream on a durable consumer: merged, a
pull's closed and branch-deleted ask remove of each tagged version still
held, and the bus gives the message again each minute until all are removed;
past 15 minutes its finding tag-removal-not-done names the seats. The builder
gains the state findings (ADR 0564 §2). Every forge message also wakes a floor
whose bridge_tags was silent.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:42:57 +01:00
jschoubben f271a70659 DLV-F4r: the grants part takes the views follower from mesh-sdk (go/views); the held-seat rows use a seat whose verbs its holder declares
mesh-broker's verbs are all optional now and the composition grants its reading verbs to its
holder, so taking one from a membership loses no holder: the rows that prove a lost last holder
is held back name a seat the catalogue defines without verbs, claimed by the bus module beside
mesh-broker.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:41:36 +01:00
jschoubben 7fd07d0236 DLV-R1: the judge's seat-set test reads mesh-contracts' seat list: fifty-nine seats, the registry seats beside the artifact store, pull-waits and renames
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:41:30 +01:00
jschoubben a7d490b0e5 DLV-J4: the judge allows the bus module's keeper its own process account, and keeps on it alone (ADR 0626 §1, §3)
The new nats manifest has a keeper process with a bus account of its own; the judge refused it under ADR 0235 §8.
Needs mesh-sdk's ProcessBus to carry keeps (requested of A1); until then the strict decoder refuses the key.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:40:49 +01:00
jschoubben a70b9a0951 DLV-B4: the builder hears the four registry seats' published, refused and removed, and keeps its floor waits in the state floor-waits
Each outcome is a seat event <seat>.<event> on the shared events stream (the
lead's ruling of 12:4x), heard on the builder's events consumer through its
consumes; the subjects are mesh-contracts'. A restarted builder takes its
floor waits in again, so its silent sources' events still wake them;
builder_floor_waits reads them.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:38:15 +01:00
jschoubben 3a789b7a6b DLV-O4: manifest-accepted and manifest-refused carry the verdicts state's revision of the verdict they say (ADR 0622 §2)
The grants part follows verdicts by these events after its first view, so each must carry the revision it is at.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:37:33 +01:00
jschoubben 72da51a10a The node-engine seat's manifest-reads answer and event come from mesh-contracts' nodeengine contract, judged by their reader (ADR 0618 §3, §4, issue 774)
The contract carries no checks, so sdk/enginereads judges the layout's version, the node, each capability, the reason and the times. The local replaces of mesh-sdk are gone: the lead pins mesh-sdk and mesh-contracts at the join.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:36:48 +01:00
jschoubben b109d09b84 DLV-F3r: the indexer and the assigner take the views follower from mesh-sdk go/views, and mesh-delivery's sdk/views goes
One copy of the reader's rule for every reader, kept where shared code lives (mesh-sdk), as the lead ruled.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:36:00 +01:00
jschoubben d78161f579 DLV-T1: the watchman's three integration tests are one whole-module test with two subtests, so mesh-delivery's integration count falls by two (test budget)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 2cb19c6415 DLV-F4r, DLV-R1: the grants part reads placement, wants, manifests, modules and verdicts by their owners' first views, then their views families; an answer empty at revision 0 drops no login (ADR 0615 §4 to §6, ADR 0621, ADR 0622)
Each first view is asked at the start until answered, past two minutes raising
first-view-unanswered naming the owner and the tool; an empty answer at revision 0 reads as
a failed read, so the user list stays in force, and first-view-empty names the operator's
step until an answer above 0. Each owner's views event applies one change with no ask; a gap
asks once more. The reads of the five buckets leave the manifest, and the tools ask the
first views too. Tools grants_first_views and grants_first_view_again (a hand act).

The verdicts are followed on the indexer's views family (views.verdicts), not on
manifest-accepted and manifest-refused as ADR 0622 §2 words it: those carry no revision.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 56891abe20 TLS-5 grants: the tool runner's consumes of the owners' views get their durable consumers and its findings state its grant, as any manifest's (A7's request; ADR 0615 §5)
Nothing in the composition changes for it: the consumers on module streams are made since
DLV-G1. The row proves it on the tool runner's own manifest.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 5b68106662 DLV-G2: the bus module's process serves nothing, as every process account (ADR 0436 §3): it hears its module's consumes on a consumer of its own, says its stream's events and raises its conditions; the seat's reading verbs stay its module's tools'
The keeper is asked by a person through its own state requests (CAT-N8); the acting verbs
are served by nobody on the bus and denied to every principal whose grant reaches them. The
bus module's tools bundle may read its module's own stream back (nats_history).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 745c30e068 DLV-G1, DLV-W1: read the bus module's events by the mesh-broker contract's field names, on its own stream or the seat's subject
The seat's definition (mesh-contracts meshbroker) names local-name and storage-type and puts
the events on mesh.seat.mesh-broker.event.<event>; design 25 § 12.3 keeps them on the bus
module's own stream. Both are read until that is ruled.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben fc150986b7 DLV-W1: the watchman says an accepted entry of bus state the bus module said nothing of in five minutes, and a refused one, until created or no longer asked (ADR 0595 §4, design 25 § 12.2)
It consumes the indexer's manifest-accepted and the bus module's created and refused on its
durable consumers. The five-minute bound is a row of its signals table (a pulse key whose
expiry is the finding, read back at a restart), not a new state: the prepare's state
overdue would have kept the same keys with no expiry to act on.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 8f6562368c DLV-G2: the bus module's process gets its own account: it defines every module's bus state by its names and an orphan's it created, serves mesh-broker's eight verbs, and no other account holds the acting four (ADR 0595 §4, §5)
The grants are composed for the process of the module holding mesh-broker, from every
manifest's bus-state entries and the streams of the bindings kept; the writers table names
it beside the grants part as a definer of streams; the acting verbs are denied to every
principal whose grant reaches them, and neither the holder's own account nor the tool runner
serves the seat's verbs.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 8975c37a0a ENG-11 grants: a node's engine hears the status view's settled, on which it retries a failed pull from a store's holder (design 51)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 7cd5bfb31b DLV-G1: the grants part writes the bus module's binding into the module's memberships on created, rewrites it on recreated, removes it on dropped, kept in memberships (ADR 0595 §1, §7)
The part hears the bus module's own stream on its durable consumer there. That consumer did
not exist: since the controller ended (ADR 0590) nobody created a module's consumer on a
module stream it hears (AccountConsumers left them to the controller), so it is created now
with every account consumer, and a pass that created consumers wakes one more pass so the
part's own hearing opens at once. Bindings are kept per module under bound.<module>.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben e33cf8459b DLV-G3: the grants part raises no history-full; fill is the bus module's, and the old key is cleared once at start (ADR 0595 §6, design 25 § 12.3)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:34:50 +01:00
jschoubben 6b3a679151 DLV-B5: builder_publish_again, builder_floor_again, builder_tag, builder_tag_asks and builder_untag, with the state tag-asks
A holder that failed leaves a version to publish again by hand: said numbered again while the build is held, built
again under the same number when its file is gone, numbered anew after a published; refused for other bytes. The
floor is asked again by hand; a branch's tagged versions are removed through each seat's remove, per repository.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:30:20 +01:00
jschoubben e41666d846 mesh-delivery-view: node_manifest_reads lists what each node's engine reads by asking the node-engine seat's verb of every node at once, each bounded, naming a node not served or away with its record's reads (ADR 0618 §3, issue 774)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:26:48 +01:00
jschoubben ad77815d88 mesh-delivery-builder: a done key keeps each reference form a counted node's engine reads, the oldest in its single reference, and names its needs; what each counted node reads is asked through the node-engine seat's verb and kept in the state engine-reads (ADR 0618 §1, §2, §4, ADR 0624 §1, §3, issue 774)
A counted node is one the placement names; a node with no statement reads as the baseline's first forms, and no node record is read in its place. builder_engine_reads lists, asks again and drops.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:24:21 +01:00
jschoubben b873b9af4b DLV-I10: indexer_config_readers; and indexer_read_findings with its views event
indexer_config_readers {module} answers {readers: [{module, key, kind,
readers}]} for every value key and own secret any kept manifest of the
module ever declared: the module where its trunk's manifest declares it,
each open pull request whose head declares it, none for a key no longer
read (design 32 *A value no longer read*). It reads one watched copy of
the modules, manifests and config-candidates states. Nodes is left out:
the indexer cannot say which nodes' builds read a key. The indexer's
findings are said on its views family and read by indexer_read_findings,
for the condition keeper (the lead's ruling).

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben d662c63db5 DLV-J1, DLV-J2, DLV-J3: the foundation is the nine parts, and indexer_old_sections counts the reads left on the owners' state
The judge's foundation holds exactly the nine parts of ADR 0619 §1 and §3,
left out of the old sections' count, and refuses a foundation manifest
asking bus state in the new form. The indexer also counts, across the
accepted manifests, the reads entries still declared on the indexer's and
the assigner's state, by owner and by entry; indexer_old_sections answers
both, so design 25 § 12.7's live row (0 on each owner) has its tool.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 3e241b3034 gofmt the assigner
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 51eedd93a1 DLV-F2r, DLV-F3r (assigner): the assigner reads the indexer's manifests and modules through its first views
The assigner no longer reads the indexer's manifests and modules buckets
(ADR 0615 §1): it asks mesh-delivery-indexer.manifests and .modules at its
start, its first pass waiting for both answers as it waits for every watch,
then follows indexer-views on its durable consumer, asking once more on a
gap; past the bound its finding names the indexer and the tool. Its status
is in its state first-views, read by assigner_first_views and asked again
by hand with assigner_first_view_again. The whole-module tests run against
an indexer owner built from the views emitter and first-view answer.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 7e77074fec sdk/views: gofmt
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben ccfca79c90 DLV-F1r, DLV-F3r (indexer): the indexer reads the placement through the assigner's first view
The indexer no longer reads the assigner's placement bucket (ADR 0615 §1):
it asks mesh-delivery-assigner.placement at its start, waiting at most the
two-minute bound so a normal start computes the fingerprints once, then
follows assigner-views on its durable consumer, asking once more on a gap.
Past the bound it raises its finding naming the assigner and placement,
cleared by the answer. Its status is kept in its state first-views, read
by indexer_first_views; indexer_first_view_again asks again by hand.
sdk/views gains the event's write time, the status hook, and the status
state and tools a reader's bundle serves.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 1d7124e388 DLV-FB1 (indexer's side): a FORGE message of a kind the indexer does not take is acknowledged and passed over
The forge bridge now says a branch's end as branch-deleted (design 51); the
indexer terminated it as unreadable. It acknowledges it, writes it to heard
as ignored, and the two handle tests are one table (mesh-delivery is over
its unit budget).

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 9d79379c5f DLV-O5: the assigner announces its marks state; its tests' placement stream takes only the seat's four events
Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 249a9333aa DLV-O2, DLV-O3, DLV-O5: the assigner answers its first views and says its views family
The assigner's bundle runs in a runtime of its own on its own account and
serves the first-view tools placement, seat-holders and wants, each answering
only the callers its manifest writes (ADR 0621 §1-§2), and the six read tools
of design 25 § 12.2. The process says each change of those six states on the
family assigner-views beside placement in its old streams section (ADR 0619
§2, ADR 0615 §5); a waiter on put never wakes on it.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 5d7fff91ce DLV-O1, DLV-O3, DLV-O4: the indexer answers its first views and says its views family
The indexer's tools bundle runs in a runtime of its own on its own account
(runs-as) and serves the first-view tools manifests, modules and verdicts,
each answering only the callers its manifest writes (ADR 0621 §1-§2, ADR
0622 §1), and the nine read tools of design 25 § 12.2. The process says each
change of its ten read states on the family indexer-views in its old streams
section (7 days, 256 MiB), with a mark per state the first view answers its
revision from, so a reader that takes the answer misses no event after it
(ADR 0615 §4-§5). The shared shape lives in sdk/views: event, answer, mark,
the emitter, the tools and the reader's follower.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:22:26 +01:00
jschoubben 5aa7bc7d49 mesh-delivery-assigner: what each node's engine reads comes from the node-engine seat's verb, asked at its start, on manifest-reads-changed and when a node is back online; a build's reference forms hold a want only when the node reads none (ADR 0618 §1, §3, §4, ADR 0624 §2, issue 774)
Where the engine does not answer the verb, the node's record is read once in its place, said in the journal (the fallback until every counted engine serves the verb). Kept in the state engine-reads, which assigner_engine_reads lists and asks again.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:19:34 +01:00
jschoubben e68a47886f sdk: the reference forms a build keeps and holds by, and the keeper of what each node's engine reads, shared by the assigner, the builder and the view (ADR 0618, ADR 0624, issue 774)
Logic only mesh-delivery's parts share, so it is mesh-delivery's and not mesh-sdk's (ADR 0593 §1, ADR 0608 §5). Tested through the assigner's and the builder's rows (the test budget). mesh-sdk is pinned by a local replace to the group's head while developing.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:19:34 +01:00
jschoubben a8bcd2abb9 DLV-B3b, DLV-B4 logic: a floor wakes on manifest-accepted, condition-cleared and the next source revision; the seats' outcomes move a version
The builder's one consumer of EVENTS now consumes the indexer's manifest-accepted and
the condition keeper's condition-cleared; a heard source revision stands for the
forge's merge. Numberer.Heard marks a version published, refused or removed and wakes
a floor whose seat was silent; its consumer of each <seat>.events waits on the seat
family's stream form (asked of A1).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:18:00 +01:00
jschoubben 688f6c21fa DLV-B2, DLV-B3 wiring: number each trunk build before its claim, record its digest, ask the floor as the builder's own account
A package whose floor is not known waits and nothing is claimed; an image, bundle or
archive is built unnumbered. The state versions is bound when the bus holds it, every
new number is said as version.numbered, and the manifest invokes the floor's three
sources (design 51 The versions, The floor; ADR 0606, ADR 0617 §1).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:11:53 +01:00
jschoubben ea193186c5 mesh-delivery-indexer: its judge knows the node-engine's manifest-reads and manifest-reads-changed (ADR 0618 §3, §4, issue 774)
mesh-sdk is pinned by a local replace to the group's head while developing.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:06:58 +01:00
jschoubben 4d94b0b9e4 mesh-grants: a node seat's event that names its holder's machine is heard from every machine, by the one rule its holder is granted it by (ADR 0618 §4, issue 774)
A watch of the node-engine's manifest-reads-changed subscribed the flat subject, which no engine says, so its consumer would hear nothing. mesh-sdk is pinned by a local replace to the group's head while developing.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:05:58 +01:00
jschoubben bd13302ecf DLV-FB1: the forge bridge says a branch's end as branch-deleted and answers bridge_tags
The bridge consumes the forge module's forge.delete and writes a branch-deleted message on FORGE for a deleted
branch (never the trunk, never a tag), so the builder removes the branch's tagged versions (design 51 *The forge
bridge says a branch's end*). Only the forge module's account may say forge.delete: the bus refuses it from
another (a case of the bridge's whole-module test). bridge_tags, served by a new tools bundle, answers a
package's git tags by prefix through the forge module's gitea_api, for the builder's floor.

One unit test merged into another, so the tags test adds no function (test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:44:53 +01:00
jschoubben 2274155766 DLV-B3, DLV-B3b, DLV-B9: the floor's three sources and what wakes a silent one; the old reference form's window
The floor asks the seat's versions, bridge_tags and indexer_requirements; a silent source is named in the
floor finding and woken by an event of that source (the holder's published, the forge bridge's next message, the
indexer's next manifest-accepted) or its module's condition clearing, or by hand (builder_floor_again), never by
a timer (design 51 *The floor*, ADR 0617 §1). The old form's window (ADR 0624 §1-§3, ADR 0618 §2): each counted
engine gets the newest form it reads among those written, the new form only once every present engine states
it, the oldest kept in the single reference; a node not serving manifest-reads counts as the baseline; a node
dropped by hand counts no more; old-form-closed is said once, and a node holding it seven days is a finding.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:41:05 +01:00
jschoubben 64b918c64c DLV-B1, DLV-B2: the builder's state versions, numbering under compare-and-set per fingerprint or source revision, and builder_versions
The builder keeps one key per name, tag and version (design 51 *The versions*, ADR 0606), in its old state
section (ADR 0619), history one, 64 MiB with the 80 % mark. A package is numbered per source revision and keeps a
version held published, publishing nothing; one numbered but never stored is published again under it. An image,
bundle or archive keeps its fingerprint's number. An unknown floor builds an image, bundle or archive unnumbered and
makes a package wait (ADR 0617 §1). Merge order and tagged versions below the next version are the package's
helpers. builder_versions answers a name's versions and the holder's question (ADR 0605 §4).

Four claim tests merged into two, so the new table test adds no function (test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:39:17 +01:00
269 changed files with 21306 additions and 1773 deletions
@@ -5,6 +5,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
@@ -12,6 +13,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -19,9 +21,10 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -30,6 +33,11 @@ import (
"git.novox.be/novox/mesh-sdk/go/provisions"
"mesh-delivery-assigner/internal/judge"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
)
@@ -92,13 +100,31 @@ func startBed(t *testing.T, leaveOut string) *bed {
Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, AllowDirect: true}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil {
t.Fatal(err)
}
// The assigner's durable consumer on EVENTS, as the grants part makes it from the manifest's consumes: a node-engine seat's
// event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose
// machineEvent; the grants part's own test holds the derivation).
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
FilterSubjects: []string{enginereads.SubjectPrefix + "*"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CONDITIONS", Subjects: []string{"mesh.seat.operator-channel.accept.condition.>"}}); err != nil {
t.Fatal(err)
}
// The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side.
serveIndexer(t, b, nc)
// The assigner's views family and its marks (ADR 0615 §5, ADR 0619 §2), on its old sections.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNER_VIEWS", Subjects: []string{"mesh.stream.mesh-delivery-assigner.event.views.>"}}); err != nil {
t.Fatal(err)
}
if kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: marksBucket, History: 1}); err != nil {
t.Fatal(err)
} else {
b.buckets[marksBucket] = kv
}
// The Go build seat's work queue, as the broker derives it from the seat's accepted kinds (delivery.AskStream).
seat, _ := delivery.AskStream(delivery.AskGo)
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seat, Subjects: []string{"mesh.seat.node-build-go.accept.>"},
@@ -340,6 +366,19 @@ func (b *bed) get(t *testing.T, bucket, key string) []byte {
// raises the condition naming g14, web and the canary rule; once ace runs F healthy, g14's first want names F and the condition is
// cleared.
func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
// DLV-F5r (ADR 0621 §3): the indexer's runtime starts together with the assigner and answers its first views only
// after the assigner asked; within the bound the assigner writes the wants from what they answer.
up := make(chan struct{})
indexerLate = up
// ADR 0631 §2: while the indexer does not answer, past the first-view bound the assigner raises the condition naming
// the indexer and graph, and reads nothing of the indexer's state; the answer clears it.
firstViewBound, firstViewWait = 300*time.Millisecond, 50*time.Millisecond
t.Cleanup(func() { indexerLate, firstViewBound, firstViewWait = nil, 0, 0 })
for _, name := range append(append([]string{}, readBuckets...), watched...) {
if strings.HasPrefix(name, delivery.IndexerModule+"_") {
t.Errorf("the assigner reads the indexer's state %s itself", name)
}
}
b := startBed(t, "")
fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64)
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
@@ -367,6 +406,31 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
}
b.compose(t, "shanks", 'a')
b.start(t)
// DLV-F5r (ADR 0621 §1-§3, design 25 § 12 on a fresh mesh): the assigner asks nothing of the bus module (its manifest
// has no bus-state entry, the bed runs no bus module and holds no binding of one), and while it still waits on the
// indexer its runtime of its own answers each first-view tool to the readers its manifest's callers lists name, at
// its mark, and refuses every other account, the assigner's own included.
firstViewsBeforeAnyNewFormState(t, b, map[string][]string{
"placement": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer"},
"seat-holders": {"n1.node-tools"},
"wants": {"n1.mesh-grants"},
})
graphKey, _, _ := views.UnansweredFinding(delivery.AssignerModule, views.Status{Owner: delivery.IndexerModule, State: graph.State}, time.Now())
graphRaised := func() bool {
e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey)
return err == nil && strings.Contains(string(e.Value()), delivery.IndexerModule+".graph")
}
for deadline := time.Now().Add(10 * time.Second); !graphRaised(); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("with the indexer down, no condition names its graph (finding %s)", graphKey)
}
}
close(up)
for deadline := time.Now().Add(10 * time.Second); graphRaised(); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("the indexer answered, and the condition naming its graph stands (finding %s)", graphKey)
}
}
wkey, _ := delivery.WantKey("ace", "web")
want, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey))
@@ -376,15 +440,18 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "" || !w.Self || w.Newest != fNew {
t.Errorf("shanks' want = %+v, want the fingerprint it runs, the node composing it, held back from F", w)
}
var neither asks.Condition
// DLV-O5 (ADR 0615 §5, ADR 0619 §2): each want is said on the assigner's views family, its value whole at its revision,
// and the first view at the mark followed by those events holds it as the bucket does; a waiter on put never wakes.
b.viewsHold(t, "wants", wkey)
var neither oc.Condition
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && neither.Key == ""; time.Sleep(100 * time.Millisecond) {
for k, c := range b.conditionsSaid(t) {
if strings.HasSuffix(k, ".neither") && c.State == asks.StateRaised {
if strings.HasSuffix(k, ".neither") && c.State == occ.StateRaised {
neither = c
}
}
}
if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != asks.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") {
if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != occ.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") {
t.Errorf("the condition of web on neither list on g14 = %+v", neither)
}
if keys, err := b.buckets[delivery.AssignerDifferencesBucket].Keys(b.ctx); err == nil && len(keys) != 0 {
@@ -404,7 +471,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
cleared := false
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !cleared; time.Sleep(100 * time.Millisecond) {
c, ok := b.conditionsSaid(t)[neither.Key]
cleared = ok && c.State == asks.StateCleared
cleared = ok && c.State == occ.StateCleared
}
if !cleared {
t.Errorf("the condition of web on neither list on g14 was not cleared once g14 got its want")
@@ -490,6 +557,118 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') {
t.Errorf("app's want = %+v", w)
}
// ADR 0618 §1, §3, §4, ADR 0624 §2: ace's engine answers the node-engine seat's manifest-reads; a build written only in a
// form it does not state is held on ace, naming the form, and moves once the engine says manifest-reads-changed on its
// subject and states the form; no timer moves it. g14's engine answers nothing and has no record: read as the baseline.
stated := catalogue.ManifestReads()
var statedMu sync.Mutex
nc, err := nats.Connect(b.srv.ClientURL())
if err != nil {
t.Fatal(err)
}
defer nc.Close()
if _, err := nc.Subscribe("mesh.seat.node-engine.tool.manifest-reads.ace", func(m *nats.Msg) {
statedMu.Lock()
answer, _ := json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: "ace", ManifestReads: stated, StatedAt: time.Now().Format(time.RFC3339Nano)})
statedMu.Unlock()
reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(answer), "node": "ace"})
_ = m.Respond(reply)
}); err != nil {
t.Fatal(err)
}
say := func(why string) {
body, _ := json.Marshal(nodeengine.ManifestReadsChangedEvent{V: 1, Node: "ace", ManifestReads: stated, At: time.Now().Format(time.RFC3339Nano), Why: why})
if _, err := b.js.Publish(b.ctx, enginereads.SubjectPrefix+"ace", body); err != nil {
t.Fatal(err)
}
}
say(enginereads.ReadsStarted)
formsOld, formsNew := fpOf('5'), fpOf('6')
formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`)
formsAt := func(fp string) {
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: delivery.RootPath})
b.putJSON(t, delivery.ModulesBucket, "forms", mod)
}
for _, fp := range []string{formsOld, formsNew} {
build := delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest}
if fp == formsNew {
build.Needs = []string{"form:new"}
}
built, err := delivery.EncodeBuilderBuild(build)
if err != nil {
t.Fatal(err)
}
bkey, _ := delivery.BuilderBuildKey("forms", fp)
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
gkey, _ := delivery.PartGrantKey("forms", fp)
grant, _ := delivery.EncodeGrant(delivery.Grant{})
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
}
runsFormsOld, _ := delivery.EncodeHave(delivery.Have{FP: formsOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b.putJSON(t, delivery.HaveBucket, "ace.forms", runsFormsOld)
b.place(t, "ace", "forms")
formsAt(formsOld)
b.wantNow(t, "ace", "forms", "the want at what ace runs, a build in the first form", func(w delivery.Want) bool { return w.FP == formsOld })
formsAt(formsNew)
statement := func(node string, ok func(enginereads.Statement) bool) enginereads.Statement {
var last enginereads.Statement
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := b.buckets[engineReadsBucket].Get(b.ctx, node); err == nil {
if last, err = enginereads.Decode(e.Value()); err == nil && ok(last) {
return last
}
}
}
t.Fatalf("%s's engine reads are %+v", node, last)
return last
}
statement("ace", func(s enginereads.Statement) bool { return s.From == enginereads.FromVerb && s.Answered })
if s := statement("g14", func(s enginereads.Statement) bool { return !s.Answered }); s.From != enginereads.FromNone {
t.Errorf("g14, whose engine answers nothing and which has no record, reads %+v", s)
}
if w := b.wantNow(t, "ace", "forms", "the want held on ace, naming the form", func(w delivery.Want) bool {
return w.Newest == formsNew && strings.Contains(w.Words, "form:new")
}); w.FP != formsOld || w.Rule != delivery.RuleComposed {
t.Errorf("forms' want = %+v, want it held at what ace runs under rule 7", w)
}
statedMu.Lock()
stated = append(slices.Clone(stated), "form:new")
statedMu.Unlock()
say(enginereads.ReadsStarted)
b.wantNow(t, "ace", "forms", "the want moved once ace's engine said it reads the form", func(w delivery.Want) bool { return w.FP == formsNew })
// assigner_engine_reads asks ace's engine again through the state: the assigner answers there with what it states now.
again, err := enginereads.AskAgainOf(bedState{b, engineReadsBucket}, "ace", "n1.ada", "a person asks", time.Now(), 15*time.Second)
if err != nil || again.From != enginereads.FromVerb || !slices.Contains(again.ManifestReads, "form:new") || again.AskAgain != nil {
t.Errorf("the ask again of ace answered %+v, %v", again, err)
}
}
// bedState is one of the bed's buckets as the engine reads' tool reads it.
type bedState struct {
b *bed
bucket string
}
func (s bedState) Keys() ([]string, error) { return s.b.buckets[s.bucket].Keys(s.b.ctx) }
func (s bedState) Get(key string) ([]byte, uint64, bool, error) {
e, err := s.b.buckets[s.bucket].Get(s.b.ctx, key)
if err != nil {
return nil, 0, false, nil
}
return e.Value(), e.Revision(), true, nil
}
func (s bedState) Put(key string, value []byte) (uint64, error) {
return s.b.buckets[s.bucket].Put(s.b.ctx, key, value)
}
func (s bedState) Delete(key string) error { return s.b.buckets[s.bucket].Delete(s.b.ctx, key) }
func (s bedState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) {
for deadline := time.Now().Add(bound); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if v, rev, found, _ := s.Get(key); found && rev > after && done(v) {
return v, nil
}
}
return nil, fmt.Errorf("no answer at %s within %s", key, bound)
}
// 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check
@@ -514,7 +693,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
t.Fatalf("held = %+v, %v", h, err)
}
akey, _ := delivery.HoldAskKey("web", fNew)
a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
a, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
if err != nil || a.Asking <= 0 || a.Ask.ID != delivery.AskingID(akey, a.Asking) || len(a.Ask.Options) != 2 {
t.Fatalf("the ask = %+v, %v", a, err)
}
@@ -523,27 +702,27 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
// conditionsSaid reads what the assigner said of its conditions, by finding key: each a finding in its own bucket, which
// the condition keeper carries (novox/hq ADR 0590 §3, issue 1126), raised while it stands and cleared once deleted, each
// with the words its last finding said and the subject the finding names (`<module>[.<fingerprint>]`).
func (b *bed) conditionsSaid(t *testing.T) map[string]asks.Condition {
func (b *bed) conditionsSaid(t *testing.T) map[string]oc.Condition {
t.Helper()
w, err := b.buckets[assign.FindingsBucket].WatchAll(b.ctx, jetstream.IncludeHistory())
if err != nil {
t.Fatal(err)
}
defer w.Stop()
out := map[string]asks.Condition{}
out := map[string]oc.Condition{}
for e := range w.Updates() {
if e == nil {
return out
}
c := out[e.Key()]
c.Key, c.State = e.Key(), asks.StateCleared
c.Key, c.State = e.Key(), occ.StateCleared
if e.Operation() == jetstream.KeyValuePut {
f, err := health.DecodeFinding(e.Value())
if err != nil {
t.Fatal(err)
}
c = asks.Condition{Key: e.Key(), State: asks.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation,
Resolved: f.Resolved, Subject: asks.Scope{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}}
c = oc.Condition{Key: e.Key(), State: occ.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation,
Resolved: f.Resolved, Subject: oc.ConditionSubject{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}}
}
out[e.Key()] = c
}
@@ -570,7 +749,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
b.start(t)
akey, _ := delivery.RestoreAskKey("ace", "mail", fNew)
a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
a, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
if err != nil || len(a.Ask.Options) != 2 || a.Ask.Options[0].ID != "restore" || a.Ask.Options[1].ID != "leave-stopped" {
t.Fatalf("the restore ask = %+v, %v", a, err)
}
@@ -580,7 +759,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
key := ""
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && key == ""; time.Sleep(100 * time.Millisecond) {
for k, c := range b.conditionsSaid(t) {
if c.State == asks.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") {
if c.State == occ.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") {
if c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") {
t.Fatalf("the condition = %+v, want it to name ace and the check's words", c)
}
@@ -698,7 +877,7 @@ func (b *bed) wantNow(t *testing.T, node, module, what string, ok func(delivery.
// answer says the teller wrote the operator's answer to the ask that stands at key (its warrant names that asking's id).
func (b *bed) answer(t *testing.T, key, option string) {
t.Helper()
a, err := delivery.DecodeAssignerAsk(key, b.get(t, delivery.AssignerAsksBucket, key))
a, err := decodeAsk(key, b.get(t, delivery.AssignerAsksBucket, key))
if err != nil {
t.Fatal(err)
}
@@ -748,7 +927,7 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
cleared := false
for k, c := range b.conditionsSaid(t) {
cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held"))
cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held"))
}
if cleared {
return
@@ -758,17 +937,17 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
}
// askNow waits for the backup ask at key to be the one numbered asking and answers it.
func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk {
func (b *bed) askNow(t *testing.T, key string, asking int) decodedAsk {
t.Helper()
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := b.buckets[delivery.AssignerAsksBucket].Get(b.ctx, key); err == nil {
if a, err := delivery.DecodeAssignerAsk(key, e.Value()); err == nil && a.Asking == asking {
if a, err := decodeAsk(key, e.Value()); err == nil && a.Asking == asking {
return a
}
}
}
t.Fatalf("the ask at %s never became asking %d", key, asking)
return delivery.AssignerAsk{}
return decodedAsk{}
}
// 1906/D4 (ADR 0539 §16), whole on the bus: a module held on a node because its backup failed is asked about, and the answer
@@ -838,7 +1017,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
continue
}
for k, c := range b.conditionsSaid(t) {
if strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held") {
if strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held") {
if w := b.wantNow(t, "ace", "web", "the want at the lifted fingerprint", func(w delivery.Want) bool { return w.FP == fNew }); w.Rule != 0 {
t.Errorf("the want = %+v", w)
}
@@ -863,7 +1042,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) {
_, stop := b.startStoppable(t)
hkey, _ := delivery.HeldKey("web", fNew)
akey, _ := delivery.HoldAskKey("web", fNew)
first, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
first, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
if err != nil {
t.Fatal(err)
}
@@ -878,7 +1057,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) {
b.answerTo(t, akey, "keep", first.Ask.ID) // an answer to the first hold, which the later hold must not take
b.putJSON(t, delivery.HaveBucket, "ace.web", failed)
b.start(t)
again, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
again, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
if err != nil {
t.Fatal(err)
}
@@ -937,7 +1116,7 @@ func (s bedStore) history(string, string) ([]stdio.StateRevision, error) { retur
func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
b := startBed(t, "")
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + delivery.AssignerModule + "_placement",
Subjects: []string{process.ModuleStreamSubject(delivery.AssignerModule, ">")}}); err != nil {
Subjects: placementSubjects()}); err != nil { // the seat's four events: the views family has a stream of its own
t.Fatal(err)
}
heard := make(chan delivery.PlacementEvent, 16)
@@ -1167,3 +1346,270 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
t.Error("the removed node's key stays")
}
}
// viewsHold waits until a follower of state, from a first view at its mark and the views family's events, holds key as
// the bucket does, and checks that no views event landed where a waiter on the assigner's put waits.
func (b *bed) viewsHold(t *testing.T, state, key string) {
t.Helper()
kv, marks := b.buckets[viewedStates[state]], b.buckets[marksBucket]
src := func(kv jetstream.KeyValue) views.Source { return bedSource{b.ctx, kv} }
st, err := b.js.Stream(b.ctx, "ASSIGNER_VIEWS")
if err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) {
f := &views.Follower{Owner: delivery.AssignerModule, Tool: state, State: state, Ask: func(context.Context, string) (views.Answer, error) {
return views.FirstView(delivery.AssignerModule, state, src(kv), src(marks), "", time.Now())
}}
if err := f.Start(b.ctx); err != nil {
t.Fatal(err)
}
info, _ := st.Info(b.ctx)
gap := false
for seq := info.State.FirstSeq; seq > 0 && seq <= info.State.LastSeq; seq++ {
m, err := st.GetMsg(b.ctx, seq)
if err != nil || views.StateOfEvent(m.Subject) != state {
continue
}
e, err := views.DecodeEvent(m.Data)
if err != nil {
t.Fatal(err)
}
if g, _ := f.Apply(e); g {
gap = true
}
}
e, err := kv.Get(b.ctx, key)
v, ok := f.Get(key)
if err == nil && ok && !gap && string(v) == string(e.Value()) && f.Status().Revision >= e.Revision() {
break
}
if time.Now().After(deadline) {
t.Fatalf("the views family holds %s.%s as %s at %d (gap %v), the bucket %v", state, key, v, f.Status().Revision, gap, err)
}
}
ev, err := b.js.Stream(b.ctx, "EVENTS")
if err != nil {
t.Fatal(err)
}
if _, err := ev.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-assigner.event.put"); err == nil {
t.Errorf("a waiter on put woke: the bus holds a put")
}
}
// bedSource is a bucket as a views.Source.
type bedSource struct {
ctx context.Context
kv jetstream.KeyValue
}
func (s bedSource) Keys() ([]string, error) {
k, err := s.kv.Keys(s.ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return k, err
}
func (s bedSource) Get(key string) (*views.Entry, bool, error) {
e, err := s.kv.Get(s.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return &views.Entry{Value: e.Value(), Revision: e.Revision()}, true, nil
}
// placementSubjects are the subjects of the seat's four events on the assigner's stream placement.
func placementSubjects() []string {
var out []string
for _, e := range delivery.PlacementEvents {
out = append(out, process.ModuleStreamSubject(delivery.AssignerModule, e))
}
return out
}
// indexerLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the
// indexer down, its runtime coming up after the assigner asked (DLV-F5r, ADR 0631 §2).
var indexerLate chan struct{}
// serveIndexer is the indexer as the owner of its manifests, modules and module graph (ADR 0615, ADR 0621, ADR 0631): its
// buckets, which a test fills as the indexer writes them; its views family said by the views emitter onto INDEXER_VIEWS,
// read by the assigner's durable consumer; and its first-view tools of the three answered from the buckets at their marks.
func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
t.Helper()
marks, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.IndexerModule + "_" + views.MarksState, History: 1})
if err != nil {
t.Fatal(err)
}
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "INDEXER_VIEWS", Subjects: []string{"mesh.stream.mesh-delivery-indexer.event.views.>"}}); err != nil {
t.Fatal(err)
}
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, "INDEXER_VIEWS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
em := views.Emitter{Owner: delivery.IndexerModule, Subject: func(e string) string { return process.ModuleStreamSubject(delivery.IndexerModule, e) },
Publish: views.JetStreamPublish(b.js), Marks: marks}
for state, bucket := range indexerFirstViews {
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 5})
if err != nil {
t.Fatal(err)
}
b.buckets[bucket] = kv
if err := em.Start(b.ctx, state, kv, nil); err != nil {
t.Fatal(err)
}
late := indexerLate
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-indexer.call."+state+".>", func(m *nats.Msg) {
if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down indexer
select {
case <-late:
default:
_ = m.Respond([]byte(`{"error":"the indexer's runtime is not up"}`))
return
}
}
var args struct {
After string `json:"after"`
}
_ = json.Unmarshal(m.Data, &args)
a, err := views.FirstView(delivery.IndexerModule, state, bedSource{b.ctx, kv}, bedSource{b.ctx, marks}, args.After, time.Now())
out := map[string]any{"result": a, "node": "n0"}
if err != nil {
out = map[string]any{"error": err.Error()}
}
raw, _ := json.Marshal(out)
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
}
// firstViewsBeforeAnyNewFormState serves the assigner's first-view tools as its runtime of its own does (the tools bundle's
// viewTools over the bed's buckets, the env its manifest's bundle writes, the caller the runtime proves from the subject's
// last token), before any new-form state exists, and asks each from every reader granted and from the others.
func firstViewsBeforeAnyNewFormState(t *testing.T, b *bed, granted map[string][]string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
m, err := catalogue.Decode(raw)
if err != nil {
t.Fatal(err)
}
if len(m.BusState) != 0 {
t.Fatalf("the assigner asks bus state of the bus module: %+v", m.BusState)
}
var manifest struct {
Build struct {
Artifacts []struct {
Kind string `json:"kind"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
env := map[string]string{}
for _, a := range manifest.Build.Artifacts {
if a.Kind == "bundle" {
env = a.Env
}
}
state := func(name string) views.Source {
if name == views.MarksState {
return bedSource{b.ctx, b.buckets[marksBucket]}
}
return bedSource{b.ctx, b.buckets[viewedStates[name]]}
}
// The runtime: one bundle on a pair of pipes, a call on mesh.mod.<module>.call.<tool>.<caller token> handed to it with
// the caller in _meta, its answer replied as the tool runner replies ({result, error, node}).
inR, inW := io.Pipe()
outR, outW := io.Pipe()
t.Cleanup(func() { inW.Close(); outW.Close() })
go func() {
_ = stdio.ServeOn(delivery.AssignerModule, viewTools(func(k string) string { return env[k] }, state, time.Now), inR, outW)
}()
answers := make(chan map[string]any, 16)
go func() {
dec := json.NewDecoder(outR)
for {
var line map[string]any
if dec.Decode(&line) != nil {
return
}
if _, asked := line["method"]; !asked {
answers <- line
}
}
}()
one := make(chan struct{}, 1) // one call at a time on the pipe, so each answer is its call's
nc, err := nats.Connect(b.srv.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
sub, err := nc.Subscribe("mesh.mod."+delivery.AssignerModule+".call.*.*", func(msg *nats.Msg) {
one <- struct{}{}
defer func() { <-one }()
tokens := strings.Split(msg.Subject, ".")
var args map[string]any
_ = json.Unmarshal(msg.Data, &args)
call, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": "c", "method": "tools/call", "params": map[string]any{"name": tokens[4],
"arguments": args, "_meta": map[string]any{stdio.MetaCaller: strings.ReplaceAll(tokens[5], "~", ".")}}})
if _, err := inW.Write(append(call, '\n')); err != nil {
return
}
a := <-answers
res, _ := a["result"].(map[string]any)
content, _ := res["content"].([]any)
text := ""
if len(content) == 1 {
text, _ = content[0].(map[string]any)["text"].(string)
}
out := map[string]any{"result": json.RawMessage(text), "node": "n1"}
if isErr, _ := res["isError"].(bool); isErr || len(content) != 1 {
out = map[string]any{"error": text}
}
reply, _ := json.Marshal(out)
_ = msg.Respond(reply)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
ask := func(user, tool string) (views.Answer, error) {
return views.AskVia(func(key string, body any) (json.RawMessage, error) {
data, _ := json.Marshal(body)
msg, err := nc.Request("mesh.mod."+delivery.AssignerModule+".call."+tool+"."+strings.ReplaceAll(user, ".", "~"), data, 5*time.Second)
if err != nil {
return nil, err
}
return msg.Data, nil
}, tool)(b.ctx, "")
}
readers := []string{"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer", "n1.mesh-delivery-assigner", "n1.mesh-delivery-builder"}
for tool, allowed := range granted {
for _, user := range readers {
a, err := ask(user, tool)
switch {
case slices.Contains(allowed, user) && err != nil:
t.Errorf("%s's first view of %s before any new-form state: %v", user, tool, err)
case slices.Contains(allowed, user):
keys, _ := b.buckets[viewedStates[tool]].Keys(b.ctx)
if a.Owner != delivery.AssignerModule || a.State != tool || len(a.Values) != len(keys) {
t.Errorf("%s's first view of %s = %+v, the state holds %v", user, tool, a, keys)
}
case err == nil || !strings.Contains(err.Error(), "refused"):
t.Errorf("%s, granted no first view of %s, was answered %+v, %v", user, tool, a, err)
}
}
}
}
@@ -21,14 +21,16 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
)
@@ -57,14 +59,11 @@ var readBuckets = []string{
delivery.PartGrantsBucket,
delivery.AnswersBucket,
delivery.BuilderBuildsBucket,
delivery.ManifestsBucket,
delivery.ModulesBucket,
delivery.PresenceBucket,
delivery.HaveBucket,
provisions.ProvisionsBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
}
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
@@ -75,7 +74,15 @@ var readFamilies = []string{delivery.ComposedRead}
// (ADR 0567 §1), named after the assigner as every module state is.
var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerDifferencesBucket,
delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket,
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket}
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket,
engineReadsBucket}
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again.
const engineReadsState = "engine-reads"
// engineReadsBucket is that state's bucket.
const engineReadsBucket = delivery.AssignerModule + "_" + engineReadsState
// seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants
// them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind).
@@ -99,6 +106,9 @@ var keptStates = []announce.State{
{Name: health.FindingsState, Readable: true},
{Name: delivery.AssignerPoliciesState, Readable: true},
{Name: delivery.SeatHoldersState, Readable: true},
{Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go)
{Name: views.StatusState, Readable: true}, // its first views of the indexer's states (views.go)
{Name: engineReadsState, Readable: true},
}
// buckets is every bucket the assigner opened, by name.
@@ -151,9 +161,10 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
continue
}
if name == graph.Bucket && err != nil {
// The indexer's module graph (ADR 0614 §2, §3): without it no want waits for a verb it invokes until it is bound.
logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err)
if name == engineReadsBucket && err != nil {
// New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and
// assigner_engine_reads lists nothing.
logf("the bucket %s cannot be opened (%v): what each node's engine reads is kept in memory only until it starts again", name, err)
continue
}
if name == delivery.PartGrantsBucket && err != nil {
@@ -174,7 +185,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
func startsWithout(name string) bool {
switch name {
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket:
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, engineReadsBucket:
return true
}
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
@@ -209,7 +220,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
thisNode = account.Node
logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs))
findings := findingsOf(ctx, kvs, logger.Printf)
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, logger)
// Each change of the states its readers read is said on its views family (ADR 0615 §5, ADR 0619 §2); a watch of it
// that ends stops the program, as a watch of what a pass reads does.
viewErrs := make(chan error, len(viewedStates))
startViews(ctx, js, conn, account, kvs, findings, viewErrs, logger.Printf)
go func() {
select {
case <-ctx.Done():
case err := <-viewErrs:
logger.Printf("%v: the assigner stops, and is started again", err)
conn.Close()
}
}()
iv := &indexerViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf,
status: process.AwaitBucket(ctx, js, conn, firstViewsBucket, "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)}
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, logger)
}
// findingsOf is where the assigner's said conditions go as findings (issue 1952 WM6): its findings bucket, which the
@@ -244,7 +269,6 @@ var watched = []string{
delivery.PartGrantsBucket,
delivery.AnswersBucket,
delivery.BuilderBuildsBucket,
delivery.ModulesBucket,
delivery.PlacementBucket,
delivery.PlacementProposalsBucket,
delivery.PlacementCandidatesBucket,
@@ -259,12 +283,12 @@ var watched = []string{
delivery.SeatHoldersBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
engineReadsBucket,
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
// each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends.
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs buckets, say func(asks.Condition) error, findings assign.Findings, bus caller, logger *log.Logger) error {
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs buckets, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *indexerViews, logger *log.Logger) error {
w := newWorld()
dirty := make(chan struct{}, 1)
wake := func() {
@@ -275,6 +299,16 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
errs := make(chan error, len(watched)+2)
settled := make(chan struct{}, len(watched)+2)
// What each node's engine reads (ADR 0618 §3, §4): asked through the seat's verb, kept in the state engine-reads, read
// from the node's record where the engine does not answer (the fallback, said in the journal).
reads := &enginereads.Keeper{Fallback: w.recordReads, Changed: wake, Logf: logger.Printf}
if bus != nil {
reads.Call = bus.CallOn
}
if kv := kvs[engineReadsBucket]; kv != nil {
reads.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
reads.Delete = func(node string) error { return kv.Delete(ctx, node) }
}
watch := func(name string, opts ...jetstream.WatchOpt) error {
kw, err := kvs[name].WatchAll(ctx, opts...)
if err != nil {
@@ -308,6 +342,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
if e.Operation() == jetstream.KeyValuePut {
value = e.Value()
}
if name == engineReadsBucket {
reads.Heard(e.Key(), value, value == nil) // it wakes a pass when a statement changed
continue
}
w.putEntry(name, e.Key(), value, e.Revision(), e.Created())
wake()
}
@@ -363,8 +401,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
return err
}
}
if err := watch(delivery.ManifestsBucket, jetstream.MetaOnly()); err != nil {
return err
// The indexer's manifests, modules and module graph, through its first views and views family, never its buckets
// (DLV-F2r, ADR 0631): the first pass waits for the three answers, as it waits for every watch to hand what stands.
if iv != nil {
started += iv.start(ctx, w, wake, settled)
}
// The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no
@@ -396,7 +436,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
logger.Printf("the bucket %s is open and watched", a.name)
return watch(a.name)
}
for range started + 1 {
for range started {
select {
case <-ctx.Done():
return nil
@@ -405,7 +445,14 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
case <-settled:
}
}
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}}
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{},
reads: reads}
// The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer.
// Heard on the assigner's durable consumer on EVENTS, which the grants part makes from the manifest's consumes; where it
// is not there yet, a node is asked again only when it comes back online.
if js != nil {
hearReadsChanged(ctx, js, reads, findings, logger.Printf)
}
if bus != nil && kvs[delivery.AssignerPutsBucket] != nil {
p.acts = newPutActs(bus, wake, logger.Printf)
}
@@ -456,13 +503,13 @@ var PutsReopenEvery = time.Minute
// sayVia publishes a condition on the operator-channel seat's one subject for conditions, under the assigner's own name
// (ADR 0437 clause 9).
func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) error {
return func(c asks.Condition) error {
subject, err := asks.ConditionSubject(delivery.AssignerModule)
func sayVia(ctx context.Context, js jetstream.JetStream) func(oc.Condition) error {
return func(c oc.Condition) error {
subject, err := occ.ConditionSubject(delivery.AssignerModule)
if err != nil {
return err
}
data, err := asks.EncodeCondition(c)
data, err := occ.EncodeCondition(c)
if err != nil {
return err
}
@@ -470,3 +517,39 @@ func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) er
return err
}
}
// hearReadsChanged hears the node-engine seat's event manifest-reads-changed on the assigner's durable consumer on EVENTS,
// and asks the node it names again. Any other event the consumer hands is acknowledged and left.
func hearReadsChanged(ctx context.Context, js jetstream.JetStream, reads *enginereads.Keeper, findings assign.Findings, logf func(string, ...any)) {
account := process.Account{Node: thisNode, Module: delivery.AssignerModule}
w := notHeardWriter(findings, logf)
cons, err := account.EventConsumer(ctx, js, process.EventsStream)
if err != nil {
w.NotHeard(process.EventsStream, enginereads.Event+" is not heard until the assigner starts again (a node is asked again when it comes back online)",
fmt.Errorf("the consumer %s on %s does not exist yet: %w", account.EventConsumerName(), process.EventsStream, err))
return
}
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
switch {
case !ours:
case err != nil:
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
reads.AskNode(node, enginereads.Event+" that does not read")
default:
reads.AskNode(node, enginereads.Event)
}
}, logf)
if err != nil {
w.NotHeard(process.EventsStream, enginereads.Event+" is not heard until the assigner starts again", err)
return
}
w.Heard(process.EventsStream)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
@@ -18,14 +18,20 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
)
// 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them
// (`<module>.<state>` read as the bucket `<module>_<state>`), so it is granted what it reads and reads nothing else.
func TestOpensWhatItsManifestNames(t *testing.T) {
// Review round 2: the assigner's views emitter writes its finding view-unsaid (Emitter.Findings set) when it keeps findings.
findings := assign.Findings{Put: func(string, []byte) error { return nil }, Delete: func(string) error { return nil }}
if viewsEmitter(nil, process.Account{}, nil, findings, nil, t.Logf).Findings == nil {
t.Error("the assigner's views emitter writes no view-unsaid finding")
}
// 1297/B2a: without a credential the program does not start, and says why.
if err := run(context.Background(), func(string) string { return "" }, io.Discard); err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") {
t.Errorf("run without a credential = %v", err)
@@ -57,7 +63,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
for _, pair := range []struct {
what string
code, want []string
}{{"reads", readBuckets, reads}, {"states", ownBuckets, states}} {
}{{"reads", readBuckets, reads}, {"states", append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket), states}} { // the marks are bound by the views family (views.go)
got, want := slices.Sorted(slices.Values(pair.code)), slices.Sorted(slices.Values(pair.want))
if !reflect.DeepEqual(got, want) {
t.Errorf("the program's %s are %v, the manifest's %v", pair.what, got, want)
@@ -67,7 +73,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
for _, s := range keptStates {
kept = append(kept, m.Module+"_"+s.Name)
}
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(ownBuckets)); !reflect.DeepEqual(got, want) {
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket))); !reflect.DeepEqual(got, want) {
t.Errorf("the program announces %v, keeps %v", got, want)
}
// ADR 0546: whatever keeps the puts' bucket from opening (not there, not granted, no answer in time), the assigner opens
@@ -113,7 +119,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
}
kvs[delivery.AssignerPutsBucket].(*watchKV).closed = true
kvs[delivery.AssignerWantsBucket].(*watchKV).wrote = wrote
go follow(ctx, nil, nil, kvs, func(asks.Condition) error { return nil }, assign.Findings{}, nil, log.New(io.Discard, "", 0))
go follow(ctx, nil, nil, kvs, func(oc.Condition) error { return nil }, assign.Findings{}, nil, nil, log.New(io.Discard, "", 0))
select {
case key := <-wrote:
if key != "want.ace.node" {
@@ -10,11 +10,13 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-assigner/internal/assign"
)
@@ -27,7 +29,7 @@ type passer struct {
sdk string
// say publishes a condition to the operator; said is, by condition key and state, what this process has said, so a
// pass says a condition once and a restart says it again (the router keeps one per key).
say func(asks.Condition) error
say func(oc.Condition) error
said map[string]bool
// findings is the assigner's findings bucket (its said conditions, ADR 0564 §2).
findings assign.Findings
@@ -68,6 +70,40 @@ type passer struct {
// saidWaits is the last said of the seat's wait for its states (sayWaits).
saidWaits string
intended map[string]map[string]map[string]bool
// reads keeps what each node's engine states it reads, asked through the node-engine seat's verb (ADR 0618 §3); nil reads
// each node's record, as the rules did before the verb. online is, by node, whether the last pass saw it online.
reads *enginereads.Keeper
online map[string]bool
}
// engineReads sets what the rules read of each node's engine (ADR 0618 §3, §4): the statement the verb manifest-reads
// answered, or its record where the verb was not answered (the fallback) or not asked yet. A node is asked when the
// assigner first hears of it, and again when it comes back online, since an engine built before the event says none.
func (p *passer) engineReads(n assign.Nodes) {
if p.reads == nil {
return
}
known := p.w.knownNodes()
first := p.online == nil
for _, node := range known {
switch {
case first:
p.reads.AskNode(node, "the assigner started")
case !p.reads.Known(node):
p.reads.AskNode(node, "first heard of")
case !first && n.Online[node] && !p.online[node]:
p.reads.AskNode(node, "back online")
}
}
p.online = maps.Clone(n.Online)
if p.online == nil {
p.online = map[string]bool{}
}
for node, reads := range p.reads.Reads(known) {
if p.reads.Known(node) {
n.Reads[node] = reads
}
}
}
func (p *passer) clock() time.Time {
@@ -107,7 +143,7 @@ func (p *passer) nodeWants(ctx context.Context) {
// the hold of a fingerprint whose health check failed. The assigner compares its wants with nothing the controller
// sends: the differences bucket is kept read-only with its history (ADR 0539 §8).
func (p *passer) wants(ctx context.Context) {
get := kvGetter(p.kvs[delivery.ManifestsBucket])
get := p.w.getter(delivery.ManifestsBucket) // the indexer's manifests, as its first view and views family say them
assigned := p.w.assigned()
trunk := p.w.trunk()
manifests := map[string]catalogue.Manifest{}
@@ -120,6 +156,7 @@ func (p *passer) wants(ctx context.Context) {
}
}
nodes := p.w.nodes(manifests, assigned)
p.engineReads(nodes)
nodesOf := map[string][]string{}
for node, modules := range assigned {
for _, m := range modules {
@@ -162,7 +199,7 @@ func (p *passer) wants(ctx context.Context) {
for name := range names {
fp := trunk[name].FP2
name := name
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (delivery.ComposedDigest, bool) {
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
@@ -517,7 +554,7 @@ func (p *passer) holdAsk(ctx context.Context, module, fp string, fresh *delivery
asking := int(now.Unix())
h := p.heldHold(module, fp, fresh)
a := assign.HoldAsk(key, module, fp, h.Node, h.Words, asking, now)
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
}
// lift takes away a hold the assigner lifted by itself, since every node that runs the module runs the held fingerprint healthy
@@ -700,7 +737,7 @@ func (p *passer) restoreAsk(ctx context.Context, module, node, fp string) {
}
words := p.heldHold(module, fp, nil).Words
a := assign.RestoreAsk(key, node, module, fp, words, time.Now())
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
}
// answeredWith says whether the teller wrote an answer to the ask at key as it stands now and the option chosen is option. The
@@ -712,12 +749,12 @@ func (p *passer) answeredWith(key, option string) bool {
if !ok || a.Option != option {
return false
}
ask, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
_, ask, err := assign.DecodeAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
if err != nil {
return false
}
id, _, _ := strings.Cut(a.Warrant, "@")
return id == ask.Ask.ID
return id == ask.ID
}
// clearHeld clears, once, the condition of a hold the operator released.
@@ -742,7 +779,7 @@ func (p *passer) recordAsk(ctx context.Context, module, fp string, need assign.R
return
}
a := assign.RecordAsk(key, need.Node, module, fp, need, time.Now())
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
}
// backups sees to the modules held on a node because the backup before an apply failed (the have's phase held, ADR 0539 §16):
@@ -773,7 +810,7 @@ func (p *passer) backups(ctx context.Context) {
if err != nil {
continue
}
current, there := delivery.DecodeAssignerAsk(akey, p.w.read(delivery.AssignerAsksBucket)[akey])
current, currentAsk, there := assign.DecodeAsk(akey, p.w.read(delivery.AssignerAsksBucket)[akey])
if there != nil || assign.BackupAskDigest(current) != want.Composed {
after := -1
if there == nil {
@@ -783,7 +820,7 @@ func (p *passer) backups(ctx context.Context) {
continue
}
answer, answered := p.w.answer(akey)
if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != current.Ask.ID ||
if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != currentAsk.ID ||
(answer.Option != delivery.BackupAgain && answer.Option != delivery.BackupSkip) {
continue // not answered yet: the ask stands
}
@@ -794,7 +831,7 @@ func (p *passer) backups(ctx context.Context) {
if wkey, err := delivery.WantKey(node, module); err == nil {
w := want
p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(w) })
p.answeredAt[current.Ask.ID] = haveChanges
p.answeredAt[currentAsk.ID] = haveChanges
}
continue
}
@@ -802,8 +839,8 @@ func (p *passer) backups(ctx context.Context) {
continue
}
// The answer is in the want. The node retried and the backup failed again when the have took another value since.
if seen, known := p.answeredAt[current.Ask.ID]; !known {
p.answeredAt[current.Ask.ID] = haveChanges // after a restart: wait for the next failure
if seen, known := p.answeredAt[currentAsk.ID]; !known {
p.answeredAt[currentAsk.ID] = haveChanges // after a restart: wait for the next failure
} else if haveChanges > seen {
p.backupAsk(ctx, akey, node, module, want, have.Detail, assign.NextBackupAsking(current.Asking))
}
@@ -813,7 +850,7 @@ func (p *passer) backups(ctx context.Context) {
// backupAsk writes the backup ask at its asking and raises the condition of the failed backup, once.
func (p *passer) backupAsk(ctx context.Context, key, node, module string, want delivery.Want, words string, asking int) {
a := assign.BackupAsk(key, node, module, want.FP, want.Composed, words, asking, time.Now())
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
c := assign.BackupCondition(node, module, want.FP, words, true)
if p.say == nil || p.said[c.Key] {
return
@@ -13,10 +13,14 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
@@ -91,7 +95,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) {
held := &delivery.Held{Node: "ace", Words: "health failed"}
first := newPasser(kvs, time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC))
first.holdAsk(context.Background(), "web", fpTest, held)
a1, err := delivery.DecodeAssignerAsk(key, asksKV.values[key])
a1, err := decodeAsk(key, asksKV.values[key])
if err != nil {
t.Fatal(err)
}
@@ -101,7 +105,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) {
}
again := newPasser(kvs, time.Date(2026, 10, 25, 2, 0, 0, 0, time.UTC)) // started again: it remembers nothing
again.holdAsk(context.Background(), "web", fpTest, held)
a2, err := delivery.DecodeAssignerAsk(key, asksKV.values[key])
a2, err := decodeAsk(key, asksKV.values[key])
if err != nil {
t.Fatal(err)
}
@@ -125,12 +129,12 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
p := newPasser(buckets{}, time.Now())
put := func(asking int) string {
a := assign.HoldAsk(key, "web", fpTest, "ace", "health failed", asking, time.Now())
v, err := delivery.EncodeAssignerAsk(key, a)
v, err := assign.EncodeAsk(key, a)
if err != nil {
t.Fatal(err)
}
p.w.put(delivery.AssignerAsksBucket, key, v)
return a.Ask.ID
return mustAskID(a)
}
first := put(100)
v, _ := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: first + "@2026-10-25T01:00:00Z", Words: "the operator"})
@@ -166,6 +170,72 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 {
t.Errorf("the nodes' reads are %v", n.Reads)
}
// ADR 0618 §3: with the verb asked, a node's reads are what its engine answered; where the engine answers it does not
// serve the verb, its record is read once in its place (the fallback, issue 774); a node asked again when it comes
// back online, since an engine built before the event says none.
asked := map[string]int{}
call := func(node, key string, _ any) (json.RawMessage, error) {
asked[node]++
if node == "g14" || key != enginereads.Invoke {
return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"}
}
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}
p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}}
on, _ := delivery.EncodePresence(delivery.Presence{Online: false})
w.put(delivery.PresenceBucket, "ace", on)
n = w.nodes(nil, nil)
p.engineReads(n)
if !slices.Equal(n.Reads["ace"], []string{"form:new"}) || len(n.Reads["g14"]) != 0 || asked["ace"] != 1 {
t.Errorf("with the verb the nodes' reads are %v (asked %v)", n.Reads, asked)
}
if s := p.reads.Statements(); s["ace"].From != enginereads.FromVerb || s["g14"].From != enginereads.FromRecord {
t.Errorf("the statements are %+v", s)
}
p.engineReads(w.nodes(nil, nil))
on, _ = delivery.EncodePresence(delivery.Presence{Online: true})
w.put(delivery.PresenceBucket, "ace", on)
p.engineReads(w.nodes(nil, nil))
if asked["ace"] != 2 {
t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"])
}
// The contract carries no checks, so its reader judges what the engine says (mesh-contracts seats/nodeengine): an
// answer or an event that does not hold is no statement, and an event's node is its subject's last token.
now := time.Now().Format(time.RFC3339Nano)
for _, row := range []struct {
name string
raw string
stays bool
}{
{"a whole answer", `{"v":1,"node":"ace","manifest-reads":["form:new"],"stated-at":"` + now + `"}`, true},
{"an engine stating nothing", `{"v":1,"node":"ace","stated-at":"` + now + `"}`, true},
{"another layout", `{"v":2,"node":"ace","stated-at":"` + now + `"}`, false},
{"no node", `{"v":1,"node":"","stated-at":"` + now + `"}`, false},
{"a node that is no token", `{"v":1,"node":"a.ce","stated-at":"` + now + `"}`, false},
{"another node's answer", `{"v":1,"node":"g14","stated-at":"` + now + `"}`, false},
{"an empty capability", `{"v":1,"node":"ace","manifest-reads":[""],"stated-at":"` + now + `"}`, false},
{"a time that is no time", `{"v":1,"node":"ace","stated-at":"yesterday"}`, false},
} {
s := enginereads.Ask(func(string, string, any) (json.RawMessage, error) { return json.RawMessage(row.raw), nil }, "ace", time.Now())
if (s.From == enginereads.FromVerb) != row.stays || !s.Answered {
t.Errorf("answer %s: %+v", row.name, s)
}
}
for _, row := range []struct {
name, subject, raw string
ours, fails bool
}{
{"the event", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"returned"}`, true, false},
{"another reason", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"bored"}`, true, true},
{"another node in the body", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"g14","at":"` + now + `","why":"started"}`, true, true},
{"a time that is no time", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"x","why":"started"}`, true, true},
{"another event", "mesh.seat.node-engine.event.done.ace", `{}`, false, false},
} {
_, ours, err := enginereads.NodeOf(row.subject, []byte(row.raw))
if ours != row.ours || (err != nil) != row.fails {
t.Errorf("event %s: ours %v, %v", row.name, ours, err)
}
}
})
}
@@ -328,8 +398,8 @@ func putOfTheBus(t *testing.T) {
if got.Step != delivery.PutPerson || got.Ask != akey {
t.Fatalf("composed, the put stands at %s (%s), ask %q", got.Step, got.Words, got.Ask)
}
a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
if err != nil || a.Ask.Options[0].Level != asks.Approve {
a, err := decodeAsk(akey, asksKV.values[akey])
if err != nil || a.Ask.Options[0].Level != occ.Approve {
t.Fatalf("the ask is %+v, %v; want a yes that approves", a, err)
}
if _, there := wantsKV.values["want.novox.nats"]; there {
@@ -377,7 +447,7 @@ func putOfTheBus(t *testing.T) {
t.Fatal(err)
}
again := step()
a2, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
a2, err := decodeAsk(akey, asksKV.values[akey])
if again.Step != delivery.PutPerson || err != nil || a2.Ask.ID == a.Ask.ID {
t.Fatalf("put again, it stands at %s (%s); its ask %q, %v; the first ask was %q", again.Step, again.Words, a2.Ask.ID, err, a.Ask.ID)
}
@@ -411,9 +481,9 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
aWantWaitsOnTheVerbEdgesTheGraphKeeps(t)
t.Run("gathers", testThePassGathersTheModulesOnNeitherList)
p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC))
var said []asks.Condition
var said []oc.Condition
failing := true
p.say = func(c asks.Condition) error {
p.say = func(c oc.Condition) error {
if failing {
failing = false
return errors.New("the channel is away")
@@ -428,7 +498,7 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
p.sayNeither(one, nil, nil) // the say fails
p.sayNeither(one, nil, nil)
p.sayNeither(one, nil, nil)
if len(said) != 1 || said[0].State != asks.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" {
if len(said) != 1 || said[0].State != occ.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" {
t.Fatalf("said = %+v, want one raised condition of screen-lock on g14", said)
}
if !strings.Contains(strings.Join(logged, "\n"), "screen-lock") {
@@ -437,7 +507,7 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
wants := map[string]string{"g14.screen-lock": ""}
p.sayNeither(map[string]neitherOne{}, nil, wants)
p.sayNeither(map[string]neitherOne{}, nil, wants)
if len(said) != 2 || said[1].State != asks.StateCleared || said[1].Key != said[0].Key {
if len(said) != 2 || said[1].State != occ.StateCleared || said[1].Key != said[0].Key {
t.Errorf("said = %+v, want the condition cleared once under its key", said)
}
if last := logged[len(logged)-1]; last != "screen-lock on g14 has a want again" {
@@ -447,11 +517,11 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
// on neither list by a rule as any module (ADR 0601 §1), nothing for the operator to do.
bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Rule: delivery.RuleGrant, Words: "no credential"}}}
p.sayNeither(bus, nil, nil)
if c := said[len(said)-1]; c.State != asks.StateRaised || !strings.HasPrefix(c.Explanation, asks.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") {
if c := said[len(said)-1]; c.State != occ.StateRaised || !strings.HasPrefix(c.Explanation, occ.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") {
t.Errorf("the bus's neither condition = %+v", c)
}
p.sayNeither(map[string]neitherOne{}, nil, map[string]string{"novox.nats": "it is held, and its held condition says so"})
if c := said[len(said)-1]; c.State != asks.StateCleared || strings.Contains(c.Resolved, "has a want") {
if c := said[len(said)-1]; c.State != occ.StateCleared || strings.Contains(c.Resolved, "has a want") {
t.Errorf("a module that left neither list with no want was cleared as %+v", c)
}
if last := logged[len(logged)-1]; last != "nats on novox is no longer on neither list and has no want: it is held, and its held condition says so" {
@@ -513,16 +583,16 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
}
// 1496, ADR 0557 §1: a failure that is the node's own (its fingerprint healthy elsewhere, not held) raises the node's condition
// once, naming the node, the module, F and the have's words, and clears it once when it ends.
var conds []asks.Condition
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
var conds []oc.Condition
p.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
failing := map[string]assign.NodeFailure{"shanks": {Others: []string{"ace"}, Words: "health failed, phase running: w"}}
for i, c := range []struct {
failed map[string]assign.NodeFailure
state string // the condition said in this pass, "" for none
}{
{failing, asks.StateRaised},
{failing, occ.StateRaised},
{failing, ""},
{nil, asks.StateCleared},
{nil, occ.StateCleared},
{nil, ""},
} {
conds = nil
@@ -530,7 +600,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
switch {
case c.state == "" && len(conds) != 0, c.state != "" && (len(conds) != 1 || conds[0].State != c.state):
t.Errorf("node failed, pass %d said %+v, want %q", i, conds, c.state)
case c.state == asks.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" ||
case c.state == occ.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" ||
!strings.Contains(conds[0].Explanation, "failed on shanks (health failed, phase running: w), while it runs healthy on ace")):
t.Errorf("node failed, raised %+v", conds[0])
}
@@ -541,7 +611,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
p.sayNodeFailed("rofi", fpTest, []string{"shanks"}, assign.Nodes{}, assign.Outcome{NodeFailed: failing}, false)
p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false)
p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false)
if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" {
if len(conds) != 2 || conds[1].State != occ.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" {
t.Errorf("a node leaving the module said %+v, want one raise and one clear", conds)
}
known := assign.Nodes{Online: map[string]bool{"ace": true, "g14": false},
@@ -557,11 +627,11 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
conds = nil
fresh := newPasser(buckets{}, time.Date(2026, 10, 26, 22, 0, 0, 0, time.UTC))
fresh.logf = func(string, ...any) {}
fresh.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
fresh.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
fresh.sayNodeFailed("rofi", fpTest, nil, c.known, assign.Outcome{}, true)
var keys []string
for _, cond := range conds {
if cond.State == asks.StateCleared {
if cond.State == occ.StateCleared {
keys = append(keys, cond.Key)
}
}
@@ -578,8 +648,8 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
t.Run("a held module", testAHeldModuleRaisesNoNeitherAndClearsAStandingOne)
wantsKV := &memKV{values: map[string][]byte{}}
p := newPasser(buckets{delivery.AssignerWantsBucket: wantsKV, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now())
var said []asks.Condition
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
var said []oc.Condition
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
p.w.put(delivery.ModulesBucket, "web", mod)
placeOn(t, p.w, "g14", "web")
@@ -593,7 +663,7 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
p.wants(context.Background())
cleared := map[string]bool{}
for _, c := range said {
if c.State != asks.StateCleared {
if c.State != occ.StateCleared {
t.Errorf("the first pass raised %+v", c)
}
cleared[c.Key] = true
@@ -614,10 +684,10 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
// neither condition for it, a neither condition this process raised for it before is cleared, and so is one a previous process
// raised (the first pass after a start), each once.
func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
setup := func() (*passer, *[]asks.Condition) {
setup := func() (*passer, *[]oc.Condition) {
p := newPasser(buckets{delivery.AssignerWantsBucket: &memKV{values: map[string][]byte{}}, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now())
said := &[]asks.Condition{}
p.say = func(c asks.Condition) error { *said = append(*said, c); return nil }
said := &[]oc.Condition{}
p.say = func(c oc.Condition) error { *said = append(*said, c); return nil }
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
p.w.put(delivery.ModulesBucket, "systemd-resolved", mod)
placeOn(t, p.w, "shanks", "systemd-resolved")
@@ -631,12 +701,12 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
return p, said
}
key := assign.NeitherCondition("shanks", "systemd-resolved", assign.Holding{}, false).Key
neithers := func(said []asks.Condition) (raised, cleared int) {
neithers := func(said []oc.Condition) (raised, cleared int) {
for _, c := range said {
if c.Key != key {
continue
}
if c.State == asks.StateRaised {
if c.State == occ.StateRaised {
raised++
} else {
cleared++
@@ -695,8 +765,8 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
var said []asks.Condition
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
var said []oc.Condition
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
for _, key := range all {
k, _ := delivery.ParseAnyAskKey(key)
var a delivery.AssignerAsk
@@ -710,7 +780,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
default:
a = assign.BackupAsk(key, k.Node, k.Module, k.FP, digOld, "w", 2, time.Now())
}
v, err := delivery.EncodeAssignerAsk(key, a)
v, err := assign.EncodeAsk(key, a)
if err != nil {
t.Fatal(err)
}
@@ -724,13 +794,13 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
heldOld, _ := delivery.HeldKey("web", fpOld)
wantsKV.values[heldOld] = []byte("x")
p.w.put(delivery.AssignerWantsBucket, heldOld, []byte("x"))
firstAsk, _ := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld])
firstAsk, _ := decodeAsk(holdOld, asksKV.values[holdOld])
p.staleAsks(context.Background(), c.trunk)
// the held record and condition of a fingerprint whose hold ask went are taken away with it
heldOld, _ = delivery.HeldKey("web", fpOld)
_, heldThere := wantsKV.values[heldOld]
cleared := slices.ContainsFunc(said, func(c asks.Condition) bool {
return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == asks.StateCleared
cleared := slices.ContainsFunc(said, func(c oc.Condition) bool {
return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == occ.StateCleared
})
if gone := slices.Contains(c.gone, holdOld); !heldThere || cleared != gone {
t.Errorf("hold ask of the old fingerprint gone = %v: its held record stays = %v (must), its condition cleared = %v", gone, heldThere, cleared)
@@ -747,8 +817,8 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
p.now = func() time.Time { return time.Now().Add(time.Hour) }
p.holdAsk(context.Background(), "web", fpOld, nil)
p.heldCondition(context.Background(), "web", fpOld, nil, false)
again, err := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld])
raised := slices.ContainsFunc(said, func(c asks.Condition) bool { return c.State == asks.StateRaised })
again, err := decodeAsk(holdOld, asksKV.values[holdOld])
raised := slices.ContainsFunc(said, func(c oc.Condition) bool { return c.State == occ.StateRaised })
if err != nil || again.Ask.ID == firstAsk.Ask.ID || !raised {
t.Errorf("the trunk returned to the held fingerprint: ask %q (first %q, %v), condition raised again = %v", again.Ask.ID, firstAsk.Ask.ID, err, raised)
}
@@ -759,9 +829,9 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
t.Run("a clear that failed to be said is said at the next pass, and the ask goes after it", func(t *testing.T) {
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
var said []asks.Condition
var said []oc.Condition
fail := true
p.say = func(c asks.Condition) error {
p.say = func(c oc.Condition) error {
if fail {
fail = false
return errors.New("router unreachable")
@@ -770,7 +840,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
return nil
}
a := assign.HoldAsk(holdOld, "web", fpOld, "novox", "w", 1, time.Now())
v, err := delivery.EncodeAssignerAsk(holdOld, a)
v, err := assign.EncodeAsk(holdOld, a)
if err != nil {
t.Fatal(err)
}
@@ -781,7 +851,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
t.Fatalf("after a failed clear: ask left = %v, conditions said = %d; want the ask kept and nothing said", there, len(said))
}
p.staleAsks(context.Background(), trunkOf(fpNew))
if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != asks.StateCleared {
if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != occ.StateCleared {
t.Errorf("at the next pass: ask left = %v, conditions said = %v; want the ask gone and the clear said", there, said)
}
})
@@ -970,7 +1040,7 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
if wantsKV.puts != wantPuts {
t.Fatalf("a want put unchanged reached the bucket")
}
a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
a, err := decodeAsk(akey, asksKV.values[akey])
if err != nil {
t.Fatal(err)
}
@@ -1007,8 +1077,8 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
t.Fatalf("calls = %d, said = %v", len(bus.calls), said)
}
var conds []asks.Condition
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
var conds []oc.Condition
p.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
fails := 1
p.findings.Keys = func() ([]string, error) {
if fails > 0 {
@@ -1020,7 +1090,7 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
p.clearStaleCalls()
p.clearStaleCalls()
p.clearStaleCalls()
if len(conds) != 2 || conds[0].State != asks.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
if len(conds) != 2 || conds[0].State != occ.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
conds[1].Key != "mesh-delivery-assigner.controller-pin.laptop.db" {
t.Fatalf("the old conditions cleared = %+v", conds)
}
@@ -1083,3 +1153,22 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
}
}
}
// decodedAsk is an asks-state value with the router's ask it carries decoded (mesh-sdk keeps it as raw JSON).
type decodedAsk struct {
Asking int
Ask oc.Ask
}
func decodeAsk(key string, data []byte) (decodedAsk, error) {
a, ask, err := assign.DecodeAsk(key, data)
return decodedAsk{Asking: a.Asking, Ask: ask}, err
}
func mustAskID(a delivery.AssignerAsk) string {
ask, err := assign.AskOf(a)
if err != nil {
panic(err)
}
return ask.ID
}
@@ -10,7 +10,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
@@ -99,7 +99,7 @@ func (p *passer) puts(ctx context.Context) {
}
assigned := p.w.assigned()
trunk := p.w.trunk()
get := kvGetter(p.kvs[delivery.ManifestsBucket])
get := p.w.getter(delivery.ManifestsBucket) // the indexer's manifests, as its first view and views family say them
wanted := p.w.wantsOf()
modules := make([]string, 0, len(all))
for m := range all {
@@ -195,14 +195,15 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo
w.Recorded = policy == catalogue.PolicyRecord
if akey, err := delivery.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey {
// Only this put's own ask counts: an ask (and its answer) of an earlier put under the same key is another asking.
if a, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) {
if a, ask, err := assign.DecodeAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) {
switch {
case p.answeredWith(akey, assign.OptionYes):
w.Answer = assign.OptionYes
case p.answeredWith(akey, assign.OptionNo):
w.Answer = assign.OptionNo
default:
w.AskExpired = !a.Ask.Expires.IsZero() && w.Now.After(a.Ask.Expires)
expires := assign.ExpiresAt(ask)
w.AskExpired = !expires.IsZero() && w.Now.After(expires)
}
}
}
@@ -242,13 +243,13 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu
// The put's own ask, written again over one an earlier put left under the key (its asking differs), so the teller
// raises it anew under its own id.
akey := next.Put.Ask
current, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey))
current, _, err := assign.DecodeAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey))
if err != nil || current.Asking != assign.PutAsking(next.Put) {
a, err := assign.PutAsk(akey, node, module, next.Put, *w.Composed, w.Bus, w.Now)
if err != nil {
p.logf("the ask about putting %s on %s is not written: %v", module, node, err)
} else {
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) })
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
}
}
}
@@ -257,15 +258,15 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu
}
}
// performs checks that the act about to be performed is the one the yes of the ask at key bound (asks.Option.Performs).
func (p *passer) performs(key string, act asks.Act) error {
a, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
// performs checks that the act about to be performed is the one the yes of the ask at key bound (occ.Performs).
func (p *passer) performs(key string, act occ.Act) error {
_, ask, err := assign.DecodeAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
if err != nil {
return fmt.Errorf("the ask %s cannot be read: %w", key, err)
}
for _, o := range a.Ask.Options {
for _, o := range ask.Options {
if o.ID == assign.OptionYes {
return o.Performs(act)
return occ.Performs(o, act)
}
}
return fmt.Errorf("the ask %s offers no yes", key)
@@ -15,7 +15,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -124,7 +124,7 @@ func (p *passer) sayWaits(unbound []string) {
if p.say == nil || p.said[c.Key] && p.saidWaits == state {
return
}
if c.State == asks.StateCleared && p.saidWaits == "" {
if c.State == occ.StateCleared && p.saidWaits == "" {
return // nothing was raised by this process
}
if err := p.say(c); err != nil {
@@ -243,11 +243,16 @@ func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey,
p.logf("the ask about the proposal %s is not written: %v", prop.ID, err)
return prop, false
}
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) })
if pk, err := delivery.PulseAskKey(prop.ID); err == nil {
p.pulse(ctx, pk, a.Ask.Expires.Sub(p.clock()))
placed, err := assign.AskOf(a)
if err != nil {
p.logf("the ask about the proposal %s is not written: %v", prop.ID, err)
return prop, false
}
prop.State, prop.Ask = delivery.ProposalAsked, a.Ask.ID
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
if pk, err := delivery.PulseAskKey(prop.ID); err == nil {
p.pulse(ctx, pk, assign.ExpiresAt(placed).Sub(p.clock()))
}
prop.State, prop.Ask = delivery.ProposalAsked, placed.ID
return prop, true
}
@@ -127,7 +127,8 @@ func TestTheSeatsVerbs(t *testing.T) {
}
}
for _, stream := range raw0.Streams {
if !reads["stream "+stream.Name] {
// The views family is no seat's stream: the readers of the assigner's states follow it (ADR 0615 §5).
if stream.Name != viewsStream && !reads["stream "+stream.Name] {
t.Errorf("no verb reads the seat's stream %s", stream.Name)
}
}
@@ -14,6 +14,8 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
)
// The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR
@@ -21,6 +23,9 @@ import (
// every step as the event put. They are the module's own tools; the seat mesh-placement's verbs are served beside them
// (seat_tools.go, ADR 0567 §10).
// engineReadsTool lists what each node's engine reads, as the assigner uses it, and asks one node's engine again.
const engineReadsTool = "assigner_engine_reads"
// putsState is the assigner's state of puts, as the runtime names a module's own state.
const putsState = "puts"
@@ -358,6 +363,9 @@ func serveTools() error {
})
wants.watched = stdio.NewWatchedStates() // the large states read whole from one watched copy (ADR 0580 clause 3)
tools = append(tools, wants.tools()...)
tools = append(tools, viewTools(os.Getenv, keptSource, time.Now)...)
// What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it.
tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now))
return stdio.Serve("", tools)
}
@@ -17,8 +17,8 @@ import (
// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's
// manifests, as a provider is counted (ADR 0603 §5).
// moduleGraph is the module graph as the indexer last wrote it; a key that does not decode is left out, so its module
// waits on no verb.
// moduleGraph is the module graph as the indexer's first view and views family last said it (ADR 0631); a key that
// does not decode is left out, so its module waits on no verb.
func (p *passer) moduleGraph() graph.Graph {
g := graph.Graph{}
for key, raw := range p.w.read(graph.Bucket) {
@@ -0,0 +1,235 @@
package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"time"
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/heard"
"mesh-delivery-assigner/internal/assign"
)
// viewsStream is the assigner's views family on its old streams section, beside placement (ADR 0619 §2, ADR 0615 §5),
// as its manifest's `streams` names it; indexerViewsStream is the indexer's, as the indexer's manifest names it.
const (
viewsStream = "assigner-views"
indexerViewsStream = "indexer-views"
)
// marksBucket is the assigner's state of what its views family said each state up to.
var marksBucket = delivery.AssignerModule + "_" + views.MarksState
// viewedTool is one tool over one state of the assigner's.
type viewedTool struct{ tool, state, words string }
// firstViewTools are the assigner's first-view tools (ADR 0621 §1): each answers only its callers list
// (views.CallersEnv), which the manifest writes.
var firstViewTools = []viewedTool{
{"placement", delivery.PlacementState, "where each module is placed: the seat mesh-placement's placement, by module."},
{"seat-holders", delivery.SeatHoldersState, "who holds which seat on which node."},
{"wants", "wants", "what each node should run: want.<node>.<module>, its fingerprint and the rule that gave it."},
}
// readTools are the assigner's ordinary read tools, one per state its readers read (design 25 § 12.2).
var readTools = []viewedTool{
{"assigner_read_placement", delivery.PlacementState, "where each module is placed, by module."},
{"assigner_read_seat_holders", delivery.SeatHoldersState, "who holds which seat on which node."},
{"assigner_read_wants", "wants", "what each node should run: want.<node>.<module>."},
{"assigner_read_findings", health.FindingsState, "the assigner's findings, which the condition keeper raises and clears."},
{"assigner_read_asks", "asks", "the asks the assigner put to a person, and their answers."},
{"assigner_read_differences", "differences", "every difference between a want and what a node runs."},
}
// viewedStates are the states the views family says, each by its bucket: the six its readers read.
var viewedStates = map[string]string{
delivery.PlacementState: delivery.PlacementBucket,
delivery.SeatHoldersState: delivery.SeatHoldersBucket,
"wants": delivery.AssignerWantsBucket,
health.FindingsState: assign.FindingsBucket,
"asks": delivery.AssignerAsksBucket,
"differences": delivery.AssignerDifferencesBucket,
}
// viewTools are the assigner's first-view and read tools, reading only its own states, so they answer as soon as its
// runtime of its own is connected (ADR 0621 §3).
func viewTools(getenv func(string) string, state func(string) views.Source, now func() time.Time) []stdio.Tool {
marks := state(views.MarksState)
var out []stdio.Tool
for _, t := range firstViewTools {
out = append(out, views.FirstViewTool(delivery.AssignerModule, t.tool, t.state, t.words, views.ParseCallers(getenv(views.CallersEnv(t.tool))), state(t.state), marks, now))
}
for _, t := range readTools {
out = append(out, views.ReadTool(delivery.AssignerModule, t.tool, t.state, t.words, state(t.state), marks, now))
}
// Its own first views, of the indexer's manifests, modules and graph (DLV-F3r, ADR 0631 §2): read, and asked again by hand.
return append(out, views.StatusTools(delivery.AssignerModule, "assigner_first_views", "assigner_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, now)...)
}
func keptSource(name string) views.Source { return views.KeptSource{State: stdio.State(name)} }
// startViews says each change of the assigner's viewed states on its views family, once its marks state is bound,
// each state as soon as its bucket is (kvs, or bound once it appears). A watch that ends is sent on errs.
func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, account process.Account, kvs buckets, findings assign.Findings, errs chan<- error, logf func(string, ...any)) {
tooLarge := func(state, key string, size int) {
if findings.Put == nil {
return
}
k, v, err := views.TooLargeFinding(delivery.AssignerModule, state, key, size, time.Now())
if err == nil {
err = findings.Put(k, v)
}
if err != nil {
logf("views: the finding of %s.%s too large is not written: %v", state, key, err)
}
}
process.AwaitBucket(ctx, js, nc, marksBucket, "no change is said on the views family, so every reader waits on its first view", logf).Ready(func(mk jetstream.KeyValue) {
em := viewsEmitter(js, account, mk, findings, tooLarge, logf)
for _, state := range []string{"asks", "differences", health.FindingsState, delivery.PlacementState, delivery.SeatHoldersState, "wants"} {
state := state
start := func(kv jetstream.KeyValue) {
if err := em.Start(ctx, state, kv, errs); err != nil {
logf("%v: its readers ask its first view at each change until the assigner starts again", err)
}
}
if kv := kvs[viewedStates[state]]; kv != nil {
start(kv)
continue
}
process.AwaitBucket(ctx, js, nc, viewedStates[state], "its changes are not said on the views family", logf).Ready(start)
}
})
}
// viewsEmitter is the assigner's emitter of its views family: a change not said past views.Bound is its finding
// view-unsaid, in its findings (review round 2); with no findings state it is said in the log only.
func viewsEmitter(js jetstream.JetStream, account process.Account, mk jetstream.KeyValue, findings assign.Findings, tooLarge func(string, string, int), logf func(string, ...any)) views.Emitter {
em := views.Emitter{Owner: delivery.AssignerModule, Subject: account.StreamSubject, Publish: views.JetStreamPublish(js), Marks: mk, TooLarge: tooLarge, Logf: logf}
if findings.Put != nil && findings.Delete != nil {
em.Findings = assignerFindings{findings}
}
return em
}
// assignerFindings is the assigner's findings as views.Emitter's Findings.
type assignerFindings struct{ f assign.Findings }
func (a assignerFindings) Put(key string, value []byte) error { return a.f.Put(key, value) }
func (a assignerFindings) Delete(key string) error { return a.f.Delete(key) }
// firstViewsBucket is the assigner's own state of its first views (views.StatusState).
var firstViewsBucket = delivery.AssignerModule + "_" + views.StatusState
// indexerViews are the assigner's first views of the indexer's manifests, modules and module graph (ADR 0615 §1,
// ADR 0621 §2, ADR 0631; DLV-F2r): asked at the start through the indexer's tools, then followed on indexer-views, asking once more on a gap;
// past the bound its finding names the indexer and the tool, cleared by the answer.
type indexerViews struct {
js jetstream.JetStream
account process.Account
call func(string, any) (json.RawMessage, error)
findings assign.Findings
status *process.AwaitedBucket
logf func(string, ...any)
}
// indexerFirstViews are the indexer's states the assigner takes by first view and the views family, each by the name of
// the indexer's bucket the world keeps it under: manifests and modules (ADR 0621 §2), and the module graph, whose verb
// edges a want waits on (ADR 0614 §2, §3; ADR 0631 §1).
var indexerFirstViews = map[string]string{"manifests": delivery.ManifestsBucket, "modules": delivery.ModulesBucket, graph.State: graph.Bucket}
// firstViewBound and firstViewWait are each first view's bound before its condition is raised, and its first wait
// between two failed asks; zero is the views package's (views.Bound, two minutes, ADR 0615 §6; and a first wait of five
// seconds). A test shortens them.
var firstViewBound, firstViewWait time.Duration
// start asks the three first views, each answer taken into w under the indexer's bucket names and settled once (a pass waits
// for it), and hears the views family; it answers how many settles to wait for.
func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int {
finding := func(s views.Status, raised bool) {
if iv.findings.Put == nil {
return
}
key, value, err := views.UnansweredFinding(delivery.AssignerModule, s, time.Now())
if err == nil && raised {
err = iv.findings.Put(key, value)
} else if err == nil {
err = iv.findings.Delete(key)
}
if err != nil {
iv.logf("views: the finding of the first view %s is not written: %v", s.Tool, err)
}
}
followers := map[string]*views.Follower{}
var all []*views.Follower
for state, bucket := range indexerFirstViews {
bucket := bucket
tool := delivery.IndexerModule + "." + state
f := &views.Follower{Owner: delivery.IndexerModule, Tool: tool, State: state, Ask: views.AskVia(iv.call, tool), Logf: iv.logf,
Bound: firstViewBound, Wait: firstViewWait,
Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) },
Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) {
if deleted {
value = nil
}
w.putEntry(bucket, key, value, 0, at)
wake()
}}
followers[state] = f
all = append(all, f)
go func() {
if f.Start(ctx) == nil {
settled <- struct{}{}
}
}()
}
if iv.status != nil {
iv.status.Ready(func(kv jetstream.KeyValue) {
for _, f := range all {
f.Said = views.KeepStatus(ctx, kv, iv.logf)
}
if err := views.HearAgain(ctx, kv, all, iv.logf); err != nil {
iv.logf("views: assigner_first_view_again is not heard (%v) until the assigner starts again", err)
}
})
}
// Not heard, the indexer's views family is the finding family-not-heard until it binds (ADR 0615 §5).
stream := catalogue.StreamBusName(indexerViewsStream)
_ = notHeardWriter(iv.findings, iv.logf).Bind(stream, "the indexer's manifests, modules and graph are the first views' alone until the assigner starts again", func() error {
cons, err := iv.account.EventConsumer(ctx, iv.js, stream)
if err != nil {
return fmt.Errorf("the consumer %s on %s does not exist yet: %w", iv.account.EventConsumerName(), stream, err)
}
_, err = process.HearEvents(cons, views.Hear(ctx, followers, iv.logf), iv.logf)
return err
})
return len(all)
}
// notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no
// findings state it is said in the log alone.
func notHeardWriter(findings assign.Findings, logf func(string, ...any)) heard.Writer {
w := heard.Writer{Observer: delivery.AssignerModule, Logf: logf, Put: findings.Put}
if findings.Delete != nil {
w.Delete = func(key string) error {
if keys, err := findings.Keys(); err == nil && !slices.Contains(keys, key) {
return nil // none stands
}
return findings.Delete(key)
}
}
return w
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"maps"
"slices"
"sort"
"sync"
@@ -197,6 +198,19 @@ func kvGetter(kv jetstream.KeyValue) getter {
}
}
// getter reads a key of a bucket the world holds: errNotThere where it holds none.
func (w *world) getter(bucket string) getter {
return func(_ context.Context, key string) ([]byte, error) {
w.mu.Lock()
defer w.mu.Unlock()
v, ok := w.values[bucket][key]
if !ok {
return nil, errNotThere
}
return v, nil
}
}
// manifest reads the manifest of a digest from the indexer's bucket once and keeps it: a digest's manifest never changes.
func (w *world) manifest(ctx context.Context, get getter, digest string) parsed {
w.mu.Lock()
@@ -420,6 +434,42 @@ func (w *world) built(module, fp string) bool {
return err == nil && b.State == delivery.BuildDone
}
// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build key's needs, ADR
// 0618 §1): nil when it is not done or names none.
func (w *world) buildNeeds(module, fp string) []string {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil
}
b, err := delivery.DecodeBuilderBuild(w.value(delivery.BuilderBuildsBucket, key))
if err != nil || b.State != delivery.BuildDone {
return nil
}
return b.Needs
}
// recordReads is what node's record `<node>.node` states its engine reads (ADR 0614 §4), and whether the assigner holds
// a record of it: the fallback where the engine does not answer the verb manifest-reads (enginereads.FromRecord).
func (w *world) recordReads(node string) ([]string, bool) {
r, ok := w.records()[node]
return r.ManifestReads, ok
}
// knownNodes is every node the assigner hears of: placed, with a record, or with a presence.
func (w *world) knownNodes() []string {
set := map[string]bool{}
for node := range w.placedNodes() {
set[node] = true
}
for node := range w.records() {
set[node] = true
}
for node := range w.online() {
set[node] = true
}
return slices.Sorted(maps.Keys(set))
}
// composed is the composition of module for node: a placed node composes its own, and its composition is the digest its own
// have names (ADR 0571 §6); an unplaced node has none, so it gets no want (and the pass says so, unplacedFindings). The
// controller's copy (mesh-controller_composed) is read no more (issue 774).
+13 -5
View File
@@ -3,24 +3,32 @@ module mesh-delivery-assigner
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
)
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+20 -10
View File
@@ -1,15 +1,25 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8=
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -23,5 +33,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
@@ -0,0 +1,68 @@
package assign
import (
"encoding/json"
"fmt"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// assignerAsk is the asking-th ask a, as the asks state holds it: the router's ask as JSON (delivery.AssignerAsk.Ask).
func assignerAsk(asking int, a oc.Ask) delivery.AssignerAsk {
raw, err := json.Marshal(a)
if err != nil { // an oc.Ask holds only strings, bools and a slice of them: it cannot fail
panic(fmt.Sprintf("assign: the ask %q does not marshal: %v", a.ID, err))
}
return delivery.AssignerAsk{Asking: asking, Ask: raw}
}
// AskOf is the router's ask an asks-state value carries.
func AskOf(a delivery.AssignerAsk) (oc.Ask, error) {
var ask oc.Ask
if err := json.Unmarshal(a.Ask, &ask); err != nil {
return oc.Ask{}, fmt.Errorf("assign: the value carries no router's ask: %w", err)
}
return ask, nil
}
// EncodeAsk is delivery.EncodeAssignerAsk at key, after the checks every keeper of an ask owes (occ.Carried, ADR 0627 §4):
// the ask's id is delivery.AskingID(key, asking), and it offers at least one option.
func EncodeAsk(key string, a delivery.AssignerAsk) ([]byte, error) {
if _, err := checkedAsk(key, a); err != nil {
return nil, err
}
return delivery.EncodeAssignerAsk(key, a)
}
func checkedAsk(key string, a delivery.AssignerAsk) (oc.Ask, error) {
ask, err := occ.Carried(a.Ask, delivery.AskingID(key, a.Asking))
if err != nil {
return oc.Ask{}, fmt.Errorf("assign: %w", err)
}
return ask, nil
}
// DecodeAsk reads the ask at key (delivery.DecodeAssignerAsk) with the router's ask it carries.
func DecodeAsk(key string, data []byte) (delivery.AssignerAsk, oc.Ask, error) {
a, err := delivery.DecodeAssignerAsk(key, data)
if err != nil {
return delivery.AssignerAsk{}, oc.Ask{}, err
}
ask, err := checkedAsk(key, a)
if err != nil {
return delivery.AssignerAsk{}, oc.Ask{}, err
}
return a, ask, nil
}
// ExpiresAt is when the ask ends unanswered; the zero time for an ask that names none.
func ExpiresAt(a oc.Ask) time.Time {
t, err := occ.Time(a.Expires)
if err != nil {
return time.Time{}
}
return t
}
@@ -6,7 +6,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -15,21 +16,21 @@ import (
// record's: the machine whose health check failed and the hold's words (issue 1906). asking numbers the writing: 0 the first, a
// larger number an ask written again after a hold lifted by itself, so the router takes it as new.
func HoldAsk(key, module, fp, node, words string, asking int, now time.Time) delivery.AssignerAsk {
return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{
return assignerAsk(asking, oc.Ask{
ID: delivery.AskingID(key, asking),
Headline: fmt.Sprintf("%s is held at %s", module, short(fp)),
Explanation: fmt.Sprintf("The health check of %s failed on %s at %s (%s), so the assigner holds that version and wants "+
"no node to move to it. It lifts the hold by itself once every node that runs %s runs it healthy; releasing it "+
"lets the wants move to it now.", module, node, short(fp), words, module),
Options: []asks.Option{
{ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: asks.Acknowledge},
{ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: asks.Acknowledge},
Options: []oc.Option{
{ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: occ.Acknowledge},
{ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: now.Add(7 * 24 * time.Hour),
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
OnExpiry: "the version stays held",
About: key,
}}
})
}
// The options of the restore ask (ADR 0537 §3, ADR 0539 §19), and what each does on the node.
@@ -42,41 +43,41 @@ const (
// not set back by itself, since a set-back would lose the writes made since. words is the check's words. Restore sets the
// node back to the last healthy fingerprint and loses the newer writes; leaving it stopped keeps the data as it is.
func RestoreAsk(key, node, module, fp, words string, now time.Time) delivery.AssignerAsk {
return delivery.AssignerAsk{Ask: asks.Ask{
return assignerAsk(0, oc.Ask{
ID: delivery.AskingID(key, 0),
Headline: fmt.Sprintf("%s failed its health check on %s", module, node),
Explanation: fmt.Sprintf("%s holds data, and its health check failed on %s at %s: %s. Setting it back to the version that ran healthy "+
"would lose the writes made since; leaving it stopped keeps the data as it is.", module, node, short(fp), words),
Options: []asks.Option{
{ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: asks.Acknowledge},
{ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: asks.Acknowledge},
Options: []oc.Option{
{ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: occ.Acknowledge},
{ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: now.Add(7 * 24 * time.Hour),
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
OnExpiry: "the module stays as it is on the node",
About: key,
}}
})
}
// HeldCondition is the condition the assigner raises when it holds a fingerprint of a module (ADR 0429 §6, ADR 0539 §19), or
// clears (raised false) when the hold is released. It names the module, the node whose health check failed and the check's
// words, under the assigner's own name (ADR 0437 clause 9), keyed by the held fingerprint. needsYou is true for a module holding
// data, whose restore ask waits for the operator.
func HeldCondition(module, fp, node, words string, raised, needsYou bool) asks.Condition {
func HeldCondition(module, fp, node, words string, raised, needsYou bool) oc.Condition {
key := delivery.AssignerModule + ".held." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2)
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
if !raised {
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared}
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared}
}
c := asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
c := oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
Summary: fmt.Sprintf("%s is held at %s", module, short(fp)),
Headline: fmt.Sprintf("%s is held at %s", module, short(fp))}
what := fmt.Sprintf("The health check of %s failed on %s at %s: %s. The assigner holds that version and wants no node to move to it.", module, node, short(fp), words)
if needsYou {
c.Needs = "answer whether to restore " + module + " on " + node
c.Explanation = asks.NeedsYou + c.Needs + ". " + what
c.Explanation = occ.NeedsYou + c.Needs + ". " + what
} else {
c.Explanation = asks.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy."
c.Explanation = occ.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy."
}
return c
}
@@ -85,22 +86,22 @@ func HeldCondition(module, fp, node, words string, raised, needsYou bool) asks.C
// node while other nodes run it healthy (ADR 0557 §1, issue 1496): the failure is the node's, so nothing is held or set back and
// the node keeps its want. It names the node, the module, fp and the have's words, keyed by node and module, and is cleared
// (raised false) once the node no longer fails alone.
func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) asks.Condition {
func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) oc.Condition {
key := delivery.AssignerModule + ".failed-alone." + node + "." + module
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
if !raised {
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared,
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared,
Resolved: fmt.Sprintf("%s on %s no longer fails its health check alone.", module, node)}
}
headline := fmt.Sprintf("%s fails on %s alone", module, node)
if n := len([]rune(headline)); n > asks.HeadlineLength {
headline = string([]rune(headline)[:asks.HeadlineLength])
if n := len([]rune(headline)); n > occ.HeadlineLength {
headline = string([]rune(headline)[:occ.HeadlineLength])
}
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
Summary: headline, Headline: headline,
Explanation: fmt.Sprintf("%s The health check of %s at %s failed on %s (%s), while it runs healthy on %s. The failure is %s's, "+
"so the assigner holds nothing and sets no node back, and %s keeps its want. This clears once %s runs it healthy or "+
"another version is the newest.", asks.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node,
"another version is the newest.", occ.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node,
node, node)}
}
@@ -108,28 +109,28 @@ func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) as
// gives up every module with a fingerprint of the new form, so nothing applies it there and its files are gone. It is raised (urgent)
// naming the module, the node and the rule holding its first want back with the rule's words, under the assigner's own name keyed by
// node and module, and cleared (raised false) when the node has a want for it again.
func NeitherCondition(node, module string, h Holding, raised bool) asks.Condition {
func NeitherCondition(node, module string, h Holding, raised bool) oc.Condition {
key := delivery.AssignerModule + ".neither." + node + "." + module
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
if !raised {
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared,
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared,
Resolved: fmt.Sprintf("%s on %s has a want again, so its own composition is applied there.", module, node)}
}
headline := fmt.Sprintf("%s on %s is on no list", module, node)
if n := len([]rune(headline)); n > asks.HeadlineLength {
headline = string([]rune(headline)[:asks.HeadlineLength])
if n := len([]rune(headline)); n > occ.HeadlineLength {
headline = string([]rune(headline)[:occ.HeadlineLength])
}
return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised,
return oc.Condition{Key: key, Severity: occ.SeverityUrgent, Subject: subject, State: occ.StateRaised,
Summary: headline, Headline: headline,
Explanation: fmt.Sprintf("%s %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+
"hold it either, so nothing applies it there. The assigner's rule %d holds its first want back: %s. The assigner writes the want once the "+
"rule holds, and clears this.", asks.NothingToDo, module, node, h.Rule, h.Words)}
"rule holds, and clears this.", occ.NothingToDo, module, node, h.Rule, h.Words)}
}
// NeitherLeftCondition clears the neither condition of module on node when it is no longer on neither list: why is "" when the
// node has a live want for it again, else why it has none (it is held, its move waits for an ask, the node no longer runs it), so
// the clear never says a want the node does not hold (issue 1945: "nats on novox has a want again" while it had none).
func NeitherLeftCondition(node, module, why string) asks.Condition {
func NeitherLeftCondition(node, module, why string) oc.Condition {
c := NeitherCondition(node, module, Holding{}, false)
if why != "" {
c.Resolved = fmt.Sprintf("%s on %s is no longer on neither list and has no want: %s.", module, node, why)
@@ -138,7 +139,7 @@ func NeitherLeftCondition(node, module, why string) asks.Condition {
}
// LiftedCondition clears the condition of a held fingerprint the assigner lifted by itself, saying why (issue 1906).
func LiftedCondition(module, fp string) asks.Condition {
func LiftedCondition(module, fp string) oc.Condition {
c := HeldCondition(module, fp, "", "", false, false)
c.Resolved = LiftedWords(module, fp) + "."
return c
@@ -161,20 +162,20 @@ func RecordAsk(key, node, module, fp string, need RecordNeed, now time.Time) del
delivery.JudgedMovesData: "moves the module's data",
delivery.JudgedCannot: "cannot be judged: " + need.Words,
}[need.Judgement]
return delivery.AssignerAsk{Ask: asks.Ask{
return assignerAsk(0, oc.Ask{
ID: delivery.AskingID(key, 0),
Headline: fmt.Sprintf("%s waits for your yes on %s", module, node),
Explanation: fmt.Sprintf("%s is recorded: a person takes each of its moves. The next one on %s (version %s) %s. "+
"The node is told to apply it only after you say yes.", module, node, short(fp), what),
Options: []asks.Option{
{ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: asks.Acknowledge},
{ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: asks.Acknowledge},
Options: []oc.Option{
{ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: occ.Acknowledge},
{ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: now.Add(7 * 24 * time.Hour),
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
OnExpiry: "the node keeps what it runs",
About: key,
}}
})
}
// NextBackupAsking is the number of the asking that follows the one numbered after (a negative number: none yet). Numbers 0 and 1
@@ -192,7 +193,11 @@ var backupDigest = regexp.MustCompile(`\(composition (sha256:[0-9a-f]{64})\)`)
// none. An answer is taken for the composition of the ask it answers, and a new revision at the same fingerprint is asked again
// under a new number (ADR 0539 §16).
func BackupAskDigest(a delivery.AssignerAsk) string {
if m := backupDigest.FindStringSubmatch(a.Ask.Explanation); m != nil {
ask, err := AskOf(a)
if err != nil {
return ""
}
if m := backupDigest.FindStringSubmatch(ask.Explanation); m != nil {
return m[1]
}
return ""
@@ -208,33 +213,33 @@ func BackupAnswerWords(option string, asking int) string {
// without a backup. digest is the composition about to be applied and asking the number of the asking, 0 the first; words are the
// backup's own words, from the node's have.
func BackupAsk(key, node, module, fp, digest, words string, asking int, now time.Time) delivery.AssignerAsk {
return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{
return assignerAsk(asking, oc.Ask{
ID: delivery.AskingID(key, asking),
Headline: fmt.Sprintf("The backup of %s failed on %s", module, node),
Explanation: fmt.Sprintf("The backup before applying %s at %s on %s failed: %s. %s keeps running what it has until you answer. (composition %s)",
module, short(fp), node, words, module, digest),
Options: []asks.Option{
{ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: asks.Acknowledge},
{ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: asks.Acknowledge},
Options: []oc.Option{
{ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: occ.Acknowledge},
{ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: now.Add(7 * 24 * time.Hour),
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
OnExpiry: "the module keeps running what it has",
About: key,
}}
})
}
// BackupCondition is the condition raised when a backup before an apply fails and the module is held on the node (ADR 0539 §16),
// naming the node, the module and the backup's words; cleared under the same key.
func BackupCondition(node, module, fp, words string, raised bool) asks.Condition {
func BackupCondition(node, module, fp, words string, raised bool) oc.Condition {
key := delivery.AssignerModule + ".backup." + node + "." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2)
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
if !raised {
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared}
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared}
}
headline := fmt.Sprintf("The backup of %s failed on %s", module, node)
needs := "answer whether to back up again or go on without a backup"
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
Summary: headline, Headline: headline, Needs: needs,
Explanation: asks.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."}
Explanation: occ.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."}
}
@@ -1,12 +1,14 @@
package assign
import (
"fmt"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -20,27 +22,42 @@ func TestTheHoldAskIsValid(t *testing.T) {
}
now := time.Now()
a := HoldAsk(key, "mail", fNew, "ace", "health failed, phase running: connection refused", 0, now)
if err := a.Ask.Check(now); err != nil {
if err := occ.Check(mustAsk(t, a), now); err != nil {
t.Errorf("the router refuses the ask: %v", err)
}
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
if _, err := EncodeAsk(key, a); err != nil {
t.Errorf("the ask cannot be written: %v", err)
}
// 1906/H3: the ask names the machine whose check failed and the hold's words.
if !strings.Contains(a.Ask.Explanation, "on ace") || !strings.Contains(a.Ask.Explanation, "connection refused") {
t.Errorf("the ask does not name the node and the hold's words: %q", a.Ask.Explanation)
if !strings.Contains(mustAsk(t, a).Explanation, "on ace") || !strings.Contains(mustAsk(t, a).Explanation, "connection refused") {
t.Errorf("the ask does not name the node and the hold's words: %q", mustAsk(t, a).Explanation)
}
// 1906/H2: an ask written again after a lift is another asking, under an id of its own.
again := HoldAsk(key, "mail", fNew, "ace", "x", 7, now)
if again.Asking != 7 || again.Ask.ID != delivery.AskingID(key, 7) || again.Ask.ID == a.Ask.ID {
t.Errorf("the asking after a lift = %d %s", again.Asking, again.Ask.ID)
if again.Asking != 7 || mustAsk(t, again).ID != delivery.AskingID(key, 7) || mustAsk(t, again).ID == mustAsk(t, a).ID {
t.Errorf("the asking after a lift = %d %s", again.Asking, mustAsk(t, again).ID)
}
if _, err := delivery.EncodeAssignerAsk(key, again); err != nil {
if _, err := EncodeAsk(key, again); err != nil {
t.Errorf("the ask asked again cannot be written: %v", err)
}
if _, err := delivery.ViewAnswerKey(key); err != nil {
t.Errorf("the view may not answer it: %v", err)
}
// The join (A1d ruling 1): mesh-sdk no longer refuses another id, no option or no ask in an assigner's ask; EncodeAsk does.
t.Run("EncodeAsk refuses what mesh-sdk no longer does", func(t *testing.T) {
good := HoldAsk(key, "mail", fNew, "ace", "x", 0, time.Unix(100, 0))
other := good
other.Asking = 3 // its id is the asking 0's
empty := good
ask := mustAsk(t, good)
ask.Options = nil
empty.Ask = assignerAsk(0, ask).Ask
for name, a := range map[string]delivery.AssignerAsk{"another id": other, "no option": empty, "no ask": {}} {
if _, err := EncodeAsk(key, a); err == nil {
t.Errorf("%s: written", name)
}
}
})
}
// 1906/D3 (ADR 0539 §19): the ask about a data module held on a node is a valid ask of the router and of mesh-sdk's layout at
@@ -52,24 +69,24 @@ func TestTheRestoreAskIsValid(t *testing.T) {
}
now := time.Now()
a := RestoreAsk(key, "ace", "mail", fNew, "connection refused", now)
if err := a.Ask.Check(now); err != nil {
if err := occ.Check(mustAsk(t, a), now); err != nil {
t.Errorf("the router refuses the ask: %v", err)
}
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
if _, err := EncodeAsk(key, a); err != nil {
t.Errorf("the ask cannot be written: %v", err)
}
if _, err := delivery.ViewAnswerKey(key); err != nil {
t.Errorf("the view may not answer it: %v", err)
}
var ids []string
for _, o := range a.Ask.Options {
for _, o := range mustAsk(t, a).Options {
ids = append(ids, o.ID)
}
if len(ids) != 2 || ids[0] != OptionRestore || ids[1] != OptionLeaveStopped {
t.Errorf("options = %v", ids)
}
if !strings.Contains(a.Ask.Explanation, "connection refused") || !strings.Contains(a.Ask.Explanation, "ace") {
t.Errorf("the ask does not name the node and the check's words: %q", a.Ask.Explanation)
if !strings.Contains(mustAsk(t, a).Explanation, "connection refused") || !strings.Contains(mustAsk(t, a).Explanation, "ace") {
t.Errorf("the ask does not name the node and the check's words: %q", mustAsk(t, a).Explanation)
}
}
@@ -83,13 +100,13 @@ func TestTheAssignersConditions(t *testing.T) {
func heldConditionNamesModuleNodeAndWords(t *testing.T) {
c := HeldCondition("mail", fNew, "ace", "connection refused", true, true)
if _, err := asks.EncodeCondition(c); err != nil {
if _, err := occ.EncodeCondition(c); err != nil {
t.Fatalf("the condition is refused: %v", err)
}
if c.State != asks.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") {
if c.State != occ.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") {
t.Errorf("condition = %+v", c)
}
if got := HeldCondition("mail", fNew, "", "", false, false); got.State != asks.StateCleared || got.Key != c.Key {
if got := HeldCondition("mail", fNew, "", "", false, false); got.State != occ.StateCleared || got.Key != c.Key {
t.Errorf("cleared = %+v", got)
}
}
@@ -97,10 +114,10 @@ func heldConditionNamesModuleNodeAndWords(t *testing.T) {
// 1906/H2: a hold lifted by itself clears the held condition under its key, saying why.
func liftedHoldClearsItsConditionSayingWhy(t *testing.T) {
c := LiftedCondition("mail", fNew)
if _, err := asks.EncodeCondition(c); err != nil {
if _, err := occ.EncodeCondition(c); err != nil {
t.Fatalf("the condition is refused: %v", err)
}
if c.State != asks.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") {
if c.State != occ.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") {
t.Errorf("condition = %+v", c)
}
}
@@ -112,7 +129,7 @@ func liftedHoldClearsItsConditionSayingWhy(t *testing.T) {
func saidConditionsAreFindings(t *testing.T) {
at := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
for _, c := range []struct {
raised, cleared asks.Condition
raised, cleared oc.Condition
key string
}{
{HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".held"},
@@ -127,7 +144,7 @@ func saidConditionsAreFindings(t *testing.T) {
puts[k] = got
return err
}, Delete: func(k string) error { deletes = append(deletes, k); return nil }, Now: func() time.Time { return at }}
say := f.Say(func(asks.Condition) error { said++; return nil })
say := f.Say(func(oc.Condition) error { said++; return nil })
if err := say(c.raised); err != nil {
t.Fatalf("%s: %v", c.key, err)
}
@@ -139,11 +156,11 @@ func saidConditionsAreFindings(t *testing.T) {
if err := say(c.cleared); err != nil || !slices.Equal(deletes, []string{c.key}) || said != 0 {
t.Fatalf("%s: deleted %v, said %d (%v)", c.key, deletes, said, err)
}
if err := (Findings{}).Say(func(asks.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 {
if err := (Findings{}).Say(func(oc.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 {
t.Fatalf("%s: with no findings bucket said %d (%v), want straight once", c.key, said, err)
}
}
if _, _, _, err := FindingOf(asks.Condition{Key: "another.x", State: asks.StateRaised}, at); err == nil {
if _, _, _, err := FindingOf(oc.Condition{Key: "another.x", State: occ.StateRaised}, at); err == nil {
t.Error("a condition not the assigner's is a finding")
}
}
@@ -164,24 +181,24 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
dgst := "sha256:" + strings.Repeat("b", 64)
b := BackupAsk(bkey, "ace", "mail", fNew, dgst, "no space left", 0, now)
for key, a := range map[string]delivery.AssignerAsk{rkey: r, bkey: b} {
if err := a.Ask.Check(now); err != nil {
if err := occ.Check(mustAsk(t, a), now); err != nil {
t.Errorf("%s: the router refuses the ask: %v", key, err)
}
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
if _, err := EncodeAsk(key, a); err != nil {
t.Errorf("%s: cannot be written: %v", key, err)
}
if _, err := delivery.ViewAnswerKey(key); err != nil {
t.Errorf("%s: the view may not answer it: %v", key, err)
}
}
if !strings.Contains(r.Ask.Explanation, "gone") || r.Ask.Options[0].ID != OptionYes {
t.Errorf("the record ask = %+v", r.Ask)
if !strings.Contains(mustAsk(t, r).Explanation, "gone") || mustAsk(t, r).Options[0].ID != OptionYes {
t.Errorf("the record ask = %+v", mustAsk(t, r))
}
if !strings.Contains(b.Ask.Explanation, "no space left") || b.Ask.Options[0].ID != delivery.BackupAgain || b.Ask.Options[1].ID != delivery.BackupSkip {
t.Errorf("the backup ask = %+v", b.Ask)
if !strings.Contains(mustAsk(t, b).Explanation, "no space left") || mustAsk(t, b).Options[0].ID != delivery.BackupAgain || mustAsk(t, b).Options[1].ID != delivery.BackupSkip {
t.Errorf("the backup ask = %+v", mustAsk(t, b))
}
// the id binds the ask to the composition and to its number (review round 1, items 3 and 4)
if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); again.Ask.ID == b.Ask.ID {
if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); mustAsk(t, again).ID == mustAsk(t, b).ID {
t.Errorf("asked again under the same id")
}
if NextBackupAsking(-1) != 0 || NextBackupAsking(0) != 2 || NextBackupAsking(2) != 3 {
@@ -194,7 +211,7 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
t.Errorf("two answers of the same option are one want value")
}
c := BackupCondition("ace", "mail", fNew, "no space left", true)
if _, err := asks.EncodeCondition(c); err != nil {
if _, err := occ.EncodeCondition(c); err != nil {
t.Errorf("the backup condition is refused: %v", err)
}
// ADR 0546 clauses 7 and 8: a put's ask is valid at the record ask's key; for the bus its yes approves and binds the act,
@@ -206,32 +223,39 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
if err != nil {
t.Fatalf("bus %v: %v", bus, err)
}
if err := a.Ask.Check(now); err != nil {
if err := occ.Check(mustAsk(t, a), now); err != nil {
t.Errorf("bus %v: the router refuses the put's ask: %v", bus, err)
}
if _, err := delivery.EncodeAssignerAsk(rkey, a); err != nil {
if _, err := EncodeAsk(rkey, a); err != nil {
t.Errorf("bus %v: the put's ask cannot be written: %v", bus, err)
}
viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: a.Ask.ID, Option: OptionYes}, a)
yes := a.Ask.Options[0]
viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: mustAsk(t, a).ID, Option: OptionYes}, rkey, a)
yes := mustAsk(t, a).Options[0]
// mesh-sdk gives no refusal by level any more: the teller refuses an option a view cannot answer (its level is not
// acknowledge), as it did through the sdk before.
if viewed == nil {
if o, ok := occ.OptionOf(mustAsk(t, a), OptionYes); ok && o.Level != "" && o.Level != occ.Acknowledge {
viewed = fmt.Errorf("only a channel that proves who answered takes the option %s", o.ID)
}
}
switch {
case !strings.Contains(a.Ask.Explanation, "the bus's user list"):
t.Errorf("bus %v: the put's ask does not say why: %s", bus, a.Ask.Explanation)
case bus && (yes.Level != asks.Approve || yes.Binds == "" || viewed == nil || a.Ask.Expires.After(now.Add(asks.ApproveLasts))):
t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, a.Ask.Expires)
case !bus && (yes.Level != asks.Acknowledge || viewed != nil):
case !strings.Contains(mustAsk(t, a).Explanation, "the bus's user list"):
t.Errorf("bus %v: the put's ask does not say why: %s", bus, mustAsk(t, a).Explanation)
case bus && (yes.Level != occ.Approve || yes.Binds == "" || viewed == nil || expiresAt(t, a).After(now.Add(occ.ApproveLasts))):
t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, expiresAt(t, a))
case !bus && (yes.Level != occ.Acknowledge || viewed != nil):
t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed)
}
later := put
later.At = now.Add(time.Minute)
b, _ := PutAsk(rkey, "ace", "mail", later, d, bus, now)
if b.Ask.ID == a.Ask.ID || b.Asking != PutAsking(later) {
t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, b.Ask.ID, a.Ask.ID)
if mustAsk(t, b).ID == mustAsk(t, a).ID || b.Asking != PutAsking(later) {
t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, mustAsk(t, b).ID, mustAsk(t, a).ID)
}
if err := yes.Performs(PutAct("ace", "mail", fNew, dgst)); err != nil {
if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dgst)); err != nil {
t.Errorf("bus %v: the yes does not perform the act it bound: %v", bus, err)
}
if err := yes.Performs(PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil {
if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil {
t.Errorf("bus: the yes performs another composition's act")
}
}
@@ -241,14 +265,29 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
// under the assigner's own name keyed by node and module, and is cleared under the same key.
func TestTheNeitherConditionNamesModuleNodeAndRule(t *testing.T) {
c := NeitherCondition("g14", "i3status-rust", Holding{Rule: delivery.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}, true)
if _, err := asks.EncodeCondition(c); err != nil {
if _, err := occ.EncodeCondition(c); err != nil {
t.Fatalf("the condition is refused: %v", err)
}
if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" ||
if c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" ||
!strings.Contains(c.Explanation, "x11-display") || !strings.Contains(c.Explanation, "rule 5") || c.Key != "mesh-delivery-assigner.neither.g14.i3status-rust" {
t.Errorf("condition = %+v", c)
}
if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != asks.StateCleared || got.Key != c.Key {
if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != occ.StateCleared || got.Key != c.Key {
t.Errorf("cleared = %+v", got)
}
}
// mustAsk is the router's ask an asks-state value carries.
func mustAsk(t *testing.T, a delivery.AssignerAsk) oc.Ask {
t.Helper()
ask, err := AskOf(a)
if err != nil {
t.Fatal(err)
}
return ask
}
func expiresAt(t *testing.T, a delivery.AssignerAsk) time.Time {
t.Helper()
return ExpiresAt(mustAsk(t, a))
}
@@ -6,7 +6,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -24,7 +25,7 @@ var FindingsBucket = health.FindingsBucket(delivery.AssignerModule)
// FindingOf is a said condition as a finding: its key `<scope>.<id>.<kind>`, from the condition's own key
// `mesh-delivery-assigner.<kind>.<id…>` (the subject's id when the key names none), its words unchanged. raised is false for
// a clear, whose key is to be deleted; value is nil then.
func FindingOf(c asks.Condition, at time.Time) (key string, value []byte, raised bool, err error) {
func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised bool, err error) {
rest, ok := strings.CutPrefix(c.Key, delivery.AssignerModule+".")
if !ok || rest == "" {
return "", nil, false, fmt.Errorf("the condition %q is not the assigner's", c.Key)
@@ -38,10 +39,10 @@ func FindingOf(c asks.Condition, at time.Time) (key string, value []byte, raised
scope = health.ScopeModule
}
key = health.FindingKey(scope, id, kind)
if c.State == asks.StateCleared {
if c.State == occ.StateCleared {
return key, nil, false, nil
}
if c.State != asks.StateRaised {
if c.State != occ.StateRaised {
return "", nil, false, fmt.Errorf("the condition %s is neither raised nor cleared", c.Key)
}
summary := c.Summary
@@ -70,8 +71,8 @@ type Findings struct {
// Say is say with each condition written as a finding (or its finding deleted), which the condition keeper carries, the
// one source since the controller's end (novox/hq ADR 0590 §3); said straight to the router only where there is no
// findings bucket at all. A finding that cannot be written is the error.
func (f Findings) Say(say func(asks.Condition) error) func(asks.Condition) error {
return func(c asks.Condition) error {
func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error {
return func(c oc.Condition) error {
var errs []error
if f.Put != nil {
now := time.Now
@@ -3,20 +3,21 @@ package assign
import (
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// SeatWaitsCondition is the assigner's word on the seat mesh-placement while a state of the seat is not bound (ADR 0567
// §1): raised naming each, cleared when none is left.
func SeatWaitsCondition(unbound []string) asks.Condition {
func SeatWaitsCondition(unbound []string) oc.Condition {
key := delivery.AssignerModule + ".seat-states"
subject := asks.Scope{Scope: "module", ID: delivery.AssignerModule}
subject := oc.ConditionSubject{Scope: "module", ID: delivery.AssignerModule}
if len(unbound) == 0 {
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: "the seat mesh-placement holds all its states."}
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared, Resolved: "the seat mesh-placement holds all its states."}
}
why := "the seat waits for its states: " + strings.Join(unbound, ", ") + " are not on the bus or not granted yet"
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
Summary: "the seat waits for its states", Headline: "the seat waits for its states",
Explanation: asks.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."}
Explanation: occ.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."}
}
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
@@ -70,7 +70,7 @@ func ProvisionFailedFinding(key string, p provisions.Provision, at time.Time) ([
return health.EncodeFinding(health.Finding{Key: ProvisionFailedKey(key), Kind: KindProvisionFailed,
Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node)},
Severity: health.Warning, Summary: summary, Headline: clipHeadline(p.Consumer.Module + " waits for a provision that failed"),
Explanation: asks.NothingToDo + " " + summary + ". It clears once the provider provides it.",
Explanation: occ.NothingToDo + " " + summary + ". It clears once the provider provides it.",
Resolved: p.Provision + " for " + p.Consumer.Module + " is provided", Evidence: []health.Evidence{{At: at.UTC(), Said: p.Why}},
Source: "assigner." + KindProvisionFailed, Observer: delivery.AssignerModule, At: at.UTC()})
}
@@ -102,7 +102,7 @@ func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) ([]b
return health.EncodeFinding(health.Finding{Key: ProvisionMissingKey(key), Kind: KindProvisionMissing,
Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: node},
Severity: health.Warning, Summary: summary, Headline: clipHeadline(module + " on " + node + " lacks " + provision),
Explanation: asks.NothingToDo + " " + summary + ". It clears once the provision has its key.",
Explanation: occ.NothingToDo + " " + summary + ". It clears once the provision has its key.",
Resolved: provision + " for " + module + " on " + node + " has its key", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}},
Source: "assigner." + KindProvisionMissing, Observer: delivery.AssignerModule, At: at.UTC()})
}
@@ -122,7 +122,7 @@ func UnplacedFinding(node string, at time.Time) ([]byte, error) {
return health.EncodeFinding(health.Finding{Key: UnplacedKey(node), Kind: KindUnplaced,
Subject: health.Subject{Scope: health.ScopeMachine, ID: node, Machine: node}, Severity: health.Warning, Summary: summary,
Headline: clipHeadline(node + " has no placement"), Needs: "place it with mesh-placement.propose.",
Explanation: asks.NeedsYou + "place it with mesh-placement.propose. " + summary + ".",
Explanation: occ.NeedsYou + "place it with mesh-placement.propose. " + summary + ".",
Resolved: node + " is placed", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}},
Source: "assigner." + KindUnplaced, Observer: delivery.AssignerModule, At: at.UTC()})
}
@@ -4,7 +4,8 @@ import (
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -210,15 +211,15 @@ func PutAsking(p delivery.Put) int {
}
// PutAct is the act a bus put's yes binds and that is performed when its want is written.
func PutAct(node, module, fp, composed string) asks.Act {
return asks.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed}
func PutAct(node, module, fp, composed string) occ.Act {
return occ.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed}
}
// PutAsk is the ask about a put's move (ADR 0546 clauses 7 and 8), under the record ask's key of the composition so the
// teller relays it and its answer as any record ask's. For the bus its yes is of level approve and binds the act, so only a
// channel that proves who answered takes it (ADR 0259), never the view.
func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, bus bool, now time.Time) (delivery.AssignerAsk, error) {
yes := asks.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: asks.Acknowledge}
yes := oc.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: occ.Acknowledge}
what := map[delivery.Judgement]string{
delivery.JudgedRecreates: "recreates a container",
delivery.JudgedMovesData: "moves the module's data",
@@ -227,26 +228,26 @@ func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest,
delivery.JudgedUnchanged: "changes nothing the node runs",
}[d.Judgement]
explanation := fmt.Sprintf("Asked to put %s at version %s on %s: %s. The move %s.", module, short(p.FP), node, p.Why, what)
expires := now.Add(7 * 24 * time.Hour)
expires := occ.FormatTime(now.Add(7 * 24 * time.Hour))
if bus {
binds, err := asks.ActDigest(PutAct(node, module, p.FP, d.Digest))
binds, err := occ.ActDigest(PutAct(node, module, p.FP, d.Digest))
if err != nil {
return delivery.AssignerAsk{}, err
}
yes.Level, yes.Binds = asks.Approve, binds
yes.Level, yes.Binds = occ.Approve, binds
yes.Does = "the bus's streams are snapshotted, then " + node + " is told to apply the bus at " + short(p.FP)
explanation += " It is the mesh's bus: every module reconnects while it restarts, and its streams are snapshotted first."
expires = now.Add(asks.ApproveLasts)
expires = occ.FormatTime(now.Add(occ.ApproveLasts))
}
return delivery.AssignerAsk{Ask: asks.Ask{
return assignerAsk(PutAsking(p), oc.Ask{
ID: delivery.AskingID(key, PutAsking(p)),
Headline: fmt.Sprintf("Put %s on %s?", module, node),
Explanation: explanation,
Options: []asks.Option{yes,
{ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: asks.Acknowledge}},
Who: asks.Operator,
Options: []oc.Option{yes,
{ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: occ.Acknowledge}},
Who: occ.Operator,
Expires: expires,
OnExpiry: "the node keeps what it runs",
About: key,
}, Asking: PutAsking(p)}, nil
}), nil
}
@@ -7,7 +7,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -170,15 +171,15 @@ const (
// CallClearedCondition clears the condition of a refused controller call a previous process raised, for its pair (node and
// module, or node and provision for a pin).
func CallClearedCondition(kind, node, module string) asks.Condition {
return asks.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module,
Subject: asks.Scope{Scope: "module", ID: module, Machine: node}, State: asks.StateCleared,
func CallClearedCondition(kind, node, module string) oc.Condition {
return oc.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module,
Subject: oc.ConditionSubject{Scope: "module", ID: module, Machine: node}, State: occ.StateCleared,
Resolved: "the assigner calls no verb of the controller any more: placement is the one source."}
}
// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions.
func PlacementAct(p delivery.PlacementProposal) asks.Act {
return asks.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)}
func PlacementAct(p delivery.PlacementProposal) occ.Act {
return occ.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)}
}
// PlacementAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is
@@ -190,29 +191,29 @@ func PlacementAsking(p delivery.PlacementProposal, at time.Time) int {
// PlacementAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that
// proves who answered takes it, with the brief of the verdict by node (judge.Brief) as its explanation.
func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) {
binds, err := asks.ActDigest(PlacementAct(p))
binds, err := occ.ActDigest(PlacementAct(p))
if err != nil {
return delivery.AssignerAsk{}, err
}
nodes := p.Nodes()
headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", "))
if n := len([]rune(headline)); n > asks.HeadlineLength {
if n := len([]rune(headline)); n > occ.HeadlineLength {
headline = fmt.Sprintf("Change placement on %d machines?", len(nodes))
}
return delivery.AssignerAsk{Ask: asks.Ask{
return assignerAsk(asking, oc.Ask{
ID: delivery.AskingID(key, asking),
Headline: headline,
Explanation: brief,
Options: []asks.Option{
Options: []oc.Option{
{ID: OptionYes, Label: "Approve", Does: "the placement of " + strings.Join(nodes, ", ") + " is written as proposed",
Level: asks.Approve, Binds: binds},
{ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: asks.Acknowledge},
Level: occ.Approve, Binds: binds},
{ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: now.Add(asks.ApproveLasts),
Who: occ.Operator,
Expires: occ.FormatTime(now.Add(occ.ApproveLasts)),
OnExpiry: "nothing is written, and the proposal ends",
About: key,
}, Asking: asking}, nil
}), nil
}
// Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -81,7 +81,7 @@ func TestThePlacementSeatDecides(t *testing.T) {
// The seat says it waits while one of its states is not bound, naming it, and clears once all are.
for _, unbound := range [][]string{{delivery.PlacementPulsesBucket}, nil} {
c := SeatWaitsCondition(unbound)
if _, err := asks.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == asks.StateRaised) ||
if _, err := occ.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == occ.StateRaised) ||
(len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+delivery.PlacementPulsesBucket)) {
t.Errorf("waiting for %v: %+v, %v", unbound, c, err)
}
@@ -92,12 +92,12 @@ func TestThePlacementSeatDecides(t *testing.T) {
t.Fatalf("writes = %+v, %v", writes, err)
}
a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0))
if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil {
if err != nil || occ.Check(mustAsk(t, a), time.Unix(100, 0)) != nil || mustAsk(t, a).Options[0].Level != occ.Approve || occ.Performs(mustAsk(t, a).Options[0], PlacementAct(p)) != nil {
t.Fatalf("the ask = %+v, %v", a, err)
}
q := p
q.Changes = append(slices.Clone(p.Changes), delivery.PlacementProposalChange{Op: delivery.OpUnpin, Node: "laptop", Provision: "x"})
if a.Ask.Options[0].Performs(PlacementAct(q)) == nil {
if occ.Performs(mustAsk(t, a).Options[0], PlacementAct(q)) == nil {
t.Error("the ask's yes binds changes it was not asked about")
}
}
@@ -12,6 +12,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
@@ -80,6 +81,10 @@ type Module struct {
// Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR
// 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none.
Needs []string
// FormNeeds are the reference forms the module's build at FP is written in (`form:<name>`, the builder's build key's
// needs, ADR 0618 §1): a node's want is held only while that node's engine reads none of them (ADR 0624 §2). Nil: the
// build names none, and holds nothing.
FormNeeds []string
// Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one
// source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while
// the node was placed does not outlive the placement (issue 1126). Nil: no node does.
@@ -179,8 +184,14 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
// manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the
// composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the
// engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states.
//
// A build's reference forms hold the want only when the engine reads none of them (ADR 0624 §2): every engine reads a
// first form, so a build written in one is never held by its forms; the words name the forms it reads none of.
func (m Module) readerLacking(node string, n Nodes) (string, bool) {
missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node])
if len(missing) == 0 && forms.Current.Held(m.FormNeeds, n.Reads[node]) {
missing = slices.Clone(m.FormNeeds)
}
if len(missing) == 0 {
return "", false
}
@@ -122,21 +122,33 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
needs []string
reads map[string][]string
held bool
// forms are the build's reference forms (ADR 0624 §2), and names what the hold's words name.
forms []string
names string
}{
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true},
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true, nil, "word:tool-runner"},
{"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"},
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false},
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false},
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false},
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false, nil, ""},
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false, nil, ""},
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false, nil, ""},
// ADR 0624 §2, with form:new a form no engine reads unless it states it and artifact-store the first form.
{"g14 back stating {old}, a build carrying {old,new}: moves", "mail", nil, map[string][]string{"g14": {"word:module"}}, false,
[]string{"form:artifact-store", "form:new"}, ""},
{"an engine stating nothing, a build carrying the baseline's form: moves", "mail", nil, nil, false, []string{"form:artifact-store"}, ""},
{"a build carrying {new} only: held, naming the form", "mail", nil, map[string][]string{"g14": {"word:module"}}, true,
[]string{"form:new"}, "form:new"},
{"the same, once g14's engine states it", "mail", nil, map[string][]string{"g14": {"form:new"}}, false, []string{"form:new"}, ""},
{"a manifest needing an unstated word, its build in the first form: held, naming the word", "mail", []string{"word:tool-runner"}, nil, true,
[]string{"form:artifact-store"}, "word:tool-runner"},
} {
m, n := ready()
m.Name, m.Needs, n.Reads = c.name, c.needs, c.reads
m.Name, m.Needs, n.Reads, m.FormNeeds = c.name, c.needs, c.reads, c.forms
n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"]
out := Wants(m, n, wantedOld(), nil)
if moved := out.Wants["g14"].FP == fNew; moved == c.held {
t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"])
}
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "word:tool-runner") ||
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, c.names) ||
!strings.Contains(r.Words, "node-engine of g14")) {
t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r)
}
@@ -14,14 +14,15 @@ import (
"strings"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// AnswerBound is how long the holder waits for a node's answer after its candidate was written: the time to live of the
// node's pulse key (delivery.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer
// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, delivery.ComposeAnswerBound). A variable so a test on the bus
// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, nec.AnswerBound). A variable so a test on the bus
// can shorten it.
var AnswerBound = delivery.ComposeAnswerBound
var AnswerBound = nec.AnswerBound
// Intended is, by node, the modules whose placed entry the proposal changes there, and the nodes that answer it: every
// node (ADR 0593 §8, changing ADR 0567 §4's list of the nodes that must answer), sorted. current is the placement as it
@@ -8,6 +8,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
func load(t *testing.T) catalogue.Manifest {
@@ -34,9 +36,10 @@ func TestManifest(t *testing.T) {
reads := slices.Clone(m.Reads)
slices.Sort(reads)
// ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1);
// and the indexer's module graph, whose verb edges a want waits on (ADR 0614 §2, §3).
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1).
// DLV-F2r (ADR 0615 §1, ADR 0631 §1): the indexer's manifests, modules and module graph (whose verb edges a want waits
// on, ADR 0614 §2, §3) are read through its first views and views family, none of the indexer's states by reads.
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source",
"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed",
"mesh-provisioner.provisions", "node-engine.have"}
if !reflect.DeepEqual(reads, want) {
@@ -47,7 +50,8 @@ func TestManifest(t *testing.T) {
states[s.Name] = s.History
}
if want := map[string]int{"asks": 5, "differences": 64, "puts": 20, "wants": 20,
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5}; !reflect.DeepEqual(states, want) {
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1,
"engine-reads": 1}; !reflect.DeepEqual(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
@@ -78,16 +82,42 @@ func TestManifest(t *testing.T) {
if seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat); catalogue.CanHold(m, seat) != nil {
t.Errorf("the assigner cannot hold the seat: %v", catalogue.CanHold(m, seat))
}
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) {
t.Errorf("the assigner serves tools %v, want its three put tools", m.Tools)
// ADR 0621 §1: the first-view tools placement, seat-holders and wants and design 25 § 12.2's six read tools, of the
// program's bundle, which runs in a runtime of its own on the assigner's account; ADR 0618 §3: and assigner_engine_reads,
// which lists and asks again what each node's engine reads.
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "placement", "seat-holders", "wants",
"assigner_read_placement", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences",
"assigner_first_views", "assigner_first_view_again", "assigner_engine_reads"}) {
t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools and assigner_engine_reads", m.Tools)
}
if len(m.Streams) != 1 || m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays ||
if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs {
t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs)
}
// ADR 0621 §2: each first-view tool answers exactly its readers, never the assigner.
callers := map[string]string{"placement": "mesh-grants,node-tools,mesh-delivery-indexer", "seat-holders": "node-tools", "wants": "mesh-grants"}
env := m.Build.Artifacts[0].Env
for tool, list := range callers {
if env[views.CallersEnv(tool)] != list || views.ParseCallers(env[views.CallersEnv(tool)]).Allows("n1."+m.Module) {
t.Errorf("%s callers = %q, want %q", tool, env[views.CallersEnv(tool)], list)
}
}
// ADR 0619 §2, ADR 0615 §5: its views family beside placement on its old streams section.
if len(m.Streams) != 2 || m.Streams[1].Name != "assigner-views" || !slices.Equal(m.Streams[1].Events, []string{views.EventPattern}) ||
m.Streams[1].MaxAgeDays != views.StreamDays || m.Streams[1].MaxMiB != views.StreamMiB || m.Streams[1].Retention != "" {
t.Errorf("streams = %+v, want placement and assigner-views", m.Streams)
}
if m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays ||
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) {
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays)
}
if want := []string{
// ADR 0618 §4: and it hears each node's engine say its reads changed.
if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "node-engine.manifest-reads-changed"}) {
t.Errorf("consumes = %v, want the indexer's views of graph, manifests and modules and the engines' reads changed", m.Consumes)
}
if want := []string{"mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
"seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) {
"seat:node-backup.backed-up", "seat:node-backup.now",
"seat:node-engine.manifest-reads"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0618 §3
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if !slices.Equal(m.Emits, []string{"put"}) {
+37 -8
View File
@@ -2,6 +2,7 @@
"module": "mesh-delivery-assigner",
"version": "1",
"slug": "assigner",
"runs-as": "mesh-delivery-assigner",
"claims": [
{
"name": "mesh-placement",
@@ -27,9 +28,6 @@
"mesh-delivery-builder.builds",
"mesh-delivery-gate.merges",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-teller.answers",
"mesh-delivery-watchman.presence",
"mesh-grants.grants",
@@ -40,12 +38,22 @@
"uses": [
"operator-channel"
],
"consumes": [
"mesh-delivery-indexer.views.graph",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules",
"node-engine.manifest-reads-changed"
],
"invokes": [
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.hand-acts",
"seat:mesh-hand-acts.warranted",
"seat:node-backup.backed-up",
"seat:node-backup.now"
"seat:node-backup.now",
"seat:node-engine.manifest-reads"
],
"emits": [
"put"
@@ -53,7 +61,19 @@
"tools": [
"assigner_put",
"assigner_release",
"assigner_puts"
"assigner_puts",
"placement",
"seat-holders",
"wants",
"assigner_read_placement",
"assigner_read_seat_holders",
"assigner_read_wants",
"assigner_read_findings",
"assigner_read_asks",
"assigner_read_differences",
"assigner_first_views",
"assigner_first_view_again",
"assigner_engine_reads"
],
"state": [
{ "name": "placement", "history": 64 },
@@ -66,7 +86,10 @@
{ "name": "wants", "history": 20 },
{ "name": "findings", "history": 5 },
{ "name": "policies", "history": 20 },
{ "name": "seat-holders", "history": 5 }
{ "name": "seat-holders", "history": 5 },
{ "name": "view-marks", "history": 1 },
{ "name": "first-views", "history": 1 },
{ "name": "engine-reads", "history": 1 }
],
"streams": [
{
@@ -78,7 +101,8 @@
"placement-withdrawn"
],
"max-age-days": 30
}
},
{ "name": "assigner-views", "events": ["views.**"], "max-age-days": 7, "max-mib": 256 }
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -128,7 +152,12 @@
"binary": "mesh-delivery-assigner",
"loads": [
"mesh-delivery-assigner"
]
],
"env": {
"MESH_VIEW_CALLERS_PLACEMENT": "mesh-grants,node-tools,mesh-delivery-indexer",
"MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools",
"MESH_VIEW_CALLERS_WANTS": "mesh-grants"
}
}
]
}
@@ -3,20 +3,21 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
+12 -10
View File
@@ -1,15 +1,17 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -23,5 +25,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
@@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"sort"
"sync"
"time"
@@ -42,6 +43,9 @@ type ToolchainGate struct {
known bool // the placement is settled
raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it
left map[string]string // <module>/<key> -> why it was said left out, so each reason is said once
// OnPlaced is told, outside the gate's lock, once the placement's history is read and after each revision of it
// FollowPlacement hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody.
OnPlaced func()
}
// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what
@@ -90,6 +94,19 @@ func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at tim
return g.again(node, changed)
}
// Nodes are the nodes the placement places any module on, sorted: the nodes the mesh counts (a node leaves the count
// when the placement no longer names it; ADR 0624 §1). None until the placement is read.
func (g *ToolchainGate) Nodes() []string {
var out []string
for node, modules := range g.placed.ByNode() {
if len(modules) > 0 {
out = append(out, node)
}
}
sort.Strings(out)
return out
}
// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's
// placement is held. It answers whether what it passed on changed.
func (g *ToolchainGate) Settled() bool {
@@ -203,6 +220,9 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
if gate.Settled() {
changed(ctx)
}
if gate.OnPlaced != nil {
gate.OnPlaced()
}
go func() {
defer func() { _ = w.Stop() }()
for {
@@ -218,6 +238,9 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
if e != nil && put(e) {
changed(ctx)
}
if e != nil && gate.OnPlaced != nil {
gate.OnPlaced()
}
}
}
}()
@@ -17,18 +17,116 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// EngineReadsTool lists, asks again and drops what each counted node's engine reads, as the builder keeps it.
const EngineReadsTool = "builder_engine_reads"
// queueAsker writes a queues value and waits for the builder's answer to it.
type queueAsker func(key string, value []byte) (json.RawMessage, error)
// Tools are the builder's tools: stuck reads, askAgain writes the reasks value, queue writes a queues value and waits for
// the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer.
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time) []stdio.Tool {
// tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks.
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader,
tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool {
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
// handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error.
handAct := func(key string, value []byte, err error) (any, error) {
if err != nil {
return nil, err
}
raw, err := buildAgain(key, value)
if err != nil {
return nil, err
}
a, err := build.DecodeReAsk(raw)
if err != nil {
return nil, err
}
if a.Refused {
return nil, errors.New(a.Answer)
}
return map[string]any{"key": key, "name": a.Name, "do": a.Do, "version": a.Version, "answer": a.Answer}, nil
}
tagAct := func(key string, value []byte, err error) (any, error) {
if err != nil {
return nil, err
}
raw, err := tags(key, value)
if err != nil {
return nil, err
}
return versions.DecodeTagAsk(raw)
}
nameFP := map[string]any{"type": "object", "required": []string{"name", "fp", "why"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>, as builder_versions lists it"},
"fp": map[string]any{"type": "string", "description": "the fingerprint, src2:<hex>"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}}
branch := func(head bool) map[string]any {
req := []string{"repository", "branch", "why"}
props := map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
"branch": map[string]any{"type": "string", "description": "the branch; its cleaned name is the tag"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}
if head {
req = append(req, "head")
props["head"] = map[string]any{"type": "string", "description": "the commit the tagged version is built from"}
}
return map[string]any{"type": "object", "required": req, "properties": props}
}
return []stdio.Tool{
{Name: versions.PublishAgainTool, Description: versions.PublishAgainDescription, Input: nameFP,
Run: func(args map[string]any) (any, error) {
return handAct(build.NewPublishAgain(str(args, "name"), str(args, "fp"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.FloorAgainTool, Description: versions.FloorAgainDescription,
Input: map[string]any{"type": "object", "required": []string{"name", "why"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}},
Run: func(args map[string]any) (any, error) {
return handAct(build.NewFloorAgain(str(args, "name"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.TagTool, Description: versions.TagDescription, Input: branch(true),
Run: func(args map[string]any) (any, error) {
return tagAct(versions.NewTagAsk(str(args, "repository"), str(args, "branch"), str(args, "head"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.UntagTool, Description: versions.UntagDescription, Input: branch(false),
Run: func(args map[string]any) (any, error) {
return tagAct(versions.NewUntag(str(args, "repository"), str(args, "branch"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.TagAsksTool, Description: versions.TagAsksDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "only this repository's asks, <owner>/<name>"}}},
Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }},
{Name: versions.OldFormTool, Description: versions.OldFormDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{
"drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"},
"why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}},
Run: func(args map[string]any) (any, error) {
if node := str(args, "drop"); node != "" {
return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now()))
}
return versions.AnswerOldForm(of)
}},
{Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }},
{Name: versions.VersionsTool, Description: versions.VersionsDescription,
Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the package, image, bundle or archive name"},
"version": map[string]any{"type": "string", "description": "one version, to ask whether it is recorded"},
"digest": map[string]any{"type": "string", "description": "with version: the digest the holder received"}}},
Run: func(args map[string]any) (any, error) {
return versions.AnswerVersions(context.Background(), vs, str(args, "name"), str(args, "version"), str(args, "digest"))
}},
{Name: build.StuckTool,
Description: "Every build key that waits (building or queued), oldest first: its module, fingerprint, step words and since when, and each ask it " +
"waits on by language with its id, when it was put, its asking (1 for the first; the builder asks an ask again when it can no longer be " +
@@ -264,7 +362,65 @@ func main() {
a, err := build.DecodeReAsk(v)
return err == nil && !a.Answered.IsZero()
})
if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now)); err != nil {
vs := stdio.State(versions.State)
vr := versions.Reader{Keys: vs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := vs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
ts := stdio.State(versions.TagAsksState)
tags := askKept(ts, versions.TagAsksState, versions.TagAsksBucket, "the ask", 25*time.Second, func(v []byte) bool {
a, err := versions.DecodeTagAsk(v)
return err == nil && !a.Answered.IsZero()
})
tr := versions.Reader{Keys: ts.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := ts.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
fs := stdio.State(versions.FloorWaitsState)
fr := versions.Reader{Keys: fs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := fs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
ofs := stdio.State(versions.OldFormState)
ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := ofs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
bs := stdio.State(versions.BoundsState)
br := versions.Reader{Keys: bs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := bs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr)
// The builder's expiring keys (state bounds): what each bounds and when it ends.
tools = append(tools, stdio.Tool{Name: versions.BoundsTool, Description: versions.BoundsDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) {
rows, err := versions.AnswerBounds(br, time.Now())
return map[string]any{"bounds": rows}, err
}})
// What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked
// again, or one node's key dropped.
tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now))
// builder_first_views and builder_first_view_again: where the builder's first views of the indexer's and the assigner's
// states stand, and asking one again by hand (ADR 0615, DLV-M1).
tools = append(tools, views.StatusTools(delivery.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, time.Now)...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
os.Exit(1)
}
@@ -0,0 +1,174 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// forgeStream is the forge bridge's stream `forge` as the bus names it, where the builder's durable consumer hears a
// branch's end (design 51 *Tagged versions*).
const forgeStream = "FORGE"
// forgeBranchDeleted is the forge bridge's kind for a branch's end (delivery.ForgeBranchDeleted in the group's mesh-sdk).
const forgeBranchDeleted = "branch-deleted"
// checkerAsksStream is the checker's family `asks` as the bus names it, and tagWantedSubject its event tag-wanted
// (design 51 *Tagged versions*, DLV-C3): on the checker's own stream, so its subject is mesh.stream.<module>.event.*.
const (
checkerAsksStream = "ASKS"
tagWantedSubject = "mesh.stream." + delivery.CheckerModule + ".event.tag-wanted"
)
// forgeEnd is the part of a forge message the builder reads.
type forgeEnd struct {
Kind string `json:"kind"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Action string `json:"action"`
}
// ended says whether the message ends a branch: merged, a pull closed, or branch-deleted.
func (f forgeEnd) ended() bool {
switch f.Kind {
case delivery.ForgeMerged, forgeBranchDeleted:
return f.Branch != ""
case delivery.ForgePull:
return f.Action == delivery.PullClosed && f.Branch != ""
}
return false
}
// branchEnds hears the forge bridge's stream: every message wakes a floor whose bridge_tags was silent, and a branch's
// end removes its tagged versions, acknowledged only once all are removed.
type branchEnds struct {
acts versions.Acts
findings *findings
tagAsks func() jetstream.KeyValue
wake func(ctx context.Context)
logf func(string, ...any)
now func() time.Time
}
func (h *branchEnds) heard(ctx context.Context, m jetstream.Msg) {
if h.wake != nil {
h.wake(ctx)
}
var f forgeEnd
if err := json.Unmarshal(m.Data(), &f); err != nil || !f.ended() {
h.ack(m)
return
}
end, err := h.acts.BranchEnded(ctx, f.Repository, f.Branch)
if err != nil {
h.logf("the end of %s %s: the versions cannot be read, heard again: %v", f.Repository, f.Branch, err)
h.again(m)
return
}
id := f.Repository + "/" + f.Branch
if end.Done {
h.findings.clear(ctx, versions.RemovalNotDone, health.ScopeModule, id)
if kv := h.tagAsks(); kv != nil {
if err := kv.Delete(ctx, versions.TagAskKey(f.Repository, f.Branch)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
h.logf("the tag ask of %s %s stays in %s: %v", f.Repository, f.Branch, versions.TagAsksState, err)
}
}
h.ack(m)
return
}
since := h.now()
if md, err := m.Metadata(); err == nil {
since = md.Timestamp
}
if h.now().Sub(since) >= versions.RemovalFindingAfter {
h.findings.raise(ctx, finding{kind: versions.RemovalNotDone, scope: health.ScopeModule, id: id,
summary: end.Finding(f.Repository, f.Branch, since),
headline: "A branch's tagged versions are not removed",
explanation: "The branch ended, and the registry seats did not say removed for every version tagged for it within 15 minutes.",
resolved: "Every seat said removed.",
needs: "Look at the seats named: " + strings.Join(end.Seats(), ", ")})
}
h.logf("the end of %s %s: remove asked of %d tagged versions, heard again in a minute", f.Repository, f.Branch, len(end.Left))
h.again(m)
}
func (h *branchEnds) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
h.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// again leaves the message for the bus to give again in a minute: the retry is the bus's redelivery, not a timer here.
func (h *branchEnds) again(m jetstream.Msg) {
if err := m.NakWithDelay(versions.RemoveAgainAfter); err != nil {
h.logf("leaving %s for the bus to give again: %v", m.Subject(), err)
}
}
// tagWanted takes the checker's tag-wanted in: written to tag-asks as builder_tag writes it, answered by its watch.
func tagWanted(ctx context.Context, kv jetstream.KeyValue, m jetstream.Msg, by string, now time.Time, logf func(string, ...any)) {
if m.Subject() != tagWantedSubject {
_ = m.Ack() // the family's other events (the verdicts) are not the builder's
return
}
var t versions.TagWanted
err := json.Unmarshal(m.Data(), &t)
var key string
var value []byte
if err == nil {
key, value, err = versions.TagWantedAsk(t, by, now)
}
if err != nil {
logf("%s holds what is no tag-wanted, left: %v", m.Subject(), err)
_ = m.Ack()
return
}
if kv == nil {
logf("tag-wanted of %s %s is heard again: %s is not on the bus yet", t.Repository, t.Branch, versions.TagAsksState)
_ = m.NakWithDelay(versions.RemoveAgainAfter)
return
}
if _, err := kv.Put(ctx, key, value); err != nil {
logf("tag-wanted of %s %s is heard again: %v", t.Repository, t.Branch, err)
_ = m.Nak()
return
}
_ = m.Ack()
}
// tagsVia is build.Builder.Tags over the state tag-asks: the asks of the repository, read when a check's build is
// looked at; none while the state is not bound.
func tagsVia(kv func() jetstream.KeyValue, logf func(string, ...any)) build.TagOf {
return func(ctx context.Context, repository, head string) (string, bool) {
store := kv()
if store == nil {
return "", false
}
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(repository)+".>")
if err != nil {
logf("the tag asks of %s cannot be read, so its check builds no tagged version: %v", repository, err)
return "", false
}
var asks []versions.TagAsk
for key := range lister.Keys() {
e, err := store.Get(ctx, key)
if err != nil {
continue
}
if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
asks = append(asks, t)
}
}
return versions.TagFor(asks, repository, head)
}
}
@@ -0,0 +1,174 @@
package main
import (
"context"
"errors"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/heard"
"mesh-delivery-builder/internal/versions"
)
// findingsBucket is the builder's findings (novox/hq ADR 0564 §2): what it sees wrong now, written at its raise and
// deleted at its clear, which the condition keeper raises and clears.
var findingsBucket = health.FindingsBucket(delivery.BuilderModule)
// findings writes the builder's findings into its state, once bound; until then each is said in the log only.
type findings struct {
kv *process.AwaitedBucket
logf func(string, ...any)
now func() time.Time
// store stands in for kv in a test.
store findingStore
}
// findingStore is the part of the findings bucket written.
type findingStore interface {
Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error)
Put(ctx context.Context, key string, value []byte) (uint64, error)
Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error
}
// finding is one of the builder's findings: its kind, what it is about, and its words.
type finding struct {
kind, scope, id string
summary, headline, explanation, resolved, needs string
severity health.Severity
}
func (f *findings) bucket() findingStore {
switch {
case f == nil:
return nil
case f.store != nil:
return f.store
case f.kv == nil:
return nil
}
if kv := f.kv.KV(); kv != nil {
return kv
}
return nil
}
// BuildFailed is the finding of a failed trunk build (DLV-B7, design 25), about its module.
const BuildFailed = "build-failed"
// buildEnded raises the module's build-failed for a trunk build that failed and clears it at a later done one; a
// candidate's build (its ref named) is the check's to say.
func (f *findings) buildEnded(ctx context.Context, e delivery.BuildLogEntry) {
if e.Event != delivery.BuildEndedEvent || e.Ref != "" {
return
}
switch e.Outcome {
case delivery.BuildFailed:
f.raise(ctx, finding{kind: BuildFailed, scope: health.ScopeModule, id: e.Module,
summary: "the trunk build of " + e.Module + " at " + e.FP + " (commit " + e.Commit + ") failed",
headline: "A module's trunk build failed",
explanation: "The builder built " + e.Module + " from the trunk and the build failed, so its new version is not delivered.",
resolved: "A later trunk build of " + e.Module + " is done.",
needs: "builder_builds_since shows the build; builder_build_again builds it again."})
case delivery.BuildDone:
f.clear(ctx, BuildFailed, health.ScopeModule, e.Module)
}
}
// raise writes fd; it is written again only when its words changed, so the keeper's condition moves only then.
func (f *findings) raise(ctx context.Context, fd finding) {
key := health.FindingKey(fd.scope, fd.id, fd.kind)
kv := f.bucket()
if kv == nil {
f.logf("finding %s (not written, %s is not on the bus yet): %s", key, findingsBucket, fd.summary)
return
}
if e, err := kv.Get(ctx, key); err == nil {
if old, err := health.DecodeFinding(e.Value()); err == nil && old.Summary == fd.summary {
return
}
}
at := f.now().UTC()
sev := fd.severity
if sev == "" {
sev = health.Warning
}
value, err := health.EncodeFinding(health.Finding{Key: key, Kind: fd.kind, Subject: health.Subject{Scope: fd.scope, ID: fd.id},
Severity: sev, Summary: fd.summary, Headline: fd.headline, Explanation: fd.explanation, Resolved: fd.resolved, Needs: fd.needs,
Evidence: []health.Evidence{{At: at, Said: fd.summary}}, Source: "builder." + fd.kind, Observer: delivery.BuilderModule, At: at})
if err == nil {
_, err = kv.Put(ctx, key, value)
}
if err != nil {
f.logf("the finding %s cannot be written: %v", key, err)
}
}
// clear deletes the finding of kind about scope and id; one not there is nothing to clear.
func (f *findings) clear(ctx context.Context, kind, scope, id string) {
kv := f.bucket()
if kv == nil {
return
}
key := health.FindingKey(scope, id, kind)
if _, err := kv.Get(ctx, key); errors.Is(err, jetstream.ErrKeyNotFound) {
return
}
if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
f.logf("the finding %s cannot be cleared: %v", key, err)
}
}
// notHeard is the writer of the builder's finding of a family it does not hear (heard.Writer, ADR 0615 §5): written into
// its findings once bound, said in the log until then.
func (f *findings) notHeard(ctx context.Context) heard.Writer {
on := func(do func(findingStore)) {
switch {
case f.store != nil:
do(f.store)
case f.kv != nil:
f.kv.Ready(func(kv jetstream.KeyValue) { do(kv) })
}
}
return heard.Writer{Observer: delivery.BuilderModule, Logf: f.logf, Now: f.now,
Put: func(key string, value []byte) error {
on(func(kv findingStore) {
if _, err := kv.Put(ctx, key, value); err != nil {
f.logf("the finding %s cannot be written: %v", key, err)
}
})
return nil
},
Delete: func(key string) error {
on(func(kv findingStore) {
if _, err := kv.Get(ctx, key); err != nil {
return // none stands
}
if err := kv.Delete(ctx, key); err != nil {
f.logf("the finding %s cannot be cleared: %v", key, err)
}
})
return nil
}}
}
// floorSaid is the floor finding (design 51 *Health and conditions*: a floor source silent): raised for a name waiting on
// its floor, about that name, cleared once all three sources answered.
func (f *findings) floorSaid(ctx context.Context) func(versions.FloorWait, bool) {
return func(w versions.FloorWait, raised bool) {
if !raised {
f.clear(ctx, versions.FloorSilent, health.ScopeModule, w.Name)
return
}
f.raise(ctx, finding{kind: versions.FloorSilent, scope: health.ScopeModule, id: w.Name, summary: w.Finding(),
headline: "A version's floor is not known",
explanation: "The builder numbers a name only once its registry seat, the forge bridge's tags and the indexer's requirements all answered; one of them did not.",
resolved: "All three answered.",
needs: "builder_floor_waits names the silent source; builder_floor_again asks again by hand."})
}
}
@@ -9,9 +9,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -25,12 +26,17 @@ import (
// The build runner the build modules pull and run asks with: mesh-build-go's own package (novox/hq ADR 0593 §1),
// another repository's, by its published version, so the builder is tested against the real pulling code.
runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
@@ -72,6 +78,9 @@ type fakeBuildModules struct {
// waiting in their streams).
closed atomic.Bool
pullers []*runner.Puller
// hang, while it holds a channel, keeps every build step running until the channel is closed: a step that never ends,
// so the builder's step bound runs out (design 51's round-2 case).
hang atomic.Pointer[chan struct{}]
}
// open lets the nodes pull again and wakes their pullers.
@@ -110,6 +119,12 @@ func (f *fakeBuildModules) step(module, name string, args map[string]any) (any,
f.builds = append(f.builds, args)
}
f.mu.Unlock()
if h := f.hang.Load(); h != nil && name == "build" {
select {
case <-*h:
case <-f.ctx.Done():
}
}
switch {
case name == "fetch":
merge := ""
@@ -195,7 +210,11 @@ type bed struct {
builds jetstream.KeyValue
tools map[string]jetstream.KeyValue
instances jetstream.KeyValue
stop func() // stops the builder started last
tagAsks jetstream.KeyValue
tagBounds jetstream.KeyValue
packages jetstream.KeyValue // the indexer's packages, served by its read tool and views family
out *saidLog // the builder's process log, every instance's
stop func() // stops the builder started last
fake *fakeBuildModules
srvURL string
dir string
@@ -270,8 +289,23 @@ func startBedWith(t *testing.T, closed bool) *bed {
b.candidates = mk(delivery.CandidatesBucket, delivery.CandidatesHistory)
b.source = mk(delivery.SourceBucket, delivery.SourceHistory)
mk(delivery.ManifestsBucket, delivery.ManifestsHistory)
// The indexer and the assigner as the owners of the states the builder follows: their read tools and views families
// (ADR 0615, DLV-M1); n1 and n2 each have their durable consumer.
readers := []string{"n1_" + delivery.BuilderModule, "n2_" + delivery.BuilderModule}
for _, st := range []string{modulesState, manifestsState, candidatesState} {
viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, delivery.IndexerModule+".indexer_read_"+st, indexerViews, readers...)
}
b.packages = viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, delivery.PackagesState, delivery.IndexerModule+".indexer_read_packages", indexerViews, readers...)
viewstest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews, readers...)
b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory)
b.instances = mk(instancesBucket, 1)
// The tag asks and their bounds (design 51 *Tagged versions*): the bounds declared key-ttl, so an ended bound leaves the
// bus's marker a watch hears.
b.tagAsks = mk(versions.TagAsksBucket, 1)
if b.tagBounds, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: versions.TagAskBoundsBucket, History: 1, LimitMarkerTTL: time.Minute}); err != nil {
t.Fatal(err)
}
b.out = &saidLog{}
// The builder's module log, as the runtime raises it (ADR 0297): its stream on the log's subjects.
logStream := buscompose.LogStreamName(delivery.BuilderModule, delivery.BuilderLog)
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: logStream, Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"},
@@ -331,15 +365,24 @@ func (b *bed) report(t *testing.T, module, node, language, version, recipe strin
// module writes modules.<name> as the indexer does for a module with a new-form fingerprint, and its manifest by digest.
func (b *bed) module(t *testing.T, name, fixture, path string) string {
return b.moduleWith(t, name, fixture, path, map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}})
return b.moduleWith(t, name, fixture, path, bothToolchains)
}
// bothToolchains are the toolchains the bed's nodes report, as a module's fingerprint names them.
var bothToolchains = map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}
// moduleWith is module with the toolchains its fingerprint was computed with.
func (b *bed) moduleWith(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed) string {
return b.moduleCommit(t, name, fixture, path, toolchains, commit)
}
func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at string) string {
t.Helper()
return b.moduleFP(t, name, fixture, path, toolchains, at, fp, 0)
}
// moduleFP is moduleCommit with the new-form fingerprint fp2 and the source revision src of its merge (0: none).
func (b *bed) moduleFP(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at, fp2 string, src uint64) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", fixture))
if err != nil {
@@ -357,8 +400,8 @@ func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains
if _, err := manifests.Put(b.ctx, digest, mv); err != nil {
t.Fatal(err)
}
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp,
Manifest: digest, Path: path, Toolchains: toolchains})
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2,
Manifest: digest, Path: path, Toolchains: toolchains, Src: src})
if err != nil {
t.Fatal(err)
}
@@ -389,16 +432,58 @@ func (b *bed) start(t *testing.T, node string, env map[string]string) {
}
runCtx, stop := context.WithCancel(b.ctx)
done := make(chan error, 1)
go func() { done <- run(runCtx, func(k string) string { return e[k] }, io.Discard) }()
go func() { done <- run(runCtx, func(k string) string { return e[k] }, b.out) }()
var once sync.Once
b.stop = func() { once.Do(func() { stop(); <-done }) }
t.Cleanup(b.stop)
}
// saidLog is the builder's process log as its instances write it.
type saidLog struct {
mu sync.Mutex
buf strings.Builder
}
func (l *saidLog) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
// awaitSaid reads the process log until it holds want, within 20 s, and answers the line.
func (b *bed) awaitSaid(t *testing.T, want string) string {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for {
b.out.mu.Lock()
all := b.out.buf.String()
b.out.mu.Unlock()
for _, line := range strings.Split(all, "\n") {
if strings.Contains(line, want) {
return line
}
}
if time.Now().After(deadline) {
t.Fatalf("the builder never said %q; it said:\n%s", want, all)
}
select {
case <-b.ctx.Done():
t.Fatal(b.ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
}
// await reads the build key until pred holds of it, within 20 s.
func (b *bed) await(t *testing.T, module string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
t.Helper()
key, err := delivery.BuilderBuildKey(module, fp)
return b.awaitFP(t, module, fp, pred)
}
// awaitFP is await of the build key of fingerprint at.
func (b *bed) awaitFP(t *testing.T, module, at string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
t.Helper()
key, err := delivery.BuilderBuildKey(module, at)
if err != nil {
t.Fatal(err)
}
@@ -425,9 +510,31 @@ func isState(state string) func(delivery.BuilderBuild) bool {
// the digests of both bundles, both build modules' toolchains, after a fetch and a build on one node (ADR 0478 clause 4).
func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
b := startBed(t)
digest := b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck")
b.start(t, "n1", nil)
// DLV-B10 (design 51, round 2): the builder's versions, findings and the floor's three sources are on the bus; the seat
// answers 0.1.4, so netcheck's bundles are numbered from 0.1.5. Two merges close together, the first replaced before
// its claim: the second is numbered at once, and the first never.
reg := b.registry(t, "n1", "0.1.4")
const netcheck = "catalogue-a67f0196-netcheck.module.json"
first := "src2:" + strings.Repeat("1", 64)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, first, 1)
digest := b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, fp, 2)
// DLV-B6: a tag ask kept and never numbered (no check builds its head) is narrated once its bound ends on the bus, with
// what it waits on, and its bound is written again; no timer runs in the builder.
key, ask, err := versions.NewTagAsk("novox/mesh-sdk", "feat/x", "abc", "a group member's check", "me@shanks", time.Now())
if err != nil {
t.Fatal(err)
}
if _, err := b.tagAsks.Put(b.ctx, key, ask); err != nil {
t.Fatal(err)
}
b.start(t, "n1", map[string]string{"MESH_BUILDER_TAG_ASK_BOUND": "1s", "MESH_BUILDER_STEP_BOUND": "1s"})
v := b.await(t, "netcheck", isState(delivery.BuildDone))
for _, a := range []string{"tools-go", "tools-typescript"} {
es := reg.of(t, "netcheck/"+a)
if len(es) != 1 || es[0].Version != "0.1.5" || es[0].Fingerprint != fp || es[0].State != versions.Numbered || es[0].Digest != digestOf(a) {
t.Errorf("versions of netcheck/%s = %+v, want the second merge numbered 0.1.5 at once with its digest, the first never", a, es)
}
}
if v.By != "n1.mesh-delivery-builder" || v.Manifest != digest || len(v.Artifacts) != 2 {
t.Fatalf("build = %+v", v)
}
@@ -439,9 +546,21 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" {
t.Errorf("toolchains = %+v", v.Toolchains)
}
// ADR 0618 §1: the done key names the reference forms it is written in.
if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" {
t.Errorf("needs = %v, want the first form", v.Needs)
}
// 1955/L7 (ADR 0571 §1): the build's start and end are in the module log build-log under the module's key, read as a
// reader with the log's read reads them, the end naming the manifest and what was made.
ended := b.logged(t, "netcheck")
// DLV-B10: between them the build log holds the build's narrations (build.said), each version numbered, and why.
var ended []delivery.BuildLogEntry
for _, e := range b.logged(t, "netcheck") {
if e.Event != delivery.BuildSaidEvent {
ended = append(ended, e)
} else if e.Why != versions.SaidNumbered || !strings.Contains(e.Line, "numbered 0.1.5") || e.FP != fp {
t.Errorf("said = %+v, want netcheck's versions numbered", e)
}
}
if len(ended) != 2 || ended[0].Event != delivery.BuildStartedEvent || ended[1].Event != delivery.BuildEndedEvent ||
ended[1].Manifest != digest || len(ended[1].Made) != 2 || ended[1].Node != "n1" {
t.Errorf("logged = %+v, want the start and the end of netcheck's build", ended)
@@ -451,38 +570,168 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
t.Errorf("%s called %d times, want %d (on one node, ADR 0478 clause 4)", tool, got, want)
}
}
}
// TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey: two builders on two nodes start together on one module key of an
// archive only (no toolchain named, as the indexer writes it); the second takes over the key the first holds building
// (ADR 0478 clause 6), and the key ends done, written once.
func TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { // 1607/AC7
b := startBed(t)
b.archive(t, "ca-trust", "modules/ca-trust", commit)
b.start(t, "n1", nil)
b.start(t, "n2", nil)
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
if len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files") {
t.Fatalf("build = %+v, want the archive's digest", v)
said := b.awaitSaid(t, "the tag ask of novox/mesh-sdk feat/x at abc (feat-x) is not answered after")
if !strings.Contains(said, "the check's build of abc is not claimed yet") {
t.Errorf("the narration = %q, want what the ask waits on", said)
}
if n := b.fake.count("mesh-build-go.build_go_build"); n < 1 { // each instance that built, on one node
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
// And in the build log, under the builder's own key (the ruling of 13:17: build.said).
if !slices.ContainsFunc(b.logged(t, delivery.BuilderModule), func(e delivery.BuildLogEntry) bool {
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidUnanswered && e.FP == key && e.Commit == "abc" &&
strings.Contains(e.Line, "the check's build of abc is not claimed yet")
}) {
t.Errorf("the build log of %s = %+v, want the unanswered tag ask said", delivery.BuilderModule, b.logged(t, delivery.BuilderModule))
}
if e, err := b.tagAsks.Get(b.ctx, key); err != nil {
t.Fatal(err)
} else if a, _ := versions.DecodeTagAsk(e.Value()); a.Answered.IsZero() || !a.Unanswered() {
t.Errorf("the tag ask = %+v, want kept and answered, no version yet", a)
}
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(50 * time.Millisecond) {
if _, err := b.tagBounds.Get(b.ctx, key); err == nil {
break
} else if time.Now().After(deadline) {
t.Fatalf("the bound is not written again after its narration: %v", err)
}
}
// Design 32, ADR 0615 §1: a package's name the indexer writes later reaches the builder by its packages' views family
// (never their bucket), and looks at the module again; the done bundles are kept, not numbered twice.
if _, err := b.packages.Put(b.ctx, "netcheck.0.1", []byte(`{"v":1,"name":"netcheck","package":"netcheck/lib","series":"0.1"}`)); err != nil {
t.Fatal(err)
}
b.awaitSaid(t, "names a package of netcheck")
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 1 || got[0] != "0.1.5" {
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.5 once", got)
}
// DLV-B10: builder_publish_again after a published takes a new number, and says it numbered.
for _, a := range []string{"tools-go", "tools-typescript"} {
reg.published(t, "netcheck/"+a, "0.1.5", digestOf(a))
}
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Published })
key, again, err := build.NewPublishAgain("netcheck/tools-go", fp, "the holder lost it", "me@shanks", time.Now())
if err != nil {
t.Fatal(err)
}
if a := reg.answered(t, key, again); a.Refused || a.Do != versions.AgainRenumber || a.Version != "0.1.6" {
t.Errorf("builder_publish_again = %+v, want a new number, 0.1.6", a)
}
// Running-testing note (review round 1): two claims never get one number on the bus's own compare-and-set, as
// the builder's state versions keeps them: eight claims of one name at once, eight versions.
store := versions.Versions{Store: awaitedVersions{process.AwaitBucket(b.ctx, b.js, nil, versions.Bucket, "", t.Logf)}}
numbers := make(chan string, 8)
var claims sync.WaitGroup
for i := range 8 {
claims.Go(func() {
d, _, err := store.Number(b.ctx, versions.Claim{Name: "netcheck/race", Kind: versions.KindBundle, Fingerprint: fmt.Sprintf("f%d", i)})
if err != nil {
t.Error(err)
}
numbers <- d.Version
})
}
claims.Wait()
close(numbers)
got := map[string]bool{}
for v := range numbers {
got[v] = true
}
if len(got) != 8 {
t.Errorf("eight claims of one name at once were numbered %v, want eight versions", slices.Sorted(maps.Keys(got)))
}
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 2 || got[1] != "0.1.6" {
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.6 said after 0.1.5", got)
}
// DLV-B10: a build module's bound running out fails the trunk build, which is the builder's finding build-failed; the
// fingerprint built again keeps its number, said with why, and its done build clears the finding.
release := make(chan struct{})
b.fake.hang.Store(&release)
third := "src2:" + strings.Repeat("4", 64)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3)
if f := b.awaitFP(t, "netcheck", third, isState(delivery.BuildFailed)); !strings.Contains(f.Error, "did not end within") {
t.Errorf("the failed build = %+v, want its bound run out", f)
}
failed := health.FindingKey(health.ScopeModule, "netcheck", BuildFailed)
reg.awaitFinding(t, failed, true)
b.fake.hang.Store(nil)
close(release)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3) // the indexer writes it again
b.awaitFP(t, "netcheck", third, isState(delivery.BuildDone))
b.awaitSaid(t, "kept: fingerprint "+third+" is numbered under it")
if !slices.ContainsFunc(b.logged(t, "netcheck"), func(e delivery.BuildLogEntry) bool {
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidKept && e.FP == third && strings.Contains(e.Line, "kept: fingerprint "+third)
}) {
t.Errorf("the build log of netcheck says no version kept at %s", third)
}
reg.awaitFinding(t, failed, false)
// DLV-B4: the seats' refused and removed, heard on the builder's durable consumer, move a numbered version: a retention
// removal turns the entry removed with its reason, a refusal refused with the holder's reason and the bytes it refused.
reg.outcome(t, ociartifactregistry.SubjectRemoved, versions.Outcome{Name: "netcheck/tools-typescript", Version: "0.1.5", Kind: "bundle", Holder: "distribution", Reason: "retention"})
reg.outcome(t, ociartifactregistry.SubjectRefused, versions.Outcome{Name: "netcheck/tools-go", Version: "0.1.6", Digest: digestOf("other"), Kind: "bundle", Holder: "distribution", Reason: "the cap"})
reg.awaitOf(t, "netcheck/tools-typescript", func(es []versions.Entry) bool {
return slices.ContainsFunc(es, func(e versions.Entry) bool {
return e.Version == "0.1.5" && e.State == versions.Removed && e.Reason == "retention"
})
})
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool {
return slices.ContainsFunc(es, func(e versions.Entry) bool {
return e.Version == "0.1.6" && e.State == versions.Refused && e.Reason == "the cap" && e.RefusedDigest == digestOf("other")
})
})
// DLV-B6: a branch's end (the forge bridge's merged) asks the seat to remove each version tagged for it, the reason
// tag-gone, and the seat's removed turns it removed.
tagged := versions.Entry{Name: "mesh-sdk/go", Kind: versions.KindPackage, Series: "0.1", Version: "0.1.15-feat-x.1", Tag: "feat-x", Module: "mesh-sdk",
Fingerprint: "src2:" + strings.Repeat("5", 64), Repository: "novox/mesh-sdk", Seat: gopackageregistry.Seat, State: versions.Published, At: time.Now().UTC()}
raw, _ := json.Marshal(tagged)
if _, err := reg.versions.Put(b.ctx, versions.Key(tagged.Name, tagged.Version), raw); err != nil {
t.Fatal(err)
}
if _, err := b.js.Publish(b.ctx, process.ModuleStreamSubject(delivery.ForgeBridgeModule, "forge"),
[]byte(`{"kind":"`+delivery.ForgeMerged+`","repository":"novox/mesh-sdk","branch":"feat/x"}`)); err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) {
reg.mu.Lock()
asked := slices.ContainsFunc(reg.removes, func(a map[string]any) bool {
return a["name"] == tagged.Name && a["version"] == tagged.Version && a["reason"] == "tag-gone"
})
reg.mu.Unlock()
if asked {
break
}
if time.Now().After(deadline) {
t.Fatalf("the seat's remove of %s@%s was not asked at the branch's end: %v", tagged.Name, tagged.Version, reg.removes)
}
}
reg.outcome(t, gopackageregistry.SubjectRemoved, versions.Outcome{Name: tagged.Name, Version: tagged.Version, Kind: versions.KindPackage, Holder: "gitea", Reason: "tag-gone"})
reg.awaitOf(t, tagged.Name, func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Removed })
}
// TestAnArchiveOnlyModuleAndARootModuleAreBuilt: the indexer gives an archive no language, so modules.ca-trust has no
// toolchains; the builder builds it on the build module's nodes all the same (review round 4). A module whose path is "."
// is given to the build tools as it is (1615/AC4). One table: the two tests were merged to pay for the enrolment's.
// is given to the build tools as it is (1615/AC4). Two builders on two nodes start together on the archive's key: the
// second takes over the key the first holds building (ADR 0478 clause 6), and the key ends done, written once (1607/AC7).
// One table: three tests were merged, to pay for the enrolment's and to bring mesh-delivery's count down (DLV-B8).
func TestAnArchiveOnlyModuleAndARootModuleAreBuilt(t *testing.T) {
for _, c := range []struct{ name, path string }{{"no toolchain", "modules/ca-trust"}, {"root", delivery.RootPath}} {
for _, c := range []struct {
name, path string
two bool
}{{"no toolchain", "modules/ca-trust", false}, {"root", delivery.RootPath, false}, {"two instances together", "modules/ca-trust", true}} {
t.Run(c.name, func(t *testing.T) {
b := startBed(t)
b.archive(t, "ca-trust", c.path, commit)
b.start(t, "n1", nil)
if c.two {
b.start(t, "n2", nil)
}
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
if c.name == "no toolchain" && (len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (")) {
t.Errorf("build = %+v, want done naming no toolchain", v)
}
if c.two && (len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files")) {
t.Errorf("build = %+v, want the archive's digest", v)
}
if n := b.fake.count("mesh-build-go.build_go_build"); c.two && n < 1 { // each instance that built, on one node
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
}
})
}
}
@@ -791,7 +1040,12 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) {
}
defer sub.Close()
var calls atomic.Int64
s, err := sub.Subscribe("mesh.mod.*.call.>", func(*nats.Msg) { calls.Add(1) })
// A build module's tool, never the owners' read tools the builder's first views ask (DLV-M1).
s, err := sub.Subscribe("mesh.mod.*.call.>", func(m *nats.Msg) {
if strings.HasPrefix(m.Subject, "mesh.mod.mesh-build-") {
calls.Add(1)
}
})
if err != nil {
t.Fatal(err)
}
@@ -910,3 +1164,184 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
t.Errorf("done = %+v", done)
}
}
// registry is the part of the bed design 51's builder cases need: the builder's states versions, findings, floor-waits and
// reasks, EVENTS with the seat oci-artifact-registry's outcomes and the builder's durable consumer of it, the builder's
// own events kept, and the floor's three sources served on the bus as their holders answer through the tool runner
// ({result}): the seat's versions, the forge bridge's bridge_tags and the indexer's indexer_requirements.
type registry struct {
b *bed
versions, findings, reasks jetstream.KeyValue
mu sync.Mutex
numbered []delivery.VersionNumbered
removes []map[string]any
}
func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *registry {
t.Helper()
r := &registry{b: b}
mk := func(name string) jetstream.KeyValue {
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: name, History: 1})
if err != nil {
t.Fatal(err)
}
return kv
}
r.versions, r.findings, r.reasks = mk(versions.Bucket), mk(findingsBucket), mk(build.ReAsksBucket)
mk(versions.FloorWaitsBucket)
// EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer.
for stream, subjects := range map[string][]string{
eventsStream: {delivery.ArtifactsGoneSubject, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved,
gopackageregistry.SubjectPublished, gopackageregistry.SubjectRefused, gopackageregistry.SubjectRemoved},
forgeStream: {process.ModuleStreamSubject(delivery.ForgeBridgeModule, ">")},
} {
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil {
t.Fatal(err)
}
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, stream, jetstream.ConsumerConfig{Durable: node + "_" + delivery.BuilderModule,
AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
// The Go seat's remove, as its holder answers it; each call is kept.
rm, err := b.js.Conn().Subscribe("mesh.seat."+gopackageregistry.Seat+".call.remove.>", func(m *nats.Msg) {
var args map[string]any
_ = json.Unmarshal(m.Data, &args)
r.mu.Lock()
r.removes = append(r.removes, args)
r.mu.Unlock()
_ = m.Respond([]byte(`{"result":{},"node":"ctl"}`))
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rm.Unsubscribe() })
own := "mesh.mod." + delivery.BuilderModule + ".event.>"
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "BUILDER_EVENTS", Subjects: []string{own}}); err != nil {
t.Fatal(err)
}
nc := b.js.Conn()
// As a build module hears it: on the contract's subject, read by the contract's decoder (BG2's late publish).
sub, err := nc.Subscribe(delivery.VersionNumberedSubject, func(m *nats.Msg) {
if nb, err := delivery.DecodeVersionNumbered(m.Data); err == nil {
r.mu.Lock()
r.numbered = append(r.numbered, nb)
r.mu.Unlock()
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
var listed []map[string]string
for _, v := range seatVersions {
listed = append(listed, map[string]string{"version": v})
}
for subject, answer := range map[string]any{
"mesh.seat." + ociartifactregistry.Seat + ".call.versions.>": map[string]any{"versions": listed},
"mesh.mod.mesh-delivery-forge-bridge.call.bridge_tags.>": map[string]any{"tags": []string{}},
"mesh.mod." + delivery.IndexerModule + ".call.indexer_requirements.>": map[string]any{"requirements": []string{}},
} {
reply, _ := json.Marshal(map[string]any{"result": answer, "node": "ctl"})
s, err := nc.Subscribe(subject, func(m *nats.Msg) { _ = m.Respond(reply) })
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Unsubscribe() })
}
return r
}
// of is every entry versions holds of name.
func (r *registry) of(t *testing.T, name string) []versions.Entry {
t.Helper()
l, err := r.versions.ListKeysFiltered(r.b.ctx, versions.NameFilter(name))
if err != nil {
t.Fatal(err)
}
defer func() { _ = l.Stop() }()
var out []versions.Entry
for k := range l.Keys() {
e, err := r.versions.Get(r.b.ctx, k)
if err != nil {
t.Fatal(err)
}
var v versions.Entry
if err := json.Unmarshal(e.Value(), &v); err != nil {
t.Fatal(err)
}
out = append(out, v)
}
return out
}
// awaitOf reads name's entries until pred holds of them, within 20 s.
func (r *registry) awaitOf(t *testing.T, name string, pred func([]versions.Entry) bool) []versions.Entry {
t.Helper()
var es []versions.Entry
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if es = r.of(t, name); pred(es) {
return es
}
}
t.Fatalf("versions of %s = %+v within 20 s", name, es)
return es
}
// published says the artifact seat's published for name@version, as its holder does.
func (r *registry) published(t *testing.T, name, version, digest string) {
t.Helper()
r.outcome(t, ociartifactregistry.SubjectPublished, versions.Outcome{Name: name, Version: version, Digest: digest, Kind: "bundle", Holder: "distribution"})
}
// outcome says a seat's outcome on subject, as its holder does.
func (r *registry) outcome(t *testing.T, subject string, o versions.Outcome) {
t.Helper()
data, _ := json.Marshal(o)
msg := nats.NewMsg(subject)
msg.Header.Set("x-event-id", subject+"-"+o.Name+"-"+o.Version)
msg.Data = data
if _, err := r.b.js.PublishMsg(r.b.ctx, msg); err != nil {
t.Fatal(err)
}
}
// answered writes a reasks value and reads it until the builder answered it, within 20 s.
func (r *registry) answered(t *testing.T, key string, value []byte) build.ReAsk {
t.Helper()
if _, err := r.reasks.Put(r.b.ctx, key, value); err != nil {
t.Fatal(err)
}
var a build.ReAsk
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := r.reasks.Get(r.b.ctx, key); err == nil && json.Unmarshal(e.Value(), &a) == nil && !a.Answered.IsZero() {
return a
}
}
t.Fatalf("reasks.%s = %+v, not answered within 20 s", key, a)
return a
}
// awaitFinding reads the builder's finding key until it is there (raised) or gone, within 20 s.
func (r *registry) awaitFinding(t *testing.T, key string, raised bool) {
t.Helper()
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if _, err := r.findings.Get(r.b.ctx, key); (err == nil) == raised {
return
}
}
t.Fatalf("the finding %s raised = %v within 20 s, want %v", key, !raised, raised)
}
// numberedOf are the version.numbered the builder said for name, in order.
func (r *registry) numberedOf(name string) []string {
r.mu.Lock()
defer r.mu.Unlock()
var out []string
for _, nb := range r.numbered {
if nb.Name == name {
out = append(out, nb.Version)
}
}
return out
}
@@ -5,6 +5,7 @@
package main
import (
"cmp"
"context"
"fmt"
"io"
@@ -19,14 +20,17 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
const (
@@ -61,22 +65,53 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return err
}
modules, err := process.EssentialBucket(ctx, js, "builder", delivery.ModulesBucket, "know any fingerprint to build")
// The indexer's modules, manifests, candidates and packages and the assigner's placement are their owners' states, read by the
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, DLV-M1). An owner that
// is down never holds the start: each is read on a goroutine once its first view is answered, and until then the
// unanswered first view is the builder's finding.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
manifests, err := process.EssentialBucket(ctx, js, "builder", delivery.ManifestsBucket, "read what a module builds")
defer calls.Close()
reader := &views.ProcessReader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf}
findingsKV := process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings, a first view unanswered past its bound among them, are said in its log only", logger.Printf)
reader.Findings = findingsKV.KV
modules, err := reader.Follow(delivery.IndexerModule, modulesState, delivery.IndexerModule+".indexer_read_modules", indexerViews)
if err != nil {
return err
}
manifests, err := reader.Follow(delivery.IndexerModule, manifestsState, delivery.IndexerModule+".indexer_read_manifests", indexerViews)
if err != nil {
return err
}
// The candidates' fingerprints are built too (issue 1684).
candidates, err := reader.Follow(delivery.IndexerModule, candidatesState, delivery.IndexerModule+".indexer_read_candidates", indexerViews)
if err != nil {
return err
}
// The indexer's packages, whose names a package is numbered under (design 32).
packages, err := reader.Follow(delivery.IndexerModule, delivery.PackagesState, delivery.IndexerModule+".indexer_read_packages", indexerViews)
if err != nil {
return err
}
placement, err := reader.Follow(delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews)
if err != nil {
return err
}
// The candidates' fingerprints are built too (issue 1684): a builder that cannot read them builds the trunk's.
candidates := process.OptionalBucket(ctx, js, delivery.CandidatesBucket, "no candidate's fingerprint is built until the builder starts again; the trunk's are", logger.Printf)
bound := defaultStepBound
if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" {
if bound, err = time.ParseDuration(v); err != nil {
return fmt.Errorf("MESH_BUILDER_STEP_BOUND: %w", err)
}
}
// An unanswered tag ask is narrated every TagAskBound (design 51: five minutes); a test gives a shorter one.
tagAskBound := versions.TagAskBound
if v := strings.TrimSpace(getenv("MESH_BUILDER_TAG_ASK_BOUND")); v != "" {
if tagAskBound, err = time.ParseDuration(v); err != nil {
return fmt.Errorf("MESH_BUILDER_TAG_ASK_BOUND: %w", err)
}
}
tools := &buildrun.ToolchainSet{}
// Each build module's part of a build is an ask on its build seat's stream, pulled by a build node with a free slot
// (novox/hq ADR 0525): the builder chooses no node, so it reads neither presence nor have and holds no capacity.
@@ -103,12 +138,31 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
heard := buildrun.HearEnds(ctx, asks, asks, buildModules, logger.Printf)
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
// The builder asks the controller, as its own account, whether the artifact store still holds a done key's blobs
// (the seat verb held, novox/hq issue 1937) before it leaves the key done.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
defer calls.Close()
// (the seat verb held, novox/hq issue 1937) before it leaves the key done, on its calls connection.
// The builder numbers each trunk build at its claim (novox/hq design 51 *The versions*): its state versions, bound once
// the bus holds it; the floor's three sources asked as its own account; each new number said as version.numbered.
versionsKV := process.AwaitBucket(ctx, js, conn, versions.Bucket, "no trunk build is numbered: each is built unnumbered and published by its digest", logger.Printf)
found := &findings{kv: findingsKV, logf: logger.Printf, now: time.Now}
floorWaits := &versions.FloorWaits{Logf: logger.Printf, Said: found.floorSaid(context.WithoutCancel(ctx))}
// The names waiting on their floor are kept in the state floor-waits (DLV-B4): a restarted builder takes them in, so
// their silent sources' events still wake them; until the state is bound they are kept here only.
process.AwaitBucket(ctx, js, conn, versions.FloorWaitsBucket, "the names waiting on their floor are kept in memory only, and builder_floor_waits shows none", logger.Printf).
Ready(func(kv jetstream.KeyValue) {
floorWaits.Keep(kvKeeper{ctx: ctx, kv: kv}, heldFloorWaits(ctx, kv, logger.Printf))
})
// A package is numbered under its name in its ecosystem, read from the indexer's packages state (design 32).
numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{versionsKV}}, Floor: floorVia(calls.Call), Waits: floorWaits,
Names: versions.NamesIn(indexerPackages{packages}),
Said: func(nb delivery.VersionNumbered) {
if err := emitNumbered(context.WithoutCancel(ctx), js, cred, nb); err != nil {
logger.Printf("%v", err)
}
}}
// A publish that ends badly is a finding (design 51 *Health and conditions*): a holder's refused, a version no holder
// answered within its bound (a key of the state bounds with its own time to live), a seat's outcome that does not read.
pubBoundsKV := process.AwaitBucket(ctx, js, conn, versions.BoundsBucket, "a publish no holder answers, and a node holding the old form seven days, are not raised", logger.Printf)
pubs := &publishes{bounds: pubBoundsKV.KV, versions: numberer.Versions, findings: found, bound: versions.PublishBound, logf: logger.Printf}
numberer.Publishing = pubs.arm
b := &build.Builder{
Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module},
Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound},
@@ -122,20 +176,52 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
Stored: storedVia(calls.Call),
Node: cred.Node,
}
tagAsksKV := process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf)
// A check's build of a head a kept tag ask names numbers its packages tagged (design 51 *Tagged versions*), and the ask
// is marked numbered with its versions.
b.Tags = tagsVia(tagAsksKV.KV, logger.Printf)
b.Numbers = build.VersionNumbers{N: numberer, TagNumbered: tagNumbered(tagAsksKV.KV, logger.Printf, time.Now)}
// An ask not answered within five minutes is narrated with what it waits on: its bound's end on the bus says when.
boundsKV := process.AwaitBucket(ctx, js, conn, versions.TagAskBoundsBucket, "an unanswered tag ask is not narrated", logger.Printf)
bounds := &tagBounds{asks: tagAsksKV.KV, bounds: boundsKV.KV, bound: tagAskBound, waits: floorWaits.Waiting, logf: logger.Printf, now: time.Now,
log: func(e delivery.BuildLogEntry) { b.Log(e) }}
// A state bound later numbers the builds looked at without it.
versionsKV.Ready(func(jetstream.KeyValue) { b.Reconsider(ctx) })
// The versions' hand acts (builder_publish_again, builder_floor_again) come through reasks, as builder_build_again.
acts := versions.Acts{Numberer: numberer, Done: doneVia(b), Reconsider: b.Reconsider, Remove: removeVia(calls.Call),
BuildAgain: func(ctx context.Context, module, fp, why, by string) (string, bool) {
r := b.BuildAgain(ctx, module, fp, why, by, buildAgainBound)
if r.Refused != "" {
return r.Refused, true
}
if r.Failed != "" {
return "built again, and it failed before its asks were put: " + r.Failed, false
}
return fmt.Sprintf("built again as revision %d", r.Revision), false
}}
// A candidate's preview is merged onto the trunk head the indexer previewed it onto: source.main at the candidate's base
// revision (the gate's source state). The source's revisions are watched with their history (a direct get of one revision
// is not what a read grants): a revision heard after the candidate that names it builds that candidate then. Where it
// cannot be read the trunk's keys are built and the candidates wait (issue 1945).
errs := make(chan error, 6)
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: b.TrunkMoved, errs: errs, logf: logger.Printf}
errs := make(chan error, 10)
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: func(ctx context.Context) {
// A source revision heard is the forge's merge: it wakes a floor whose bridge_tags was silent (design 51 *The floor*).
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
b.TrunkMoved(ctx)
}, errs: errs, logf: logger.Printf}
trunks.start()
b.Trunk = trunks.head
// The builder's log (ADR 0571 §1): each build's start and end in its module log `build-log`, read by the view, the
// indexer and the assigner through their reads, and by the operator's command nox builds through the tool builder_builds_since.
b.Log = func(e delivery.BuildLogEntry) {
if e.Node == "" { // a narration of no one build (a tag ask, the old form's window) is this instance's
e.Node = cred.Node
}
if err := appendLog(context.WithoutCancel(ctx), js, cred, e); err != nil {
logger.Printf("%v", err)
}
// A failed trunk build is the builder's finding build-failed, cleared by a later done one (DLV-B7).
found.buildEnded(context.WithoutCancel(ctx), e)
}
b.Said = func(e build.AskedAgain) {
if err := emitAskedAgain(context.WithoutCancel(ctx), js, cred, e); err != nil {
@@ -165,42 +251,219 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// placement is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf)
if err != nil {
return err
// What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the
// builder's own account, for every node the placement names, and kept in the state engine-reads once it is bound.
reads := newEngineReads(calls.CallOn, gate, logger.Printf)
// The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form
// reads it and drops a retired node by hand.
reads.ctx = context.WithoutCancel(ctx)
reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now, log: func(e delivery.BuildLogEntry) { b.Log(e) },
bounds: pubBoundsKV.KV,
said: func(body versions.OldFormClosedBody) {
if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil {
logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err)
}
}}
acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) {
answer, err := reads.window.drop(ctx, node, by, why, at)
if err == nil {
reads.stepWindow()
}
return answer, err
}
process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf).
Ready(func(kv jetstream.KeyValue) {
reads.window.bind(context.WithoutCancel(ctx), kv)
reads.stepWindow()
})
gate.OnPlaced = reads.placed
b.Forms = reads.built
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
placementErrs := make(chan error, 1)
go func() {
select {
case <-ctx.Done():
return
case <-placement.Follower().Ready():
}
pe, err := buildrun.FollowPlacement(ctx, reader, gate, b.NodesChanged, logger.Printf)
if err != nil {
placementErrs <- err
return
}
placementErrs <- <-pe
}()
if err := watch.start(); err != nil {
return err
}
// A build module says each time it wrote its toolchains key: its stream, and the builder's durable consumer on it, are
// made by the controller. Where the stream is not there yet the builder says so and is started again with the build
// module.
// Each consumer that is not there yet is the finding family-not-heard naming its stream until it binds at a later
// start (heard.Writer; ADR 0615 §5); one there that cannot be heard stops the builder, as before.
notHeard := found.notHeard(ctx)
var stopped error
hear := func(stream, consequence string, each func(jetstream.Msg)) {
_ = notHeard.Bind(stream, consequence, func() error {
cons, err := cred.EventConsumer(ctx, js, stream)
if err != nil {
return fmt.Errorf("the consumer %s_%s on %s does not exist yet: %w", cred.Node, cred.Module, stream, err)
}
cc, err := process.HearEvents(cons, each, logger.Printf)
if err != nil {
stopped = cmp.Or(stopped, err)
return err
}
go func() { <-ctx.Done(); cc.Stop() }()
return nil
})
}
for _, module := range []string{buildrun.GoBuildModule, buildrun.NodeBuildModule} {
stream := toolchainStreams[module]
cons, err := cred.EventConsumer(ctx, js, stream)
if err != nil {
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s's reports are not heard until the builder starts again: %v", cred.Node, cred.Module, stream, module, err)
continue
}
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { watch.Reported(m) }, logger.Printf)
if err != nil {
return err
}
defer cc.Stop()
hear(toolchainStreams[module], module+"'s toolchain reports are not heard until the builder starts again", func(m jetstream.Msg) { watch.Reported(m) })
}
// The seat mesh-artifact-sweep says artifacts-gone when it found a key's blob gone (issue 1937, ADR 0571 §2): the
// builder's durable consumer on EVENTS, made by the grants part from the manifest's consumes. Where it is not there yet the builder says
// so; it still asks held at its start and at every module value.
switch cons, err := cred.EventConsumer(ctx, js, eventsStream); {
case err != nil:
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v",
cred.Node, cred.Module, eventsStream, delivery.ArtifactsGoneEvent, err)
default:
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { heardGone(ctx, b, m, logger.Printf) }, logger.Printf)
if err != nil {
return err
// The same consumer carries what wakes a floor (design 51 *The floor*): the indexer's manifest-accepted and the
// condition keeper's condition-cleared.
floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf}
// And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version.
seats := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs}
hear(eventsStream, delivery.ArtifactsGoneEvent+", what wakes a floor, the registry seats' words and manifest-reads-changed are not heard until the builder starts again", func(m jetstream.Msg) {
if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4)
return
}
heardGone(ctx, b, m, logger.Printf)
})
if stopped != nil {
return stopped
}
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "builder_first_views reads nothing and builder_first_view_again is not heard", logger.Printf))
// The modules, once their first views and the manifests' are answered: what a module builds is read from the manifests.
go func() { errs <- buildFromModules(ctx, b, modules, manifests, candidates) }()
// A package's name heard in the indexer's packages looks at its module again, so a done build is numbered then.
go func() { errs <- followPackages(ctx, packages, b, logger.Printf) }()
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
// every other input. The builder asks again by itself without it.
// A state not on the bus yet at this start is watched once it appears (issue 1790).
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, acts, kv, e, logger.Printf) }
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
} else {
go func() { errs <- follow(ctx, rw, eachReAsk) }()
}
})
// A tag ask or a branch's removal by hand (builder_tag, builder_untag) is written to tag-asks and answered there
// (design 51 *Tagged versions*); the checker's tag-wanted and the branch's end join it on their consumers (DLV-B6).
tagAsksKV.Ready(func(kv jetstream.KeyValue) {
each := func(e jetstream.KeyValueEntry) {
tagAsked(ctx, acts, kv, e, logger.Printf)
if !opOf(e) {
bounds.disarm(ctx, e.Key())
} else if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
bounds.asked(ctx, e.Key(), t)
}
}
if tw, err := settle(ctx, kv, each); err != nil {
process.NotWatched(logger.Printf, versions.TagAsksBucket, err, "builder_tag and builder_untag are not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, tw, each) }()
}
})
pubBoundsKV.Ready(func(kv jetstream.KeyValue) {
each := func(e jetstream.KeyValueEntry) {
if versions.IsOldFormBound(e.Key()) {
if e.Operation() == jetstream.KeyValuePurge {
reads.stepWindow() // a node's seven days ended: the step raises old-form-held now
}
return
}
pubs.ended(ctx, e)
}
watchBounds(ctx, kv, each, found.notHeard(ctx), errs, logger.Printf)
})
boundsKV.Ready(func(kv jetstream.KeyValue) {
each := func(e jetstream.KeyValueEntry) { bounds.ended(ctx, e) }
if bw, err := settle(ctx, kv, each); err != nil {
process.NotWatched(logger.Printf, versions.TagAskBoundsBucket, err, "an unanswered tag ask is not narrated until the builder starts again")
} else {
go func() { errs <- follow(ctx, bw, each) }()
}
})
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
// builder alone may read the stream, so it answers there.
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
if qw, err := settle(ctx, kv, eachRead); err != nil {
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, qw, eachRead) }()
}
})
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): the forge bridge's merged, a pull's
// closed or branch-deleted on the builder's durable consumer of its stream; each forge message also wakes a floor whose
// bridge_tags was silent. The checker's tag-wanted is kept in tag-asks. A consumer not there yet is said.
ends := &branchEnds{acts: acts, findings: found, tagAsks: tagAsksKV.KV, logf: logger.Printf, now: time.Now,
wake: func(ctx context.Context) {
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
}}
for _, c := range []struct {
stream, what string
hear func(jetstream.Msg)
}{
{forgeStream, "a branch's end", func(m jetstream.Msg) { ends.heard(ctx, m) }},
{checkerAsksStream, "the checker's tag-wanted", func(m jetstream.Msg) {
tagWanted(ctx, tagAsksKV.KV(), m, delivery.CheckerModule, time.Now(), logger.Printf)
}},
} {
hear(c.stream, c.what+" is not heard until the builder starts again", c.hear)
}
if stopped != nil {
return stopped
}
// The state engine-reads, new with ADR 0618 (issue 1790's kind): until it is on the bus each statement is kept in memory
// only, and builder_engine_reads answers nothing.
process.AwaitBucket(ctx, js, conn, engineReadsBucket, "what each node's engine reads is kept in memory only", logger.Printf).Ready(func(kv jetstream.KeyValue) {
reads.bind(ctx, kv, errs, logger.Printf)
})
logger.Printf("started on %s", cred.Node)
select {
case <-ctx.Done():
b.Wait()
return nil
case err := <-errs:
return err
case err := <-placementErrs:
return err
case err := <-watch.errs:
return err
}
}
// The builder's own state of its first views (views.StatusState); its findings (findingsBucket) are where the condition
// keeper raises a first view unanswered past its bound (ADR 0615 §6).
var firstViewsBucket = delivery.BuilderModule + "_" + views.StatusState
// The indexer's states the builder follows (delivery.ModulesBucket, ManifestsBucket, CandidatesBucket), and the owners'
// views families as their manifests' `streams` name them.
const (
indexerViews = "indexer-views"
assignerViews = "assigner-views"
modulesState = "modules"
manifestsState = "manifests"
candidatesState = "candidates"
)
// buildFromModules builds from the indexer's modules and candidates once the first views of the modules, the manifests and
// the candidates are answered (never at the start: an indexer that is down holds no other part of it), and follows them.
func buildFromModules(ctx context.Context, b *build.Builder, modules, manifests, candidates *views.Picture) error {
for _, p := range []*views.Picture{modules, manifests, candidates} {
select {
case <-ctx.Done():
return nil
case <-p.Follower().Ready():
}
defer cc.Stop()
}
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
@@ -225,48 +488,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
b.ForgetCandidate(e.Key())
}
}
if candidates != nil {
cw, err := settle(ctx, candidates, eachCandidate)
if err != nil {
process.NotWatched(logger.Printf, delivery.CandidatesBucket, err, "no candidate's fingerprint is built until the builder starts again")
} else {
go func() { errs <- follow(ctx, cw, eachCandidate) }()
}
}
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
// every other input. The builder asks again by itself without it.
// A state not on the bus yet at this start is watched once it appears (issue 1790).
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) }
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
} else {
go func() { errs <- follow(ctx, rw, eachReAsk) }()
}
})
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
// builder alone may read the stream, so it answers there.
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
if qw, err := settle(ctx, kv, eachRead); err != nil {
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, qw, eachRead) }()
}
})
logger.Printf("started on %s", cred.Node)
go func() { errs <- follow(ctx, w, each) }()
select {
case <-ctx.Done():
b.Wait()
return nil
case err := <-errs:
return err
case err := <-placementErrs:
return err
case err := <-watch.errs:
cw, err := settle(ctx, candidates, eachCandidate)
if err != nil {
return err
}
go func() { _ = follow(ctx, cw, eachCandidate) }()
return follow(ctx, w, each)
}
// sourceHeads are the trunk heads of each revision of the gate's source.main the watch delivered, the newest
@@ -0,0 +1,187 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"mesh-delivery-builder/internal/versions"
)
// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the
// state old-form: stepped on every change of what the counted nodes state, of the placement, on a hand drop and at each
// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once
// as version.old-form-closed and in the builder's log.
type oldForm struct {
forms forms.ArtifactForms
findings *findings
// said emits version.old-form-closed.
said func(versions.OldFormClosedBody)
// log writes the close into the builder's build log (build.said); nil writes none.
log func(delivery.BuildLogEntry)
logf func(string, ...any)
now func() time.Time
// bounds is the builder's state bounds: each node holding the window has a key there whose time to live ends at its
// seven days, so old-form-held is raised then, not at the next change (review round 1); nil keeps none.
bounds func() jetstream.KeyValue
mu sync.Mutex
w versions.OldFormWindow
kv jetstream.KeyValue
// overdue are the nodes the finding is raised for now.
overdue []string
}
// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps
// every later step in kv.
func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) {
e, err := kv.Get(ctx, versions.OldFormKey)
o.mu.Lock()
defer o.mu.Unlock()
switch {
case errors.Is(err, jetstream.ErrKeyNotFound):
case err != nil:
o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err)
default:
w, err := versions.DecodeOldFormWindow(e.Value())
if err != nil {
o.logf("%v; the window starts again", err)
break
}
// Taken in under what was stepped before the state was bound: the kept since and drops are the older truth.
for n, s := range o.w.Since {
if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) {
w.Since[n] = s
}
}
for _, d := range o.w.Dropped {
w, _ = w.Drop(d.Node, d.By, d.Why, d.At)
}
o.w = w
}
o.kv = kv
o.keep(ctx)
}
// dropped is the nodes dropped by hand.
func (o *oldForm) dropped() map[string]bool {
o.mu.Lock()
defer o.mu.Unlock()
return o.w.DroppedSet()
}
// step moves the window to what counted states.
// named is every node the read named (the placement's nodes before any is dropped), counted the statements of those
// still counted: a read naming no node judges nothing (delivery.StepWindow), so it never closes the window.
func (o *oldForm) step(ctx context.Context, named []string, counted map[string][]string) {
o.mu.Lock()
defer o.mu.Unlock()
before := o.w
next, closed, overdue := o.w.Step(named, versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now())
o.w = next
if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed {
o.keep(ctx)
}
for _, n := range o.overdue {
if !slices.Contains(overdue, n) {
o.findings.clear(ctx, versions.OldFormHeld, health.ScopeMachine, n)
}
}
for _, n := range overdue {
o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: health.ScopeMachine, id: n,
summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it",
n, next.Since[n].Format(time.RFC3339)),
headline: "A node holds the old reference form",
explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.",
resolved: "The node's engine states the newest form, the node leaves the placement, or it is dropped as retired.",
needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."})
}
o.overdue = overdue
o.keepBounds(ctx, before, next, overdue)
if closed {
body := next.Closing()
line := fmt.Sprintf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out",
body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped))
o.logf("%s", line)
// In the build log too (design 51 *The node-engine's own build*), under the builder's own key, the window's key
// standing for the fingerprint: the close is no one build's.
if o.log != nil {
o.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: versions.OldFormKey,
At: body.At.UTC(), Line: line, Why: versions.SaidOldFormClosed})
}
if o.said != nil {
o.said(body)
}
}
}
// keepBounds writes a bound for each node holding the window and not yet overdue, ending at its seven days, and deletes
// the bound of each node that no longer holds it. A standing bound keeps its time (a Create). Called with mu held.
func (o *oldForm) keepBounds(ctx context.Context, before, next versions.OldFormWindow, overdue []string) {
if o.bounds == nil {
return
}
kv := o.bounds()
if kv == nil {
return
}
for _, n := range before.Holding {
if !slices.Contains(next.Holding, n) {
if err := kv.Delete(ctx, versions.OldFormBoundKey(n)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
o.logf("the old form's bound of %s is not deleted: %v", n, err)
}
}
}
for _, n := range next.Holding {
left := next.Since[n].Add(versions.FindingAfter).Sub(o.now())
if slices.Contains(overdue, n) || left <= 0 {
continue
}
if left < time.Second {
left = time.Second // the bus's least time to live
}
if _, err := kv.Create(ctx, versions.OldFormBoundKey(n), versions.EncodeBound("the seven days "+n+" holds the old form's window", o.now().Add(left)), jetstream.KeyTTL(left)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) {
o.logf("the old form's bound of %s is not written, so its seven days are seen at the next change only: %v", n, err)
}
}
}
// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller.
func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) {
o.mu.Lock()
defer o.mu.Unlock()
w, err := o.w.Drop(node, by, why, at)
if err != nil {
return "", err
}
o.w = w
o.keep(ctx)
return node + " dropped from the old form's count by hand; the window is stepped now", nil
}
// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held.
func (o *oldForm) keep(ctx context.Context) {
if o.kv == nil {
return
}
raw, err := json.Marshal(o.w)
if err == nil {
_, err = o.kv.Put(ctx, versions.OldFormKey, raw)
}
if err != nil {
o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err)
}
}
@@ -0,0 +1,129 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/heard"
"mesh-delivery-builder/internal/versions"
)
// publishes raises and clears the builder's findings of a publish that ended badly (versions/publish.go): a holder's
// refused, a version unanswered past its bound, kept as a key of the state bounds with its own time to live, and a seat's
// outcome that does not read.
type publishes struct {
bounds func() jetstream.KeyValue
versions versions.Versions
findings *findings
bound time.Duration
logf func(string, ...any)
}
// arm writes the version's bound when none stands, so a standing bound keeps its time. A bound that cannot be written
// would never end, so the version is publish-unanswered at once (review round 2): the finding says why.
func (p *publishes) arm(ctx context.Context, e versions.Entry) {
kv := p.bounds()
var err error
if kv == nil {
err = fmt.Errorf("%s is not on the bus yet", versions.BoundsBucket)
} else if _, err = kv.Create(ctx, versions.PublishBoundKey(e.Name, e.Version), versions.EncodeBound("the publish of "+e.Name+"@"+e.Version+" to "+e.Seat, time.Now().Add(p.bound)), jetstream.KeyTTL(p.bound)); errors.Is(err, jetstream.ErrKeyExists) {
err = nil
}
if err == nil {
return
}
p.logf("the publish bound of %s@%s is not written, so it is raised unanswered now: %v", e.Name, e.Version, err)
p.unanswered(ctx, e, fmt.Sprintf("its bound could not be written (%v), so the builder cannot wait on the holder's word", err))
}
// unanswered raises publish-unanswered about e's name, saying why.
func (p *publishes) unanswered(ctx context.Context, v versions.Entry, why string) {
p.findings.raise(ctx, finding{kind: versions.PublishUnanswered, scope: health.ScopeModule, id: v.Name,
summary: fmt.Sprintf("%s said neither published nor refused of %s@%s: %s", v.Seat, v.Name, v.Version, why),
headline: "A registry seat did not answer a publish",
explanation: "The builder built and numbered " + v.Name + " " + v.Version + " and asked " + v.Seat + " to publish it; no word of its holder is known, so the build says done while the version may not be in the registry.",
resolved: "That version is published, or a later version of " + v.Name + " is.",
needs: "a look at the holder of " + v.Seat + " (its health and log); builder_publish_again asks the publish again."})
}
// heard is told each holder's word the numberer took in: published clears the name's findings, refused raises
// publish-refused with the holder's reason, and either ends the version's bound.
func (p *publishes) heard(ctx context.Context, seat, event string, o versions.Outcome) {
if o.Version != "" {
if kv := p.bounds(); kv != nil {
if err := kv.Delete(ctx, versions.PublishBoundKey(o.Name, o.Version)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
p.logf("the publish bound of %s@%s is not ended: %v", o.Name, o.Version, err)
}
}
}
switch event {
case versions.HeardPublished:
p.findings.clear(ctx, versions.PublishRefused, health.ScopeModule, o.Name)
p.findings.clear(ctx, versions.PublishUnanswered, health.ScopeModule, o.Name)
case versions.HeardRefused:
reason := o.Reason
if reason == "" {
reason = "it gave no reason"
}
p.findings.raise(ctx, finding{kind: versions.PublishRefused, scope: health.ScopeModule, id: o.Name,
summary: fmt.Sprintf("%s refused to publish %s@%s (digest %s): %s", seat, o.Name, o.Version, o.Digest, reason),
headline: "A registry seat refused a publish",
explanation: "The builder built and numbered " + o.Name + " " + o.Version + ", and the holder of " + seat + " refused to store it, so that version is not in the registry.",
resolved: "That version is published, or a later version of " + o.Name + " is.",
needs: "builder_versions shows the version and why; builder_publish_again publishes it again once the cause is mended."})
}
}
// unread raises seat-outcome-unread about seat: what it said on subject is no outcome. read clears it.
func (p *publishes) unread(ctx context.Context, seat, subject string, why error) {
p.findings.raise(ctx, finding{kind: versions.SeatOutcomeUnread, scope: health.ScopeModule, id: seat,
summary: fmt.Sprintf("%s said on %s what is no outcome of a publish: %v", seat, subject, why),
headline: "A registry seat said what the builder cannot read",
explanation: "The builder hears each registry seat's published, refused and removed to move a numbered version; this one does not read, so the version it was about does not move.",
resolved: "The next outcome of " + seat + " reads.",
needs: "a look at the seat's holder: the outcome's body is its contract's (mesh-contracts)."})
}
func (p *publishes) read(ctx context.Context, seat string) {
p.findings.clear(ctx, versions.SeatOutcomeUnread, health.ScopeModule, seat)
}
// ended is told each change of the state bounds: a bound whose time ended (the bus's marker) on a version still numbered
// raises publish-unanswered.
func (p *publishes) ended(ctx context.Context, e jetstream.KeyValueEntry) {
key, ok := versions.PublishBoundOf(e.Key())
if e.Operation() != jetstream.KeyValuePurge || !ok {
return
}
raw, _, found, err := p.versions.Store.Get(ctx, key)
if err != nil || !found {
return
}
v, err := versions.Decode(raw)
if err != nil || v.State != versions.Numbered {
return
}
p.unanswered(ctx, v, fmt.Sprintf("nothing within %s of its build", p.bound))
}
// watchBounds watches the state bounds, handing each change to each: a watch that cannot start loses every bound's end,
// so it is the finding family-not-heard naming the state until a later start watches it (review round 2).
func watchBounds(ctx context.Context, kv jetstream.KeyValue, each func(jetstream.KeyValueEntry), w heard.Writer, errs chan<- error, logf func(string, ...any)) {
const consequence = "a publish no holder answers, and a node holding the old form seven days, are not raised until the builder starts again"
pw, err := settle(ctx, kv, each)
if err != nil {
process.NotWatched(logf, versions.BoundsBucket, err, consequence)
w.NotHeard(versions.BoundsState, consequence, err)
return
}
w.Heard(versions.BoundsState)
go func() { errs <- follow(ctx, pw, each) }()
}
@@ -0,0 +1,151 @@
package main
import (
"context"
"slices"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
)
// EngineReadsState is the builder's state of what each counted node's engine states it reads (novox/hq ADR 0618 §2, ADR
// 0624 §1): one key `<node>`, written from the node-engine seat's verb manifest-reads at the builder's start, on the
// seat's event manifest-reads-changed for that node, and when a person asks again (builder_engine_reads); a node that
// is away keeps its last statement, and its key is removed when the placement no longer names the node. A counted node
// with no key reads as stating the baseline: its first forms. No node's record is read in its place.
const EngineReadsState = "engine-reads"
// engineReadsBucket is that state's bucket.
const engineReadsBucket = delivery.BuilderModule + "_" + EngineReadsState
// engineReads keeps the statements and the counted nodes for the builder's forms (build.Builder.Forms).
type engineReads struct {
keeper *enginereads.Keeper
gate *buildrun.ToolchainGate
mu sync.Mutex
counted []string
// window is the old reference form's window over these statements (DLV-B9); nil keeps none.
window *oldForm
ctx context.Context
}
// newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound.
func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads {
r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}}
r.keeper.Changed = r.stepWindow
return r
}
// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old
// reference form*): on a statement's change, the placement's and a hand drop, and at each build key written.
func (r *engineReads) stepWindow() {
if r.window == nil {
return
}
ctx := r.ctx
if ctx == nil {
ctx = context.Background()
}
_, counted := r.forms()
r.window.step(ctx, r.named(), counted)
}
// named are the nodes the placement names, none dropped: what the window's step judges a read by.
func (r *engineReads) named() []string {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.counted)
}
// bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop
// later. Bound once the state is on the bus (process.AwaitBucket).
func (r *engineReads) bind(ctx context.Context, kv jetstream.KeyValue, errs chan<- error, logf func(string, ...any)) {
r.keeper.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
r.keeper.Delete = func(node string) error { return kv.Delete(ctx, node) }
each := func(e jetstream.KeyValueEntry) {
r.keeper.Heard(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut)
}
w, err := settle(ctx, kv, each)
if err != nil {
process.NotWatched(logf, engineReadsBucket, err, "builder_engine_reads is not answered until the builder starts again")
return
}
go func() { errs <- follow(ctx, w, each) }()
// Every counted node asked again, so what was asked before the state was bound is kept in it too.
r.mu.Lock()
nodes := slices.Clone(r.counted)
r.mu.Unlock()
for _, node := range nodes {
r.keeper.AskNode(node, "the state engine-reads is bound")
}
}
// placed takes the placement in: a node it names newly is asked, one it no longer names is forgotten, key and all. The
// first call asks every counted node (the builder's start).
func (r *engineReads) placed() {
nodes := r.gate.Nodes()
r.mu.Lock()
before, first := r.counted, r.counted == nil
r.counted = nodes
r.mu.Unlock()
for _, node := range nodes {
if first || !slices.Contains(before, node) {
r.keeper.AskNode(node, "counted: the placement names it")
}
}
for _, node := range before {
if !slices.Contains(nodes, node) {
r.keeper.Forget(node)
}
}
r.stepWindow()
}
// heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true.
func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool {
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
if !ours {
return false
}
if ackErr := m.Ack(); ackErr != nil {
logf("acknowledging %s: %v", m.Subject(), ackErr)
}
if err != nil {
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
}
r.keeper.AskNode(node, enginereads.Event)
return true
}
// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand
// (builder_old_form) counts no more.
func (r *engineReads) forms() (present, counted map[string][]string) {
r.mu.Lock()
nodes := slices.Clone(r.counted)
r.mu.Unlock()
if r.window != nil {
dropped := r.window.dropped()
nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] })
}
return r.keeper.Present(nodes), r.keeper.Reads(nodes)
}
// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next
// build key at the latest.
func (r *engineReads) built() (present, counted map[string][]string) {
present, counted = r.forms()
if r.window != nil {
ctx := r.ctx
if ctx == nil {
ctx = context.Background()
}
r.window.step(ctx, r.named(), counted)
}
return present, counted
}
@@ -15,6 +15,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// askAgainer is the part of the builder a reasks value asks through.
@@ -31,7 +32,7 @@ const buildAgainBound = 15 * time.Second
// reAsked answers one value of the reasks state (the tool builder_ask_again, issue 1945): a value not yet answered asks the
// key's waiting lanes again and is written back, by compare-and-set at its revision, with what the builder did. A value
// answered already (this builder's own write) or deleted is left.
func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
if e.Operation() != jetstream.KeyValuePut {
return
}
@@ -52,6 +53,11 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre
announcedAgain(ctx, b, kv, e, r, logf)
return
}
if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop {
// A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch.
go versionActed(ctx, acts, kv, e, r, logf)
return
}
asked := b.AskAgain(e.Key(), r.Why)
if len(asked) == 0 {
r.Answer = "no ask of this key waits in this builder now (it is not building, or its build has not reached its ask yet): nothing was asked; builder_stuck shows where it stands"
@@ -72,21 +78,7 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre
// emitAskedAgain says the builder's event build.asked-again (issue 1945): mesh.mod.<module>.event.build.asked-again,
// with the SDK's envelope headers and the new ask's id as the bus's dedup id.
func emitAskedAgain(ctx context.Context, js jetstream.JetStream, cred process.Account, e build.AskedAgain) error {
data, err := json.Marshal(e)
if err != nil {
return err
}
msg := nats.NewMsg(cred.EventSubject(build.AskedAgainEvent))
id := "asked-again-" + e.New
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if _, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id)); err != nil {
if err := emitEvent(ctx, js, cred, build.AskedAgainEvent, "asked-again-"+e.New, e); err != nil {
return fmt.Errorf("saying %s for %s: %w", build.AskedAgainEvent, e.Key, err)
}
return nil
@@ -163,3 +155,33 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account
}
return nil
}
// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log.
func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) {
var err error
if r.Mode == build.ModePublishAgain {
var ag versions.Again
ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At)
r.Do, r.Version = ag.Do, ag.Entry.Version
} else if r.Mode == build.ModeOldFormDrop {
if acts.DropOldForm == nil {
err = errors.New("this builder keeps no old-form window")
} else {
r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At)
}
} else {
r.Answer, err = acts.FloorAgain(ctx, r.Name)
}
if err != nil {
r.Answer, r.Refused = err.Error(), true
}
r.Answered = time.Now().UTC()
logf("%s: %s of %s by %s (%s): %s", e.Key(), r.Mode, r.Name, r.By, r.Why, r.Answer)
data, err := json.Marshal(r)
if err != nil {
return
}
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("%s.%s: the answer could not be written: %v", build.ReAsksState, e.Key(), err)
}
}
@@ -3,17 +3,31 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"maps"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-delivery/sdk/heard"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// 1945 (a part exits at start for a bucket it works without): a bus that refuses every toolchains bucket leaves the
@@ -21,6 +35,9 @@ import (
// its grant. The gate over the seat's placement, and its refusal, are mesh-sdk's (buildrun.ToolchainGate,
// issue 774).
func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
t.Run("a floor's wake is heard on the events consumer (design 51 The floor)", aFloorsWakeIsHeard)
t.Run("a failed trunk build is a finding until a later done one (DLV-B7)", aFailedTrunkBuildIsAFinding)
t.Run("a branch's end is acknowledged once its tagged versions are removed (design 51 Tagged versions)", aBranchsEndIsHeard)
set := &buildrun.ToolchainSet{}
var logs bytes.Buffer
logger := log.New(&logs, "", 0)
@@ -45,6 +62,100 @@ func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
if src.opened {
t.Error("a refused source counts as opened, so it is never opened again")
}
t.Run("ADR 0624: the counted nodes' engine reads", theCountedNodesEngineReads)
}
// theCountedNodesEngineReads: the builder asks the node-engine seat's verb of every node the placement names; a node away
// and never asked has no statement and reads as the baseline's first forms, with no node record read in its place; an
// away node keeps its last statement; a node the placement no longer names leaves the count, its key removed; and
// builder_engine_reads drops one node's key.
func theCountedNodesEngineReads(t *testing.T) {
gate := buildrun.NewToolchainGate(nil, func(string, string, []byte, bool) bool { return false }, t.Logf)
place := func(nodes ...string) {
for _, node := range []string{"n1", "n2"} {
pl := delivery.Placement{Node: node}
if slices.Contains(nodes, node) {
pl.Modules = []delivery.PlacedModule{{Module: "web"}}
}
values, err := delivery.PlacementValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
gate.Placement(key, raw, false, time.Now())
}
if !slices.Contains(nodes, node) {
key, _ := delivery.PlacementModuleKey(node, "web")
gate.Placement(key, nil, true, time.Now())
}
}
}
away := map[string]bool{"n2": true}
asked := map[string]int{}
r := newEngineReads(func(node, key string, _ any) (json.RawMessage, error) {
asked[node]++
if away[node] {
return nil, nats.ErrNoResponders
}
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}, gate, t.Logf)
r.keeper.Sync = true
kept := map[string][]byte{}
r.keeper.Put = func(node string, v []byte) error { kept[node] = v; return nil }
r.keeper.Delete = func(node string) error { delete(kept, node); return nil }
place("n1", "n2")
r.placed()
present, counted := r.forms()
if !slices.Equal(present["n1"], []string{"form:new"}) || len(present) != 1 || len(counted) != 2 || counted["n2"] != nil {
t.Errorf("away and never asked: present %v, counted %v", present, counted)
}
if s := r.keeper.Statements()["n2"]; s.From != enginereads.FromNone || s.Answered || asked["n2"] != 1 {
t.Errorf("n2, away and never asked, is %+v (asked %d)", s, asked["n2"])
}
// n2 back, stating {word:module}, then away again: it keeps that statement.
away["n2"] = false
r.keeper.Heard("n2", mustStatement(t, enginereads.Statement{Node: "n2", From: enginereads.FromVerb, Answered: true,
AskAgain: &enginereads.Again{By: "n1.ada", Why: "back"}}), false)
away["n2"] = true
r.keeper.AskNode("n2", "its event")
if _, counted = r.forms(); !slices.Equal(counted["n2"], []string{"form:new"}) || r.keeper.Statements()["n2"].Answered {
t.Errorf("away again, n2 reads %v (%+v)", counted["n2"], r.keeper.Statements()["n2"])
}
// n2 retired: it leaves the placement, and its key goes.
place("n1")
r.placed()
if _, counted = r.forms(); len(counted) != 1 || kept["n2"] != nil {
t.Errorf("retired n2: counted %v, its key %s", counted, kept["n2"])
}
// builder_engine_reads drops one key.
st := &memReads{v: map[string][]byte{"n1": kept["n1"]}}
tool := enginereads.Tool("builder_engine_reads", "the builder", st, true, time.Now)
if _, err := tool.Run(map[string]any{"node": "n1", "drop": "true", "why": "a test"}); err != nil || st.v["n1"] != nil {
t.Errorf("drop: %v, the key %s", err, st.v["n1"])
}
}
func mustStatement(t *testing.T, s enginereads.Statement) []byte {
t.Helper()
raw, err := enginereads.Encode(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// memReads is the builder's state of engine reads in memory, as the tool reads it.
type memReads struct{ v map[string][]byte }
func (m *memReads) Keys() ([]string, error) { return slices.Collect(maps.Keys(m.v)), nil }
func (m *memReads) Get(key string) ([]byte, uint64, bool, error) {
v, ok := m.v[key]
return v, 1, ok, nil
}
func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil }
func (m *memReads) Delete(key string) error { delete(m.v, key); return nil }
func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) {
return nil, errors.New("no part answers here")
}
// refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err.
@@ -71,3 +182,347 @@ func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstrea
func (r refusedWatch) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
return nil, r.err
}
// wakeMsg is a message of the builder's events consumer: its subject and body, acknowledged.
type wakeMsg struct {
jetstream.Msg
subject string
data []byte
acked bool
naked bool
delayed time.Duration
}
func (m *wakeMsg) Subject() string { return m.subject }
func (m *wakeMsg) Data() []byte { return m.data }
func (m *wakeMsg) Ack() error { m.acked = true; return nil }
func (m *wakeMsg) Nak() error { m.naked = true; return nil }
func (m *wakeMsg) NakWithDelay(d time.Duration) error {
m.delayed = d
return nil
}
func (m *wakeMsg) Metadata() (*jetstream.MsgMetadata, error) {
return nil, errors.New("no metadata")
}
func (m *wakeMsg) Headers() nats.Header {
return nil
}
func aFloorsWakeIsHeard(t *testing.T) {
var logs bytes.Buffer
waits := &versions.FloorWaits{}
waits.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceRequirements}}, nil, time.Unix(0, 0))
waits.Wait("m/img", versions.SeatImages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
f := &floorWake{b: &build.Builder{}, waits: waits, logf: log.New(&logs, "", 0).Printf}
accepted := &wakeMsg{subject: manifestAcceptedSubject}
if !f.heard(context.Background(), accepted) || !accepted.acked || !strings.Contains(logs.String(), "the floor of m/lib is asked again: manifest-accepted") {
t.Fatalf("manifest-accepted: acked %v, log %q", accepted.acked, logs.String())
}
logs.Reset()
cleared := &wakeMsg{subject: conditionClearedSubject, data: []byte(`{"subject":{"scope":"probe","id":"ace/mesh-delivery-forge-bridge"}}`)}
if !f.heard(context.Background(), cleared) || !strings.Contains(logs.String(), "the floor of m/img is asked again: condition-cleared") {
t.Fatalf("condition-cleared: log %q", logs.String())
}
if f.heard(context.Background(), &wakeMsg{subject: delivery.ArtifactsGoneSubject}) {
t.Fatal("artifacts-gone was taken as a floor's wake")
}
// DLV-B4: a registry seat's outcome on the same consumer moves a version; a published wakes a name whose seat was
// silent; one the state cannot take is left for the bus to give again.
logs.Reset()
waits.Wait("m/pkg", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceSeat}}, nil, time.Unix(0, 0))
h := &seatHearer{n: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}, Waits: waits}, b: &build.Builder{}, logf: log.New(&logs, "", 0).Printf}
byDigest := &wakeMsg{subject: "mesh.seat.go-package-registry.event.published", data: []byte(`{"name":"m/other","digest":"sha256:aa","kind":"package","holder":"gitea"}`)}
if !h.heard(context.Background(), byDigest) || !byDigest.acked || !strings.Contains(logs.String(), "the floor of m/pkg is asked again: go-package-registry said published") {
t.Fatalf("published: acked %v, log %q", byDigest.acked, logs.String())
}
refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)}
if !h.heard(context.Background(), refused) || refused.acked || !refused.naked {
t.Fatalf("a refusal the state cannot take: acked %v, naked %v, log %q", refused.acked, refused.naked, logs.String())
}
if h.heard(context.Background(), &wakeMsg{subject: manifestAcceptedSubject}) {
t.Fatal("manifest-accepted was taken as a seat's outcome")
}
}
// noVersions is a state versions that holds nothing.
type noVersions struct{}
func (noVersions) Get(context.Context, string) ([]byte, uint64, bool, error) {
return nil, 0, false, nil
}
func (noVersions) Create(context.Context, string, []byte) (uint64, error) {
return 0, errors.New("read only")
}
func (noVersions) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, errors.New("read only")
}
func (noVersions) KeysOf(context.Context, string) ([]string, error) { return nil, nil }
func aBranchsEndIsHeard(t *testing.T) {
for _, c := range []struct {
f forgeEnd
want bool
}{
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r", Branch: "feat/x"}, true},
{forgeEnd{Kind: forgeBranchDeleted, Repository: "o/r", Branch: "feat/x"}, true},
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullClosed}, true},
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullOpened}, false},
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r"}, false},
} {
if got := c.f.ended(); got != c.want {
t.Errorf("%+v ended = %v, want %v", c.f, got, c.want)
}
}
var logs bytes.Buffer
woken := 0
h := &branchEnds{acts: versions.Acts{Numberer: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}}}, findings: &findings{logf: t.Logf},
tagAsks: func() jetstream.KeyValue { return nil }, wake: func(context.Context) { woken++ }, logf: log.New(&logs, "", 0).Printf, now: time.Now}
open := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.pull", data: []byte(`{"kind":"pull","repository":"o/r","branch":"feat/x","action":"opened"}`)}
h.heard(context.Background(), open)
merged := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.merged", data: []byte(`{"kind":"merged","repository":"o/r","branch":"feat/x"}`)}
h.heard(context.Background(), merged)
if !open.acked || !merged.acked || woken != 2 {
t.Errorf("an open pull acked %v, a merge with nothing tagged acked %v, floors woken %d times", open.acked, merged.acked, woken)
}
// The checker's family holds its verdicts beside tag-wanted: a verdict is acknowledged and not kept; a tag-wanted with
// tag-asks not bound is heard again.
verdict := &wakeMsg{subject: "mesh.stream.mesh-delivery-checker.event.verdict", data: []byte(`{"repository":"o/r"}`)}
wanted := &wakeMsg{subject: tagWantedSubject, data: []byte(`{"repository":"o/r","branch":"feat/x","head":"abc","tag":"feat-x"}`)}
for _, m := range []*wakeMsg{verdict, wanted} {
tagWanted(context.Background(), nil, m, delivery.CheckerModule, time.Now(), t.Logf)
}
if !verdict.acked || verdict.delayed != 0 || wanted.acked || wanted.delayed != versions.RemoveAgainAfter || !strings.HasPrefix(tagWantedSubject, "mesh.stream.") {
t.Errorf("a verdict acked %v (delayed %v), a tag-wanted acked %v (delayed %v)", verdict.acked, verdict.delayed, wanted.acked, wanted.delayed)
}
unreadable := &wakeMsg{subject: merged.subject, data: merged.data}
h.acts.Numberer.Versions.Store = failingVersions{}
h.heard(context.Background(), unreadable)
if unreadable.acked || unreadable.delayed != versions.RemoveAgainAfter {
t.Errorf("a branch's end the versions cannot answer: acked %v, given again after %v", unreadable.acked, unreadable.delayed)
}
}
// failingVersions is a state versions that cannot be read.
type failingVersions struct{ noVersions }
func (failingVersions) KeysOf(context.Context, string) ([]string, error) {
return nil, errors.New("down")
}
func aFailedTrunkBuildIsAFinding(t *testing.T) {
st := &memFindings{vals: map[string][]byte{}}
f := &findings{store: st, logf: t.Logf, now: func() time.Time { return time.Unix(0, 0) }}
ended := func(outcome, ref string) delivery.BuildLogEntry {
return delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "m", FP: "f1", Commit: "c", Ref: ref, Outcome: outcome}
}
key := health.FindingKey(health.ScopeModule, "m", BuildFailed)
f.buildEnded(context.Background(), ended(delivery.BuildFailed, "refs/pull/3/head"))
if len(st.vals) != 0 {
t.Fatalf("a candidate's failed build is a finding: %v", st.vals)
}
f.buildEnded(context.Background(), ended(delivery.BuildFailed, ""))
got, err := health.DecodeFinding(st.vals[key])
if err != nil || got.Observer != delivery.BuilderModule || !strings.Contains(got.Summary, "trunk build of m at f1") {
t.Fatalf("the finding = %+v, %v", got, err)
}
f.buildEnded(context.Background(), ended(delivery.BuildDone, ""))
if _, ok := st.vals[key]; ok {
t.Error("a later done trunk build leaves build-failed")
}
// The floor finding: raised while a name waits on its floor, cleared once it is known.
fw := &versions.FloorWaits{Said: f.floorSaid(context.Background())}
fw.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
floorKey := health.FindingKey(health.ScopeModule, "m/lib", versions.FloorSilent)
if got, err := health.DecodeFinding(st.vals[floorKey]); err != nil || !strings.Contains(got.Summary, "bridge_tags did not answer") {
t.Fatalf("the floor finding = %+v, %v", got, err)
}
fw.Answered("m/lib")
if _, ok := st.vals[floorKey]; ok {
t.Error("a known floor leaves floor-silent")
}
// Design 51 *Health and conditions* (review round 1): a holder's refused is publish-refused with its reason, a version
// no holder answered within its bound publish-unanswered, a seat's outcome that does not read seat-outcome-unread;
// a later published of the name clears the first two, the seat's next outcome that reads the third.
vs := versions.Versions{Store: memVersions{}}
for _, v := range []string{"0.1.3", "0.1.4"} {
raw, err := versions.Encode(versions.Entry{Name: "m/program", Kind: versions.KindBundle, Series: "0.1", Version: v, Seat: "oci-artifact-registry", State: versions.Numbered})
if err != nil {
t.Fatal(err)
}
vs.Store.(memVersions)[versions.Key("m/program", v)] = raw
}
pubs := &publishes{bounds: func() jetstream.KeyValue { return nil }, versions: vs, findings: f, bound: versions.PublishBound, logf: t.Logf}
h := &seatHearer{n: &versions.Numberer{Versions: vs}, b: &build.Builder{}, logf: t.Logf, pub: pubs}
refusedKey := health.FindingKey(health.ScopeModule, "m/program", versions.PublishRefused)
refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)}
if !h.heard(context.Background(), refused) || !refused.acked {
t.Fatalf("a refusal: acked %v", refused.acked)
}
if got, err := health.DecodeFinding(st.vals[refusedKey]); err != nil || !strings.Contains(got.Summary, "refused to publish m/program@0.1.3") || !strings.Contains(got.Summary, "bytes differ") {
t.Fatalf("a holder's refused: the finding = %+v, %v", got, err)
}
pubs.ended(context.Background(), purged{key: versions.PublishBoundKey("m/program", "0.1.4")})
unansweredKey := health.FindingKey(health.ScopeModule, "m/program", versions.PublishUnanswered)
if got, err := health.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "neither published nor refused of m/program@0.1.4") {
t.Fatalf("a bound ended unanswered: the finding = %+v, %v", got, err)
}
// Review round 2: a bound that cannot be written is publish-unanswered at once; a watch of bounds that cannot start
// is family-not-heard naming the state.
delete(st.vals, unansweredKey)
pubs.arm(context.Background(), versions.Entry{Name: "m/program", Version: "0.1.4", Seat: "oci-artifact-registry"})
if got, err := health.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "its bound could not be written") {
t.Fatalf("a bound not written: the finding = %+v, %v", got, err)
}
watchBounds(context.Background(), refusedWatch{err: errors.New("permissions violation")}, func(jetstream.KeyValueEntry) {}, f.notHeard(context.Background()), nil, t.Logf)
if got, err := health.DecodeFinding(st.vals[heard.Key(delivery.BuilderModule, versions.BoundsState)]); err != nil || !strings.Contains(got.Summary, "permissions violation") {
t.Fatalf("bounds not watched: the finding = %+v, %v", got, err)
}
delete(st.vals, heard.Key(delivery.BuilderModule, versions.BoundsState))
malformed := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"version":"0.1.4"}`)}
unreadKey := health.FindingKey(health.ScopeModule, "oci-artifact-registry", versions.SeatOutcomeUnread)
if !h.heard(context.Background(), malformed) || !malformed.acked || st.vals[unreadKey] == nil {
t.Fatalf("an outcome that does not read: acked %v, findings %v", malformed.acked, st.vals)
}
published := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"name":"m/program","version":"0.1.4","digest":"sha256:cc"}`)}
if !h.heard(context.Background(), published) || st.vals[refusedKey] != nil || st.vals[unansweredKey] != nil || st.vals[unreadKey] != nil {
t.Fatalf("a later published leaves the findings: %v", st.vals)
}
// ADR 0615 §5 (review round 1): a consumer not there yet is family-not-heard naming its stream, cleared when it binds.
notHeard := f.notHeard(context.Background())
unheardKey := heard.Key(delivery.BuilderModule, "FORGE")
_ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return errors.New("consumer not found") })
if got, err := health.DecodeFinding(st.vals[unheardKey]); err != nil || got.Kind != heard.Kind || !strings.Contains(got.Summary, "does not hear FORGE") {
t.Fatalf("a consumer not there: the finding = %+v, %v", got, err)
}
_ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return nil })
if _, ok := st.vals[unheardKey]; ok {
t.Error("a consumer bound leaves family-not-heard")
}
// DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is
// old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a
// retired node dropped by hand leaves the count.
now := time.Unix(0, 0)
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
var closings []versions.OldFormClosedBody
var logged []delivery.BuildLogEntry
marks := &memBounds{keys: map[string]bool{}}
window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now }, bounds: func() jetstream.KeyValue { return marks },
said: func(b versions.OldFormClosedBody) { closings = append(closings, b) },
log: func(e delivery.BuildLogEntry) { logged = append(logged, e) }}
reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}}
newest := []string{forms.FormNeed("oci")}
counted := map[string][]string{"a": newest, "b": nil}
window.step(context.Background(), []string{"a", "b"}, counted)
heldKey := health.FindingKey(health.ScopeMachine, "b", versions.OldFormHeld)
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil {
t.Fatalf("one node lacking the newest form: %+v", window.w)
}
// Review round 1: the node holding it has a bound in the state bounds ending at its seven days, so old-form-held is
// raised then, not at the next change.
if !marks.keys[versions.OldFormBoundKey("b")] || marks.keys[versions.OldFormBoundKey("a")] {
t.Fatalf("the old form's bounds = %v, want b's alone", marks.keys)
}
// Security-upgrade F3 (review round 1): an empty node_haves read, naming no node, leaves the window open.
window.step(context.Background(), nil, map[string][]string{})
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || len(closings) != 0 {
t.Fatalf("an empty read closed the window: %+v, closings %+v", window.w, closings)
}
now = now.Add(versions.FindingAfter)
window.step(context.Background(), []string{"a", "b"}, counted)
if got, err := health.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") {
t.Fatalf("seven days held: the finding = %+v, %v", got, err)
}
counted["b"] = newest
window.step(context.Background(), []string{"a", "b"}, counted)
window.step(context.Background(), []string{"a", "b"}, counted)
if marks.keys[versions.OldFormBoundKey("b")] {
t.Error("a node no longer holding the window keeps its bound")
}
if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil {
t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil)
}
if len(logged) != 1 || logged[0].Event != delivery.BuildSaidEvent || logged[0].Why != versions.SaidOldFormClosed ||
!strings.Contains(logged[0].Line, "last held by b") {
t.Errorf("the build log = %+v, want the close said once (build.said)", logged)
} else if _, err := delivery.EncodeBuildLogEntry(logged[0]); err != nil {
t.Errorf("the close is no entry the build log takes: %v", err)
}
if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil {
t.Fatal(err)
}
if _, c := reads.forms(); len(c) != 1 || c["b"] != nil {
t.Errorf("a node dropped by hand is still counted: %v", c)
}
}
// memVersions is a state versions in memory.
type memVersions map[string][]byte
func (m memVersions) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
v, ok := m[key]
return v, 1, ok, nil
}
func (m memVersions) Create(_ context.Context, key string, v []byte) (uint64, error) {
m[key] = v
return 1, nil
}
func (m memVersions) Update(_ context.Context, key string, v []byte, _ uint64) (uint64, error) {
m[key] = v
return 2, nil
}
func (m memVersions) KeysOf(context.Context, string) ([]string, error) {
return slices.Collect(maps.Keys(m)), nil
}
// memBounds is the state bounds in memory: which keys stand.
type memBounds struct {
jetstream.KeyValue
keys map[string]bool
}
func (m *memBounds) Create(_ context.Context, key string, _ []byte, _ ...jetstream.KVCreateOpt) (uint64, error) {
if m.keys[key] {
return 0, jetstream.ErrKeyExists
}
m.keys[key] = true
return 1, nil
}
func (m *memBounds) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
delete(m.keys, key)
return nil
}
// purged is the bus's marker of a key whose time to live ended.
type purged struct {
jetstream.KeyValueEntry
key string
}
func (p purged) Key() string { return p.key }
func (p purged) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePurge }
// memFindings is a findings bucket in memory.
type memFindings struct{ vals map[string][]byte }
type memEntry struct {
jetstream.KeyValueEntry
v []byte
}
func (e memEntry) Value() []byte { return e.v }
func (m *memFindings) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) {
v, ok := m.vals[key]
if !ok {
return nil, jetstream.ErrKeyNotFound
}
return memEntry{v: v}, nil
}
func (m *memFindings) Put(_ context.Context, key string, v []byte) (uint64, error) {
m.vals[key] = v
return 1, nil
}
func (m *memFindings) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
delete(m.vals, key)
return nil
}
@@ -0,0 +1,148 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// seatEvent is one registry seat's event the builder hears: the seat and the event's name.
type seatEvent struct{ seat, event string }
// seatEvents are the four registry seats' published, refused, removed and kept, by subject (design 51 *How a publish ends*,
// DLV-B4): each a seat event `<seat>.<event>` on the shared events stream, heard on the builder's durable consumer of
// it through the manifest's consumes. The subjects are the seats' contracts' (mesh-contracts).
var seatEvents = map[string]seatEvent{
containerimageregistry.SubjectPublished: {containerimageregistry.Seat, containerimageregistry.EventPublished},
containerimageregistry.SubjectRefused: {containerimageregistry.Seat, containerimageregistry.EventRefused},
containerimageregistry.SubjectRemoved: {containerimageregistry.Seat, containerimageregistry.EventRemoved},
containerimageregistry.SubjectKept: {containerimageregistry.Seat, containerimageregistry.EventKept},
ociartifactregistry.SubjectPublished: {ociartifactregistry.Seat, ociartifactregistry.EventPublished},
ociartifactregistry.SubjectRefused: {ociartifactregistry.Seat, ociartifactregistry.EventRefused},
ociartifactregistry.SubjectRemoved: {ociartifactregistry.Seat, ociartifactregistry.EventRemoved},
ociartifactregistry.SubjectKept: {ociartifactregistry.Seat, ociartifactregistry.EventKept},
gopackageregistry.SubjectPublished: {gopackageregistry.Seat, gopackageregistry.EventPublished},
gopackageregistry.SubjectRefused: {gopackageregistry.Seat, gopackageregistry.EventRefused},
gopackageregistry.SubjectRemoved: {gopackageregistry.Seat, gopackageregistry.EventRemoved},
gopackageregistry.SubjectKept: {gopackageregistry.Seat, gopackageregistry.EventKept},
npmpackageregistry.SubjectPublished: {npmpackageregistry.Seat, npmpackageregistry.EventPublished},
npmpackageregistry.SubjectRefused: {npmpackageregistry.Seat, npmpackageregistry.EventRefused},
npmpackageregistry.SubjectRemoved: {npmpackageregistry.Seat, npmpackageregistry.EventRemoved},
npmpackageregistry.SubjectKept: {npmpackageregistry.Seat, npmpackageregistry.EventKept},
}
// seatHearer takes the registry seats' outcomes in: each moves a numbered version (versions.Numberer.Heard), and a
// `published` that wakes a name waiting on its floor looks at every module again.
type seatHearer struct {
n *versions.Numberer
b *build.Builder
logf func(string, ...any)
// pub raises and clears the findings of a publish that ended badly; nil raises none.
pub *publishes
}
// heard takes a message of the events consumer in when it is a registry seat's outcome, and answers whether it was.
// One the state could not take is left unacknowledged (Nak), so the bus gives it again; one that is no outcome is said
// and dropped.
func (h *seatHearer) heard(ctx context.Context, m jetstream.Msg) bool {
se, ok := seatEvents[m.Subject()]
if !ok {
return false
}
var o versions.Outcome
if err := json.Unmarshal(m.Data(), &o); err != nil || o.Name == "" {
if err == nil {
err = errors.New("it names no name")
}
h.logf("%s holds what is no outcome of %s, left: %v", m.Subject(), se.seat, err)
if h.pub != nil {
h.pub.unread(ctx, se.seat, m.Subject(), err)
}
h.ack(m)
return true
}
if h.pub != nil {
h.pub.read(ctx, se.seat)
}
id := ""
if m.Headers() != nil {
id = m.Headers().Get("x-event-id")
}
wake, err := h.n.Heard(ctx, se.seat, se.event, id, o)
if err != nil {
h.logf("%v; heard again", err)
if nakErr := m.Nak(); nakErr != nil {
h.logf("leaving %s for the bus to give again: %v", m.Subject(), nakErr)
}
return true
}
if h.pub != nil {
h.pub.heard(ctx, se.seat, se.event, o)
}
h.ack(m)
if len(wake) > 0 {
h.logf("the floor of %s is asked again: %s said %s", strings.Join(wake, ", "), se.seat, se.event)
h.b.Reconsider(ctx)
}
return true
}
func (h *seatHearer) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
h.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// kvKeeper is the state floor-waits as versions.FloorKeeper.
type kvKeeper struct {
ctx context.Context
kv jetstream.KeyValue
}
func (k kvKeeper) Put(key string, value []byte) error {
_, err := k.kv.Put(k.ctx, key, value)
return err
}
func (k kvKeeper) Delete(key string) error {
err := k.kv.Delete(k.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
return err
}
// heldFloorWaits are the waits floor-waits holds at the builder's start; a value that is no wait is said and left.
func heldFloorWaits(ctx context.Context, kv jetstream.KeyValue, logf func(string, ...any)) []versions.FloorWait {
l, err := kv.ListKeys(ctx)
if err != nil {
logf("%s cannot be listed, so a restart's waits are asked at their next claim: %v", versions.FloorWaitsState, err)
return nil
}
defer func() { _ = l.Stop() }()
var out []versions.FloorWait
for k := range l.Keys() {
e, err := kv.Get(ctx, k)
if err != nil {
continue
}
w, err := versions.DecodeFloorWait(e.Value())
if err != nil {
logf("%s.%s holds what is no floor wait, left: %v", versions.FloorWaitsState, k, err)
continue
}
out = append(out, w)
}
return out
}
@@ -0,0 +1,132 @@
package main
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// tagBounds narrates a tag ask the builder has not answered within five minutes, with what it waits on (design 51
// *Tagged versions*): each unanswered ask has a key in the state tag-ask-bounds written with its own time to live, and
// the bus's marker of its end, heard on the watch, is what narrates it and writes the bound again. No timer.
type tagBounds struct {
asks func() jetstream.KeyValue
bounds func() jetstream.KeyValue
waits func() []versions.FloorWait
logf func(string, ...any)
// log writes a narration into the builder's build log (build.said); nil writes none.
log func(delivery.BuildLogEntry)
now func() time.Time
bound time.Duration
}
// arm writes key's bound when none stands: a Create, so a standing bound keeps its time and a restart arms each
// unanswered ask whose bound ended while the builder was away.
func (tb *tagBounds) arm(ctx context.Context, key string) {
kv := tb.bounds()
if kv == nil {
return
}
if _, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(tb.bound)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) {
tb.logf("the bound of the tag ask %s is not written, so it is not narrated past five minutes: %v", key, err)
}
}
// disarm removes key's bound: the ask was answered or ended.
func (tb *tagBounds) disarm(ctx context.Context, key string) {
if kv := tb.bounds(); kv != nil {
_ = kv.Delete(ctx, key)
}
}
// asked is told each value of tag-asks: an unanswered ask is armed, any other disarmed.
func (tb *tagBounds) asked(ctx context.Context, key string, t versions.TagAsk) {
if t.Unanswered() {
tb.arm(ctx, key)
return
}
tb.disarm(ctx, key)
}
// ended is told each change of tag-ask-bounds: a bound whose time ended (the bus's marker, a purge on the watch) on an
// ask still unanswered narrates it and arms it again.
func (tb *tagBounds) ended(ctx context.Context, e jetstream.KeyValueEntry) {
if e.Operation() != jetstream.KeyValuePurge {
return
}
kv := tb.asks()
if kv == nil {
return
}
got, err := kv.Get(ctx, e.Key())
if err != nil {
return // the ask ended with its branch
}
t, err := versions.DecodeTagAsk(got.Value())
if err != nil || !t.Unanswered() {
return
}
var waits []versions.FloorWait
if tb.waits != nil {
waits = tb.waits()
}
line := versions.NarrateUnanswered(t, waits, tb.now())
tb.logf("%s", line)
// In the build log too (design 51 *Tagged versions*): under the builder's own key, no build being claimed yet, the
// ask's key standing for the fingerprint.
if tb.log != nil {
tb.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: e.Key(), Commit: t.Head,
At: tb.now().UTC(), Line: line, Why: versions.SaidUnanswered})
}
tb.arm(ctx, e.Key())
}
// tagNumbered marks the tag ask of a check's build's head numbered with its versions in tag-asks, by compare-and-set.
func tagNumbered(kv func() jetstream.KeyValue, logf func(string, ...any), now func() time.Time) func(context.Context, versions.BuildClaim, versions.Numbering) {
return func(ctx context.Context, c versions.BuildClaim, got versions.Numbering) {
store := kv()
if store == nil {
return
}
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(c.Repository)+".>")
if err != nil {
logf("the tag ask of %s at %s is not marked numbered: %v", c.Repository, c.Head, err)
return
}
for key := range lister.Keys() {
for range 3 {
e, err := store.Get(ctx, key)
if err != nil {
break
}
t, err := versions.DecodeTagAsk(e.Value())
if err != nil {
break
}
t, changed := t.WithNumbered(c.Module, c.Head, got.Versions, got.Names, now())
if !changed {
break
}
data, err := json.Marshal(t)
if err != nil {
break
}
if _, err = store.Update(ctx, key, data, e.Revision()); err == nil {
logf("%s: tag ask of %s %s: %s", key, t.Repository, t.Branch, t.Answer)
break
}
if !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("the tag ask %s is not marked numbered: %v", key, err)
break
}
}
}
}
}
@@ -0,0 +1,352 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// The floor's two module tools (novox/hq design 51 *The floor*); the seat's is `seat:<registry>.versions`.
const (
bridgeTagsTool = "mesh-delivery-forge-bridge.bridge_tags"
requirementsTool = "mesh-delivery-indexer.indexer_requirements"
)
// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue
// 1790); until then every use is an error, so a trunk build is built unnumbered and said (build.Builder.number).
type awaitedVersions struct{ b *process.AwaitedBucket }
var errVersionsMissing = errors.New("the state " + versions.Bucket + " is not on the bus yet")
func (s awaitedVersions) kv() (jetstream.KeyValue, error) {
if kv := s.b.KV(); kv != nil {
return kv, nil
}
return nil, errVersionsMissing
}
func (s awaitedVersions) Get(ctx context.Context, key string) ([]byte, uint64, bool, error) {
kv, err := s.kv()
if err != nil {
return nil, 0, false, err
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, 0, false, nil
}
if err != nil {
return nil, 0, false, err
}
return e.Value(), e.Revision(), true, nil
}
// indexerPackages is the indexer's packages state as the builder reads a package's name in its ecosystem from it (design
// 32): the picture of its read tool's first view and its views family (ADR 0615 §1), never its bucket. Until the first
// view is answered a package is built unnumbered and said (versions.Numberer.Names), and numbered once its name is heard
// (followPackages).
type indexerPackages struct{ kv jetstream.KeyValue }
func (s indexerPackages) Keys(ctx context.Context) ([]string, error) {
keys, err := s.kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
func (s indexerPackages) Get(ctx context.Context, key string) ([]byte, bool, error) {
e, err := s.kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return e.Value(), true, nil
}
// followPackages watches the picture of the indexer's packages state: each series key heard that names a package's name
// (versions.NamedModule), its first view's included, looks at its module again (build.Builder.PackageNamed), so a done
// build that left the package unnumbered is numbered then with its digests. Woken by the views family, never a timer; a
// key heard again naming the name and module it named (the indexer writes a series at each publish) looks at nothing.
func followPackages(ctx context.Context, kv jetstream.KeyValue, b *build.Builder, logf func(string, ...any)) error {
w, err := kv.WatchAll(ctx)
if err != nil {
return err
}
heard := map[string]string{}
return follow(ctx, w, func(e jetstream.KeyValueEntry) {
if e.Operation() != jetstream.KeyValuePut {
delete(heard, e.Key())
return
}
module, name, named := versions.NamedModule(e.Key(), e.Value())
if named && heard[e.Key()] != module+" "+name {
heard[e.Key()] = module + " " + name
logf("%s.%s names a package of %s: its build is looked at again", delivery.PackagesBucket, e.Key(), module)
b.PackageNamed(ctx, module)
}
})
}
func versionsConflict(err error) error {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return versions.ErrConflict
}
return err
}
func (s awaitedVersions) Create(ctx context.Context, key string, value []byte) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Create(ctx, key, value)
return rev, versionsConflict(err)
}
func (s awaitedVersions) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Update(ctx, key, value, revision)
return rev, versionsConflict(err)
}
func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, error) {
kv, err := s.kv()
if err != nil {
return nil, err
}
l, err := kv.ListKeysFiltered(ctx, filter)
if err != nil {
return nil, err
}
defer func() { _ = l.Stop() }()
var out []string
for k := range l.Keys() {
out = append(out, k)
}
return out, nil
}
// floorVia is the floor's three sources as calls of the builder's own account (design 51 *The floor*): the seat's verb
// versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. A call that fails is that source
// silent.
func floorVia(call toolCaller) versions.FloorSources {
return versions.FloorSources{
Seat: func(_ context.Context, seat, name string) ([]string, error) {
raw, err := call("seat:"+seat+".versions", map[string]any{"name": name})
if err != nil {
return nil, err
}
var a struct {
Versions []struct {
Version string `json:"version"`
} `json:"versions"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s's versions answered what is no list of versions: %w", seat, err)
}
var out []string
for _, v := range a.Versions {
if v.Version != "" {
out = append(out, v.Version)
}
}
return out, nil
},
Tags: func(_ context.Context, repository, prefix string) ([]string, error) {
raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix})
if err != nil {
return nil, err
}
var a struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("bridge_tags answered what is no list of tags: %w", err)
}
return a.Tags, nil
},
Requirements: func(_ context.Context, name string) ([]string, error) {
raw, err := call(requirementsTool, map[string]any{"package": name})
if err != nil {
return nil, err
}
var a struct {
Requirements []string `json:"requirements"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("indexer_requirements answered what is no list of versions: %w", err)
}
return a.Requirements, nil
},
}
}
// emitNumbered says the builder's event version.numbered (design 51 *Events of the builder*), on its own family apart
// from its builds' ends; the name and version are the bus's dedup id.
func emitNumbered(ctx context.Context, js jetstream.JetStream, cred process.Account, b delivery.VersionNumbered) error {
id := "numbered-" + versions.Key(b.Name, b.Version)
if b.Again != "" {
id += "-again-" + b.Again
}
if err := emitEvent(ctx, js, cred, delivery.VersionNumberedEvent, id, b); err != nil {
return fmt.Errorf("saying %s for %s@%s: %w", delivery.VersionNumberedEvent, b.Name, b.Version, err)
}
return nil
}
// emitEvent says one of the builder's events, mesh.mod.<module>.event.<event>, with the SDK's envelope headers and id as
// the bus's dedup id.
func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account, event, id string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg(cred.EventSubject(event))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
return err
}
// The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b).
var (
manifestAcceptedSubject = "mesh.mod." + delivery.IndexerModule + ".event." + delivery.ManifestAcceptedEvent
conditionClearedSubject = "mesh.mod." + health.KeeperModule + ".event." + health.EventCleared
)
// floorWake asks the floor of the names an event wakes again, by looking at every module again: a trunk build is
// numbered before its claim (build.Builder.number).
type floorWake struct {
b *build.Builder
waits *versions.FloorWaits
logf func(string, ...any)
}
// heard takes a message of the consumer in when it is one that wakes a floor, acknowledged, and answers whether it was.
func (f *floorWake) heard(ctx context.Context, m jetstream.Msg) bool {
switch m.Subject() {
case manifestAcceptedSubject:
f.ack(m)
f.wake(ctx, versions.WakeAccepted, "", "")
case conditionClearedSubject:
f.ack(m)
var e health.Event
if err := json.Unmarshal(m.Data(), &e); err != nil {
f.logf("%s holds what is no condition, left: %v", m.Subject(), err)
return true
}
for _, module := range conditionModules(e.Subject.ID) {
f.wake(ctx, versions.WakeCleared, "", module)
}
default:
return false
}
return true
}
func (f *floorWake) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
f.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// wake looks at every module again when the event wakes a name, and says which.
func (f *floorWake) wake(ctx context.Context, kind, seat, module string) {
names := f.waits.Wake(kind, seat, module, "")
if len(names) == 0 {
return
}
f.logf("the floor of %s is asked again: %s", strings.Join(names, ", "), kind)
f.b.Reconsider(ctx)
}
// conditionModules are the tokens of a condition's subject id (`<node>/<module>`, `<module>.<check>`): a module named
// among them is the one whose condition cleared.
func conditionModules(id string) []string {
return strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '.' || r == ':' })
}
// doneVia says whether a build key is done, read from the builder's own builds state: the build's file is held.
func doneVia(b *build.Builder) func(ctx context.Context, module, fp string) (bool, error) {
return func(ctx context.Context, module, fp string) (bool, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return false, err
}
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
if err != nil || !found {
return false, err
}
cur, err := delivery.DecodeBuilderBuild(raw)
if err != nil {
return false, err
}
return cur.State == delivery.BuildDone, nil
}
}
// removeVia calls a registry seat's remove for one tagged version, as the builder's own account (design 51 *Tagged
// versions*): the reason tag-gone; the seat says removed on its events, or answers kept when another tag names the
// digest (the registry part's RemoveAnswer: kept, reason, tags), which ends the remove.
func removeVia(call toolCaller) func(ctx context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
return func(_ context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
raw, err := call("seat:"+seat+".remove", map[string]any{"name": e.Name, "version": e.Version, "tag": e.Tag, "reason": "tag-gone"})
if err != nil {
return versions.RemoveOutcome{}, err
}
var a ociartifactregistry.RemoveAnswer // the four registry seats share the part's answer
if len(raw) > 0 && json.Unmarshal(raw, &a) != nil {
return versions.RemoveOutcome{}, fmt.Errorf("%s's remove answered what is no answer of it: %s", seat, raw)
}
return versions.RemoveOutcome{Kept: a.Kept, Reason: a.Reason, Tags: a.Tags}, nil
}
}
// tagAsked answers an unanswered value of tag-asks by compare-and-set at its revision, said in the log.
func tagAsked(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
if e.Operation() != jetstream.KeyValuePut {
return
}
t, err := versions.DecodeTagAsk(e.Value())
if err != nil {
logf("%s.%s holds what is not a tag ask, left: %v", versions.TagAsksState, e.Key(), err)
return
}
if !t.Answered.IsZero() {
return
}
t = acts.TagAsked(ctx, t, time.Now())
logf("%s: %s of %s %s by %s (%s): %s", e.Key(), map[bool]string{true: "untag", false: "tag ask"}[t.Untag], t.Repository, t.Branch, t.By, t.Why, t.Answer)
data, err := json.Marshal(t)
if err != nil {
return
}
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("%s.%s: the answer could not be written: %v", versions.TagAsksState, e.Key(), err)
}
}
+25 -6
View File
@@ -3,25 +3,44 @@ module mesh-delivery-builder
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ./buildrun
require (
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
)
require (
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
)
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+32 -12
View File
@@ -1,17 +1,37 @@
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459 h1:DA4SFG/gKyG+aPJbfUpAUad2VeITIiKjJ6qOPmDhSA8=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459/go.mod h1:zR1HsAKZVbQsBIY5wPyrqI2WHInK268tITzGl2oqHro=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:I6uVDO9E+i3D9/n6KQ2RWYRs3kXYCqueiKXynifQo8w=
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:ER2NVrd81uJo1lN0YCGA7OBxL910IHRgWYtxpsnHGF0=
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:Rs5jOa7EWlpKuaH6+ExyGLN9k9UUi8N7upP9Ac3k+GY=
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:fNfjFkP1+suYb12HJkapQe5W3cF98JsTcLBI/syoLHc=
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:VG+sZltyBIKrd9cwFJyKkInz04SxWBxUlJWfAuo6774=
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Nq7glLZSZ8zOLTh1pXDnKZi+MxsMRyHw/bZODC3mvrY=
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 h1:ROS9UG/Dg0NpsaDuEmDFV9CsIC1R92sOm3QauF7IvL0=
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/Pke1v2VnSFcR8ic+fo/kNCg3JFYK6ILTal72Ca9520=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639 h1:oAXU1t8A1rGIr82JPBaNYSGLicXqhWglKaEAtPmrD0c=
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:Y5kgBh0hBbL/hgc+abCF9Vbhov+vCsB0ckpRzUw1D+0=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639 h1:ILh7uctNwgx/89qHoR/H2kz5TpWeKEeIH0O2pDPE7Iw=
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:JRtTjIjMTkzn59IN4vCYei9ZwJGFnN1Puwame4V/sD0=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -25,5 +45,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
@@ -114,12 +114,11 @@ func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) {
if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil {
t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err)
}
}
func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3
st := newMem()
// A claim losing the create race gets nothing (merged into this test for the test budget, design 51).
st = newMem()
st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) }
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
if err != nil || got != nil {
t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err)
}
@@ -197,12 +196,11 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing
if st.writes != writes {
t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes)
}
}
func TestFinishAfterTheKeyChangedIsAConflictNotAnOverwrite(t *testing.T) { // 1607/AC3
st := newMem()
c := Claimer{Store: st, By: "novox.a"}
got, _ := c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
// Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget).
st = newMem()
c = Claimer{Store: st, By: "novox.a"}
got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`))
err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"})
if !errors.Is(err, ErrConflict) {
@@ -34,3 +34,45 @@ func importsABuildAnswersReachTheDoneKeyAndTheLog(t *testing.T) {
t.Fatalf("log %+v, want the imports on the end alone", logged)
}
}
// Design 26 (the lead's ruling on A2i's finding 2): the out-of-step files a contracts module's build module answers, in a
// failed step's result too, reach the log's end entry, sorted and each once, where the indexer raises its finding; a
// build that answers none names none.
func outOfStepFilesABuildAnswersReachTheLog(t *testing.T) {
for _, row := range []struct {
name string
fail bool
files []string
want string
}{
{"a passed build naming files", false, []string{"go/b.go", "go/a.go", "go/b.go"}, "go/a.go,go/b.go"},
{"a failed build naming files", true, []string{"ts/x.ts"}, "ts/x.ts"},
{"a build naming none", false, nil, ""},
} {
t.Run(row.name, func(t *testing.T) {
r := newRig(t, goBundle)
r.asks.Answer = func(a delivery.StepAsk) delivery.Step {
step := r.answer(a)
answer := map[string]any{}
_ = json.Unmarshal(step.Result, &answer)
if row.files != nil {
answer["out-of-step"] = row.files
}
step.Result, _ = json.Marshal(answer)
if row.fail {
step.State, step.Reason = delivery.StepFailed, "the generated files are out of step"
}
return step
}
var logged []delivery.BuildLogEntry
var mu sync.Mutex
r.b.Node, r.b.Log = "novox", func(e delivery.BuildLogEntry) { mu.Lock(); logged = append(logged, e); mu.Unlock() }
r.consider(t, "modules/m")
mu.Lock()
defer mu.Unlock()
if len(logged) != 2 || strings.Join(logged[1].OutOfStep, ",") != row.want || len(logged[0].OutOfStep) != 0 {
t.Fatalf("log %+v, want the end naming %q", logged, row.want)
}
})
}
}
@@ -0,0 +1,140 @@
package build
import (
"context"
"fmt"
"strconv"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// Numbers numbers a trunk build at its claim (novox/hq design 51 *The versions*, ADR 0606, ADR 0617 §1) and records the
// digests it was built with. Nil numbers nothing.
type Numbers interface {
// tag is a check's build's tag (TagOf), empty for a trunk build; done the digests of its key when it is done already
// (versions.BuildClaim.Done), nil otherwise.
Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string, done map[string]string) (versions.Numbering, error)
Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error
}
// VersionNumbers is Numbers over the builder's state versions.
type VersionNumbers struct {
N *versions.Numberer
// TagNumbered is told the versions a check's build of a tag ask's head was numbered (the ask is then marked numbered
// in tag-asks, design 51 *Tagged versions*); nil tells nobody.
TagNumbered func(ctx context.Context, c versions.BuildClaim, got versions.Numbering)
}
// ClaimOf is the claim a module value's trunk build is numbered by: a package per source revision (Src), the rest per
// fingerprint, in merge order (Src).
func ClaimOf(module string, m delivery.Module, plan Plan) versions.BuildClaim {
c := versions.BuildClaim{Module: module, Path: m.Path, Repository: delivery.RepositoryName(m.Repository), Fingerprint: m.FP2,
Commit: m.Commit, Merge: m.Src}
if m.Src != 0 {
c.Revision = strconv.FormatUint(m.Src, 10)
}
for _, t := range plan.Tasks {
c.Artifacts = append(c.Artifacts, versions.ClaimArtifact{Artifact: t.Artifact, Kind: t.Kind, Language: t.Language})
}
return c
}
func (v VersionNumbers) Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string, done map[string]string) (versions.Numbering, error) {
c := ClaimOf(module, m, plan)
c.Done = done
if tag != "" {
// A check's build of a head a tag ask names: its packages tagged, below the next version (design 51 *Tagged versions*).
c.Tag, c.Head, c.Revision, c.Merge = tag, m.Commit, "", 0
}
got, err := v.N.Number(ctx, c)
if err == nil && tag != "" && len(got.Versions) > 0 && v.TagNumbered != nil {
v.TagNumbered(ctx, c, got)
}
return got, err
}
func (v VersionNumbers) Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
digests := map[string]string{}
for _, a := range made {
digests[a.Name] = a.Digest
}
return v.N.Built(ctx, ClaimOf(module, m, plan), got, digests)
}
// TagOf answers the cleaned tag a kept tag ask names for a repository's head (the builder's state tag-asks, design 51
// *Tagged versions*): a check's build of that head numbers its packages tagged at its claim.
type TagOf func(ctx context.Context, repository, head string) (tag string, ok bool)
// number numbers a build before its claim, a trunk build's artifacts or a check's build's packages under its tag: false
// when it waits for its floor (a package, design 51 *The floor*), and then nothing is claimed; the floor's wake looks at
// the module again.
func (b *Builder) number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string) (versions.Numbering, bool) {
if b.Numbers == nil {
return versions.Numbering{}, true
}
var done map[string]string
if tag == "" {
done = b.doneDigests(ctx, module, m.FP2)
}
got, err := b.Numbers.Number(ctx, module, m, plan, tag, done)
for _, s := range got.Said {
b.logf("%s at %s: %s", module, m.FP2, s.Line)
// And in the build log (design 51 *The versions*: it says a version was kept, and why), between the build's start and end.
// A look at a key already done builds nothing and has no start or end: a version kept there says nothing new, and is
// not logged (each later look, a first view answered or a name heard, would add one).
if b.Log != nil && !(done != nil && s.Why == versions.SaidKept) {
b.Log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: module, FP: m.FP2, Commit: m.Commit, Node: b.Node,
At: time.Now().UTC(), Line: s.Line, Why: s.Why})
}
}
if err != nil {
// Said, and built as before: an unnumbered build is published by its digest (ADR 0617 §1), numbered at its next look.
b.logf("%s at %s: %v", module, m.FP2, fmt.Errorf("not numbered, the versions cannot be used: %w", err))
return versions.Numbering{}, true
}
if got.Wait != "" {
b.logf("%s at %s is not built yet: %s", module, m.FP2, got.Wait)
return got, false
}
return got, true
}
// doneDigests are the digests of a trunk build's key by artifact when the key is done, nil otherwise or when it cannot be
// read: a look at a done build numbers what it left unnumbered with them (versions.BuildClaim.Done).
func (b *Builder) doneDigests(ctx context.Context, module, fp string) map[string]string {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil || b.Claimer.Store == nil {
return nil
}
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
if err != nil || !found {
return nil
}
cur, err := delivery.DecodeBuilderBuild(raw)
if err != nil || cur.State != delivery.BuildDone {
return nil
}
out := map[string]string{}
for _, a := range cur.Artifacts {
if a.Digest != "" {
out[a.Name] = a.Digest
}
}
return out
}
// PackageNamed looks at a module again when the indexer's packages state names a name for one of its packages
// (versions.NamedModule): a done trunk build that left the package unnumbered, its name not known at its claim, is
// numbered then with the done build's digests, and version.numbered says them for its build module's late publish.
func (b *Builder) PackageNamed(ctx context.Context, module string) {
b.init()
b.mu.Lock()
m, ok := b.known[module]
b.mu.Unlock()
if ok {
b.consider(ctx, module, m)
}
}
@@ -3,11 +3,12 @@ package build
import (
"errors"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/delivery"
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -2,9 +2,10 @@ package build
import (
"context"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -14,9 +14,12 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet).
@@ -79,6 +82,16 @@ type Builder struct {
// time.Now. Node is the node the builder runs on, which asked the build.
Now func() time.Time
Node string
// Numbers numbers each trunk build at its claim and records what it was built with (design 51 *The versions*); nil
// numbers nothing.
Numbers Numbers
// Tags answers a check's build's tag from the kept tag asks; nil numbers no check's build.
Tags TagOf
// Forms answers what each node's engine states it reads (ADR 0618 §2, ADR 0624 §1): present, the nodes whose engine
// answered when last asked, and counted, every node the placement names, present or away, a node with no statement
// stating nothing (the baseline's first forms). A done key names each artifact in the forms these keep, the oldest in
// its single reference, and its needs. Nil: no node states anything, and the first forms are kept.
Forms func() (present, counted map[string][]string)
once sync.Once
sem chan struct{}
@@ -90,8 +103,10 @@ type Builder struct {
plans map[string]planned // by manifest digest
cands map[string]delivery.Candidate // by candidates key (candidates.go)
via map[string]via // by build key: a candidate's fetch (candidates.go)
trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint
tried map[string]bool // by build key: looked at since this process started
// outOfStep are, by build key, the out-of-step files a build's lanes answered, until its end entry takes them.
outOfStep map[string][]string
trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint
tried map[string]bool // by build key: looked at since this process started
// published are the package versions `<name>@<version>` a done key of this bucket names as a package's reference: a key
// waiting for one is built once it is here (ADR 0500 clause 6).
published map[string]bool
@@ -430,6 +445,17 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
first := !b.tried[key]
b.tried[key] = true
b.mu.Unlock()
var numbering versions.Numbering
tag := ""
if !trunk && b.Tags != nil && b.refOf(module, m.FP2) != "" {
tag, _ = b.Tags(ctx, delivery.RepositoryName(m.Repository), m.Commit)
}
if (trunk || tag != "") && planErr == nil && !nothing {
var goOn bool
if numbering, goOn = b.number(ctx, module, m, plan, tag); !goOn {
return
}
}
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished,
Stored: b.storedOnce(key)})
if err != nil {
@@ -490,7 +516,14 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
b.logf("%s: the build could not be written: %v", cl.Key, err)
return
}
b.logBuild(LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result))
ended := LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result)
ended.OutOfStep = b.takeOutOfStep(key) // the generated files the build module's compare found out of step (design 26)
b.logBuild(ended)
if result.State == delivery.BuildDone && b.Numbers != nil && len(numbering.Versions) > 0 {
if err := b.Numbers.Built(ctx, module, m, plan, numbering, result.Artifacts); err != nil {
b.logf("%s: %v", cl.Key, err)
}
}
if result.State == delivery.BuildFailed {
b.logf("%s: failed: %s", cl.Key, result.Error)
}
@@ -536,6 +569,7 @@ type lane struct {
digests map[string]string // artifact -> digest
references map[string]string // artifact -> the reference an image was pushed to (a trunk build only)
imports []string // the mesh packages the lane's programs import, as its build module answered them
outOfStep []string // the generated files its build module's compare found out of step, passed or failed
err error
}
@@ -591,6 +625,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
go func() { defer wg.Done(); b.runLane(ctx, l, repository, m, module, asked) }()
}
wg.Wait()
if key, err := delivery.BuilderBuildKey(module, m.FP2); err == nil {
b.keepOutOfStep(key, lanes)
}
var errs []string
for _, l := range lanes {
if l.err != nil {
@@ -606,6 +643,10 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s", keyError(errs))
}
var artifacts []delivery.BuiltArtifact
var present, counted map[string][]string
if b.Forms != nil {
present, counted = b.Forms()
}
keptModule := map[string]bool{}
for _, bm := range kept {
keptModule[bm] = true
@@ -634,6 +675,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s", err)
}
built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d}
var answered []string
for _, l := range lanes {
if l.module != t.Module {
continue
@@ -645,10 +687,11 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s%s on %s: the build answered no reference, so its push to the artifact store was not answered", delivery.NotStored, t.Artifact, l.node)
}
if r != "" {
built.Reference = r
answered = append(answered, r)
}
}
artifacts = append(artifacts, built)
// The forms a counted node's engine reads are kept, the oldest in the single reference (ADR 0624 §1, §3).
artifacts = append(artifacts, forms.Current.Choose(built, answered, present, counted))
}
var steps []string
for _, l := range lanes {
@@ -670,7 +713,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
used = nil
}
done := delivery.BuilderBuild{State: delivery.BuildDone, By: b.Claimer.By, Manifest: m.Manifest, Step: step, Artifacts: artifacts, Toolchains: used,
Imports: importsOf(lanes)}
Imports: importsOf(lanes), Needs: forms.Current.Needs(artifacts)} // the forms it is written in (ADR 0618 §1)
if _, err := delivery.EncodeBuilderBuild(done); err != nil {
// A reference the build module answered that does not name the artifact and its digest is no reference.
return failed("%s%v", delivery.NotStored, err)
@@ -765,7 +808,7 @@ func (n *askNotes) note(ctx context.Context, language, id, step string) error {
n.mu.Lock()
defer n.mu.Unlock()
v := n.cl.Build
v.State, v.By, v.Step, v.Error, v.Artifacts = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil
v.State, v.By, v.Step, v.Error, v.Artifacts, v.Needs = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil, nil
v.Asks = maps.Clone(v.Asks)
if v.Asks == nil {
v.Asks = map[string]string{}
@@ -861,6 +904,12 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del
return
}
l.node = step.Node
// The build module's compare of a contracts module's generated files (design 26, CAT-BG5/BN2) answers the files out
// of step as delivery.BuildAnswer.OutOfStep, in a failed step's result too: read before the step's state is.
var compared delivery.BuildAnswer
if len(step.Result) > 0 && json.Unmarshal(step.Result, &compared) == nil {
l.outOfStep = compared.OutOfStep
}
if step.State != delivery.StepPassed {
l.err = fmt.Errorf("%s on %s: the ask %s %s: %s", l.module, step.Node, ask.ID, step.State, step.Reason)
return
@@ -959,6 +1008,36 @@ func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, re
return e
}
// keepOutOfStep holds, by build key, the out-of-step files the lanes' build modules answered, sorted and each once, until
// the build's end entry takes them (takeOutOfStep); a build with none holds nothing.
func (b *Builder) keepOutOfStep(key string, lanes []*lane) {
seen := map[string]bool{}
for _, l := range lanes {
for _, f := range l.outOfStep {
seen[f] = true
}
}
b.mu.Lock()
defer b.mu.Unlock()
if len(seen) == 0 {
delete(b.outOfStep, key)
return
}
if b.outOfStep == nil {
b.outOfStep = map[string][]string{}
}
b.outOfStep[key] = slices.Sorted(maps.Keys(seen))
}
// takeOutOfStep answers the out-of-step files held for a build key and forgets them.
func (b *Builder) takeOutOfStep(key string) []string {
b.mu.Lock()
defer b.mu.Unlock()
files := b.outOfStep[key]
delete(b.outOfStep, key)
return files
}
// importsOf are the mesh packages the lanes' programs import, as their build modules answered them (novox/hq ADR 0593
// §2): sorted and each once; nil when none answered any. A lane kept from a prior build answered none, so a build that
// kept one reports only what it built.
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"sync"
@@ -14,9 +15,12 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// buildModules simulates the two build modules on the nodes: a long tool answers a step and key at once and writes the
@@ -222,6 +226,8 @@ var (
func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7, ADR 0478 clauses 1, 4
t.Run("the imports a build answers reach the done key and the log", importsABuildAnswersReachTheDoneKeyAndTheLog)
t.Run("the out-of-step files a build answers reach the log", outOfStepFilesABuildAnswersReachTheLog)
t.Run("ADR 0624: a done key keeps each form a counted node reads", aDoneKeyKeepsEachFormACountedNodeReads)
r := newRig(t, goBundle, archiveA, tsBundle)
// ADR 0584: a third node agrees with novox and ace's typescript is the odd one; the build is done and its step says ace sits out.
r.tools.Put(buildrun.NodeBuildModule, "g14.typescript", tcValue(t, "typescript", "tsc5"), false)
@@ -278,6 +284,11 @@ func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7
if v.Artifacts[0].Kind != "archive" || v.Artifacts[1].Kind != "bundle" {
t.Errorf("kinds = %+v", v.Artifacts)
}
// ADR 0618 §1: the done key names the forms its references are written in, here the first form alone, and keeps no list
// beside the single reference while no counted node needs another form.
if !slices.Equal(v.Needs, []string{"form:artifact-store"}) || v.Artifacts[0].References != nil {
t.Errorf("needs = %v, references = %v", v.Needs, v.Artifacts[0].References)
}
if got := v.Toolchains["go"]; got.Version != "go1.27.1" || got.Recipe != "1" {
t.Errorf("toolchains = %+v, want go and typescript named", v.Toolchains)
}
@@ -418,8 +429,19 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
r := newRig(t, imageArt)
r.puller = "ace"
trunkModule(t, r)
// DLV-B6: a check's build of a head a kept tag ask names is numbered under that tag at its claim.
nums := &fakeNumbers{}
var tagAsked []string
r.b.Numbers = nums
r.b.Tags = func(_ context.Context, repository, head string) (string, bool) {
tagAsked = append(tagAsked, repository+"@"+head)
return "feat-x", head == headX
}
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
if !slices.Equal(nums.tags, []string{"feat-x"}) || len(tagAsked) != 1 || !strings.HasSuffix(tagAsked[0], "@"+headX) {
t.Errorf("the check's build numbered with tags %v (asked %v), want feat-x for its head", nums.tags, tagAsked)
}
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
t.Fatalf("build = %+v, want done with no reference", v)
@@ -433,14 +455,69 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
}
}
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b; design 51 *The versions*: numbered at the claim
r := newRig(t, imageArt)
r.puller = "ace"
nums := &fakeNumbers{wait: "m/img: the package waits for its floor"}
r.b.Numbers = nums
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
r.b.Wait()
if _, ok := r.st.vals[r.key]; ok || nums.numbered != 1 {
t.Fatalf("a build waiting for its floor was claimed (numbered %d times)", nums.numbered)
}
nums.wait, nums.versions = "", map[string]string{"img": "0.1.4"}
r.consider(t, "modules/m")
calls := r.bm.callsOf("build_image_build")
if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true {
t.Errorf("calls = %+v, want publish true", calls)
}
if nums.built["img"] != digest {
t.Errorf("the built digest recorded = %v, want %s for img", nums.built, digest)
}
if nums.done != nil {
t.Errorf("a build still to make was numbered with done digests %v", nums.done)
}
// The indexer's packages state names a package of the module (PackageNamed): the done build is looked at again and
// numbered with its key's digests, so version.numbered carries them for the late publish; nothing is built again.
looks := nums.numbered
r.b.PackageNamed(context.Background(), "m")
r.b.Wait()
if nums.numbered != looks+1 || nums.done["img"] != digest || len(r.bm.callsOf("build_image_build")) != 1 {
t.Errorf("a named package's done build: numbered %d more times with %v, %d image builds; want once with img %s and no build",
nums.numbered-looks, nums.done, len(r.bm.callsOf("build_image_build")), digest)
}
r.b.PackageNamed(context.Background(), "other")
r.b.Wait()
if nums.numbered != looks+1 {
t.Errorf("a module the builder does not know was looked at")
}
}
// fakeNumbers is Numbers that answers what it is set to and keeps the digests a done build recorded.
type fakeNumbers struct {
wait string
versions map[string]string
numbered int
built map[string]string
tags []string
done map[string]string // the done key's digests the last look numbered with
}
func (f *fakeNumbers) Number(_ context.Context, _ string, _ delivery.Module, _ Plan, tag string, done map[string]string) (versions.Numbering, error) {
f.tags = append(f.tags, tag)
f.done = done
f.numbered++
return versions.Numbering{Wait: f.wait, Versions: f.versions}, nil
}
func (f *fakeNumbers) Built(_ context.Context, _ string, _ delivery.Module, _ Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
f.built = map[string]string{}
for _, a := range made {
if got.Versions[a.Name] != "" {
f.built[a.Name] = a.Digest
}
}
return nil
}
func TestAnUnpinnedUpstreamFailsNotPinnedAndIsNeverBuiltAgain(t *testing.T) { // B3, ADR 0498 clause 4
@@ -1234,3 +1311,117 @@ type silentWatch struct{}
func (silentWatch) WatchAsk(context.Context, string, string) (<-chan delivery.Step, error) {
return make(chan delivery.Step), nil
}
// aDoneKeyKeepsEachFormACountedNodeReads holds the builder's rows of ADR 0624's check: every build keeps each form a
// counted node's engine reads, present or away, a node stating nothing reading the baseline's first form; the oldest kept
// form is the single reference; a retired node leaves the count. old is the first form, new a fixture second form read
// only by an engine stating form:new.
func aDoneKeyKeepsEachFormACountedNodeReads(t *testing.T) {
fs := forms.ArtifactForms{
{Name: "old", First: true, Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "artifact-store://") }},
{Name: "new", Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "10.0.0.1:5000/") }},
}
both, nothing, onlyNew := []string{"form:new", "word:module"}, []string(nil), []string{"form:new"}
oldRef, newRef := "artifact-store://m/code/blobs/"+digest, "10.0.0.1:5000/m/code@"+digest
for _, row := range []struct {
name string
present, counted map[string][]string
kept []string
reference string
references []string
}{
{"A present {old,new}, B away {old}: both forms, the old in the single reference",
map[string][]string{"a": both}, map[string][]string{"a": both, "b": {"word:module"}}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"A present {new}, B away stating nothing: the baseline's form stays",
map[string][]string{"a": onlyNew}, map[string][]string{"a": onlyNew, "b": nothing}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"A present {old,new}, B away and never asked (no key): the baseline's form stays",
map[string][]string{"a": both}, map[string][]string{"a": both, "b": nil}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"B retired: the new form only", map[string][]string{"a": both}, map[string][]string{"a": both}, []string{"new"}, newRef, nil},
{"B present stating nothing: the new form is not written",
map[string][]string{"a": both, "b": nothing}, map[string][]string{"a": both, "b": nothing}, []string{"old"}, oldRef, nil},
{"no node present: the first form", nil, map[string][]string{"a": both}, []string{"old"}, oldRef, nil},
} {
if got := fs.Kept("bundle", row.present, row.counted); !slices.Equal(got, row.kept) {
t.Errorf("%s: kept %v, want %v", row.name, got, row.kept)
}
a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{newRef, oldRef}, row.present, row.counted)
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
t.Errorf("%s: %q %v, want %q %v", row.name, a.Reference, a.References, row.reference, row.references)
}
// An engine that predates the list reads the single reference, in a form every counted node reads (ADR 0624 §3).
if form, _ := fs.FormOf("bundle", a.Reference); form != row.kept[0] {
t.Errorf("%s: the single reference is in %q, want the oldest kept %q", row.name, form, row.kept[0])
}
}
// An answered reference no kept form names still names where the artifact is (ADR 0498 clause 1).
if a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{oldRef}, map[string][]string{"a": both},
map[string][]string{"a": both}); a.Reference != oldRef {
t.Errorf("a lone answered reference was dropped: %+v", a)
}
withBoth := []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: digest, Reference: oldRef, References: []string{oldRef, newRef}}}
if got := fs.Needs(withBoth); !slices.Equal(got, []string{"form:new", "form:old"}) {
t.Errorf("needs %v", got)
}
// The builder's own forms (forms.Current): a bundle a build step answers in the registry seat's form (mesh-build-go's
// oci-artifact-registry/<module>/<artifact>@<digest>) keeps the artifact-store form it derives for each counted node
// whose engine does not state form:seat-reference, that one in the single reference (ADR 0624 §1, §3).
seat := []string{forms.FormNeed(forms.FormSeatReference), "word:module"}
// Each kind the seat form names (a bundle through oci-artifact-registry, an image and an upstream copy through
// container-image-registry, the copy under the holder's own upstream/<host>/<path>), with the artifact-store form it derives.
for _, k := range []struct{ kind, seatRef, storeRef string }{
{"bundle", "oci-artifact-registry/m/code@" + digest, "artifact-store://m/code/blobs/" + digest},
{"image", "container-image-registry/m/code@" + digest, "artifact-store://m/code@" + digest},
{"upstream", "container-image-registry/upstream/quay.io:443/org/app@" + digest, "artifact-store://upstream/quay.io:443/org/app@" + digest},
} {
seatRef, storeRef := k.seatRef, k.storeRef
for _, row := range []struct {
name string
present, counted map[string][]string
reference string
references []string
needs []string
}{
{"seat form answered, B away stating nothing: the derived store form stays, in the single reference",
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": nil}, storeRef, []string{storeRef, seatRef},
[]string{"form:artifact-store", "form:seat-reference"}},
{"seat form answered, every counted engine reads it: the seat form only",
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": seat}, seatRef, nil, []string{"form:seat-reference"}},
{"seat form answered, a present engine stating nothing: the store form only",
map[string][]string{"a": seat, "b": nil}, map[string][]string{"a": seat, "b": nil}, storeRef, nil, []string{"form:artifact-store"}},
{"seat form answered, no node present: the store form", nil, map[string][]string{"a": seat}, storeRef, nil, []string{"form:artifact-store"}},
} {
a := forms.Current.Choose(delivery.BuiltArtifact{Name: "code", Kind: k.kind, Digest: digest}, []string{seatRef}, row.present, row.counted)
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
t.Errorf("%s %s: %q %v, want %q %v", k.kind, row.name, a.Reference, a.References, row.reference, row.references)
}
needs := forms.Current.Needs([]delivery.BuiltArtifact{a})
if !slices.Equal(needs, row.needs) {
t.Errorf("%s %s: needs %v, want %v", k.kind, row.name, needs, row.needs)
}
for node, stated := range row.counted {
if forms.Current.Held(needs, stated) {
t.Errorf("%s %s: the want of %s is held", k.kind, row.name, node)
}
}
if _, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.TrimPrefix(digest, "sha256:"), Artifacts: []delivery.BuiltArtifact{a}, Needs: needs}); err != nil {
t.Errorf("%s %s: %v", k.kind, row.name, err)
}
}
}
// What the assigner holds a want by (ADR 0624 §2), from the same forms.
for _, row := range []struct {
name string
needs []string
stated []string
held bool
}{
{"a node stating {old}, a build carrying {old,new}: moves", []string{"form:new", "form:old"}, []string{"word:module"}, false},
{"a node stating nothing, a build carrying the baseline's form: moves", []string{"form:old"}, nil, false},
{"a build carrying {new} only, the node stating {old}: held", []string{"form:new"}, []string{"word:module"}, true},
{"a build naming no form: moves", nil, nil, false},
} {
if got := fs.Held(row.needs, row.stated); got != row.held {
t.Errorf("%s: held %v", row.name, got)
}
}
}
@@ -1,6 +1,8 @@
package build
import (
"mesh-delivery-builder/internal/versions"
"encoding/json"
"errors"
"fmt"
@@ -54,6 +56,67 @@ type ReAsk struct {
Failed string `json:"failed,omitempty"`
Revision uint64 `json:"revision,omitempty"`
Asks map[string]string `json:"asks,omitempty"`
// Name is the numbered name (`<module>/<artifact>`) of a ModePublishAgain or ModeFloorAgain value; Do and Version the
// builder's answer: what it did (versions.Again*) and the version published.
Name string `json:"name,omitempty"`
Do string `json:"do,omitempty"`
Version string `json:"version,omitempty"`
}
// ModePublishAgain is the Mode of a reasks value of builder_publish_again (design 51 *Tools of the builder*).
const ModePublishAgain = "publish-again"
// ModeFloorAgain is the Mode of a reasks value of builder_floor_again (design 51 *The floor*).
const ModeFloorAgain = "floor-again"
// NewPublishAgain is the value builder_publish_again writes for a name's fingerprint: the key of the name's module and
// fingerprint, and the value.
func NewPublishAgain(name, fp, why, by string, at time.Time) (string, []byte, error) {
module, artifact, ok := strings.Cut(strings.TrimSpace(name), "/")
if !ok || module == "" || artifact == "" {
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
}
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return "", nil, err
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why it is published again (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModePublishAgain, Module: module, FP: fp, Name: strings.TrimSpace(name)})
return key, data, err
}
// NewFloorAgain is the value builder_floor_again writes for a name: the key `floor.<name escaped>`, never a build key,
// and the value.
func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) {
name = strings.TrimSpace(name)
if module, artifact, ok := strings.Cut(name, "/"); !ok || module == "" || artifact == "" {
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why the floor is asked again (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeFloorAgain, Name: name})
return "floor." + versions.EscapeToken(name), data, err
}
// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old
// reference form*): a retired node dropped from the count by hand; Name is the node.
const ModeOldFormDrop = "old-form-drop"
// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.<node>`, never a build
// key, and the value.
func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) {
node = strings.TrimSpace(node)
if node == "" || strings.ContainsAny(node, ". *>") {
return "", nil, fmt.Errorf("node %q is not a node's name", node)
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why the node leaves the count (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node})
return "old-form." + node, data, err
}
// ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again.
@@ -0,0 +1,351 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// What builder_publish_again does with one fingerprint of a name (design 51 *Tools of the builder*), after a holder failed.
const (
// AgainPublish says version.numbered again for the recorded version: the build module publishes the held digest.
AgainPublish = "publish"
// AgainBuild builds the fingerprint again under the same number: the build's file is gone and no published was said
// for the number, so the new digest replaces the recorded one (Numberer.Built).
AgainBuild = "build"
// AgainRenumber takes a new number: a published was said for the number, so other bytes never go under it.
AgainRenumber = "renumber"
)
// Again is what builder_publish_again does, with the entry it acts on and why.
type Again struct {
Do string `json:"do"`
Entry Entry `json:"entry"`
Why string `json:"why"`
}
// PublishAgain decides builder_publish_again for fingerprint among a name's entries; built says whether the builder still
// holds the build's file (its done key). It refuses a fingerprint not numbered, and a version refused for other bytes
// than the ones recorded under it.
func PublishAgain(entries []Entry, fingerprint string, built bool) (Again, error) {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return Again{}, errors.New("fp is required: the fingerprint whose version is published again")
}
for _, e := range latestFirst(entries) {
if e.Tag != "" || e.Fingerprint != fingerprint {
continue
}
switch {
case e.State == Refused && e.RefusedDigest != "" && e.Digest != "" && e.RefusedDigest != e.Digest:
return Again{}, fmt.Errorf("%s@%s was refused for other bytes (%s, recorded %s): it is not published again; a new build takes a new number",
e.Name, e.Version, e.RefusedDigest, e.Digest)
case e.State == Published || e.State == Removed:
return Again{Do: AgainRenumber, Entry: e, Why: fmt.Sprintf("%s@%s was %s, so the fingerprint takes a new number", e.Name, e.Version, e.State)}, nil
case !built:
return Again{Do: AgainBuild, Entry: e, Why: fmt.Sprintf("the build's file is gone and no published was said for %s@%s: built again under the same number", e.Name, e.Version)}, nil
}
return Again{Do: AgainPublish, Entry: e, Why: fmt.Sprintf("%s@%s (%s) is said numbered again, so its build module publishes the held digest", e.Name, e.Version, e.State)}, nil
}
return Again{}, fmt.Errorf("fingerprint %s is not numbered for this name: builder_versions lists what is", fingerprint)
}
// The builder's state of tag asks (design 51 *Tagged versions*): one key per repository and branch, removed when the
// branch is merged or deleted or its pull request closed.
const (
TagAsksState = "tag-asks"
TagAsksBucket = "mesh-delivery-builder_tag-asks"
)
// The states of a tag ask.
const (
TagAsked = "asked"
TagNumbered = "numbered"
TagRemoving = "removing"
TagRemoved = "removed"
)
// TagAsk is one value of tag-asks: an ask of a tagged version for a branch's head, from the checker's tag-wanted or by
// hand (builder_tag), or by hand the removal of the branch's tagged versions (builder_untag, Untag).
type TagAsk struct {
V int `json:"v"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Head string `json:"head,omitempty"`
Tag string `json:"tag"`
By string `json:"by,omitempty"`
Why string `json:"why,omitempty"`
At time.Time `json:"at"`
Untag bool `json:"untag,omitempty"`
// State, Versions (name to version), Left (what a removal still waits on, name@version to its seat) and Answer are
// the builder's.
State string `json:"state,omitempty"`
Versions map[string]string `json:"versions,omitempty"`
Left map[string]string `json:"left,omitempty"`
Answer string `json:"answer,omitempty"`
Answered time.Time `json:"answered,omitzero"`
}
// TagAskKey is the key of a repository's branch in tag-asks.
func TagAskKey(repository, branch string) string {
return EscapeToken(repository) + "." + EscapeToken(branch)
}
// NewTagAsk is the value builder_tag writes: its key and the value. The tag is the branch's, cleaned (CleanTag).
func NewTagAsk(repository, branch, head, why, by string, at time.Time) (string, []byte, error) {
return newTagAsk(repository, branch, head, why, by, at, false)
}
// NewUntag is the value builder_untag writes: its key and the value.
func NewUntag(repository, branch, why, by string, at time.Time) (string, []byte, error) {
return newTagAsk(repository, branch, "", why, by, at, true)
}
func newTagAsk(repository, branch, head, why, by string, at time.Time, untag bool) (string, []byte, error) {
repository, branch, head, why = strings.TrimSpace(repository), strings.TrimSpace(branch), strings.TrimSpace(head), strings.TrimSpace(why)
switch {
case repository == "" || !strings.Contains(repository, "/"):
return "", nil, errors.New("repository is required: <owner>/<name>")
case branch == "":
return "", nil, errors.New("branch is required")
case !untag && head == "":
return "", nil, errors.New("head is required: the commit the tagged version is built from")
case why == "":
return "", nil, errors.New("say why (why): a hand act is recorded")
}
data, err := json.Marshal(TagAsk{V: 1, Repository: repository, Branch: branch, Head: head, Tag: CleanTag(branch), By: by, Why: why,
At: at.UTC(), Untag: untag, State: TagAsked})
return TagAskKey(repository, branch), data, err
}
// DecodeTagAsk reads a tag-asks value.
func DecodeTagAsk(data []byte) (TagAsk, error) {
var a TagAsk
if err := json.Unmarshal(data, &a); err != nil {
return a, err
}
if a.V != 1 {
return a, fmt.Errorf("a tag-asks value of version %d, not 1", a.V)
}
return a, nil
}
// TagAsksAnswer is what builder_tag_asks answers: the open asks, oldest first.
type TagAsksAnswer struct {
Asks []TagAsk `json:"asks"`
}
// AnswerTagAsks is builder_tag_asks over the state read, only repository's when it is given.
func AnswerTagAsks(r Reader, repository string) (TagAsksAnswer, error) {
keys, err := r.Keys()
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s cannot be listed: %w", TagAsksState, err)
}
out := TagAsksAnswer{Asks: []TagAsk{}}
for _, k := range keys {
raw, ok, err := r.Get(k)
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s.%s: %w", TagAsksState, k, err)
}
if !ok {
continue
}
a, err := DecodeTagAsk(raw)
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s.%s holds what is not a tag ask: %w", TagAsksState, k, err)
}
if repository == "" || a.Repository == repository {
out.Asks = append(out.Asks, a)
}
}
sort.Slice(out.Asks, func(i, j int) bool { return out.Asks[i].At.Before(out.Asks[j].At) })
return out, nil
}
// Untagged are repository's tagged versions of tag that are not removed (a group's branch spans repositories, each
// ending apart): what a branch's end, or builder_untag, removes through
// each one's seat (design 51 *Tagged versions*). A version with no tag is never among them.
func Untagged(entries []Entry, repository, tag string) []Entry {
tag = CleanTag(tag)
var out []Entry
for _, e := range entries {
if e.Tag != "" && e.Repository == repository && CleanTag(e.Tag) == tag && e.State != Removed && e.State != Kept {
out = append(out, e)
}
}
return InOrder(out)
}
// The tools' descriptions.
const (
PublishAgainDescription = "Publish one fingerprint's version again after its holder failed (design 51): with the build's file held, the " +
"builder says version.numbered again and the build module publishes the held digest; with the file gone, it builds the fingerprint " +
"again under the same number, the new digest replacing the recorded one, since no published was said for it; after a published (or " +
"a removal) it takes a new number. It refuses a fingerprint not numbered and a version the holder refused for other bytes. A hand " +
"act, recorded with its caller and why in the builder's log."
FloorAgainDescription = "Ask the floor of one name again now, by hand (design 51 *The floor*): the seat's versions, the forge bridge's " +
"bridge_tags and the indexer's indexer_requirements, as the builder asks them by itself when a silent source says something. " +
"Answers which sources were silent and whether the floor is now known. A name the builder's versions already hold needs no floor: " +
"refused, saying so. A hand act."
TagDescription = "Ask a tagged version for a branch's head by hand, as the checker's tag-wanted asks it (design 51 *Tagged versions*): " +
"the builder keeps the ask in its state tag-asks until the branch is merged or deleted, or its pull request closed, and numbers " +
"each package the head builds below the next trunk version (0.1.15-<tag>.2). A hand act."
TagAsksDescription = "The open tag asks of the builder's state tag-asks, oldest first: repository, branch, head, tag, who asked and why, " +
"its state (asked, numbered, removing, removed), the versions numbered for it and, while its tagged versions are removed, the " +
"seats that have not yet said removed. With repository, that repository's only. Read-only."
UntagDescription = "Remove a branch's tagged versions by hand, as its merge, its pull request's close or its deletion does (design 51 " +
"*Tagged versions*): the builder calls each seat's remove with the reason tag-gone and marks each version removed once its seat says " +
"removed. Answers the versions it removes and the seats still waited on. A version with no tag is never removed. A hand act."
)
// Acts are the builder's hand acts on its versions, answered by its program (the tools bundle writes them to reasks).
type Acts struct {
Numberer *Numberer
// Done says whether the build key of module and fingerprint is done: the build's file is held.
Done func(ctx context.Context, module, fp string) (bool, error)
// BuildAgain builds a failed key again (builder_build_again's path): its answer, or why it refused.
BuildAgain func(ctx context.Context, module, fp, why, by string) (answer string, refused bool)
// Reconsider looks at every module again, so a claim asks a name's floor.
Reconsider func(ctx context.Context)
// Remove calls a seat's verb remove for one tagged version, reason tag-gone: what it answered, kept or asked.
Remove func(ctx context.Context, seat string, e Entry) (RemoveOutcome, error)
// DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not.
DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error)
}
// TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions'
// seats and leaves each in Left until its seat says removed (Heard, on the seat's events); a call that fails stays in
// Left with its error, asked again by the branch's end. A tag ask is kept: its versions are numbered when the head's
// packages are claimed for the group's check.
func (a Acts) TagAsked(ctx context.Context, t TagAsk, now time.Time) TagAsk {
t.Answered = now.UTC()
if !t.Untag {
t.State = TagAsked
t.Answer = "kept: each package the head builds is numbered " + "<next>-" + t.Tag + ".<n> when its build is claimed for the check"
return t
}
all, err := a.Numberer.Versions.All(ctx)
if err != nil {
t.Answer = "nothing removed: " + err.Error()
return t
}
t.Left, t.Versions = map[string]string{}, map[string]string{}
for _, e := range Untagged(all, t.Repository, t.Tag) {
t.Versions[e.Name] = e.Version
at := e.Name + "@" + e.Version
t.Left[at] = e.Seat
if left, ok := a.removeOne(ctx, e); ok {
delete(t.Left, at)
} else if left != "" {
t.Left[at] = left
}
}
if len(t.Left) == 0 {
t.State, t.Left, t.Answer = TagRemoved, nil, "the branch holds no tagged version: nothing to remove"
return t
}
t.State = TagRemoving
t.Answer = fmt.Sprintf("remove asked of %d tagged versions; each is marked removed when its seat says removed", len(t.Left))
return t
}
// removeOne asks e's seat to remove it: done when the seat kept it whole (recorded Kept, no retry); otherwise left, with
// the error of a call that failed.
func (a Acts) removeOne(ctx context.Context, e Entry) (left string, done bool) {
got, err := a.Remove(ctx, e.Seat, e)
if err != nil {
return e.Seat + ": " + err.Error(), false
}
if !got.Kept {
return "", false
}
if err := a.Numberer.keep(ctx, e, got.Reason, got.Tags); err != nil {
return e.Seat + ": kept, and not recorded so: " + err.Error(), false
}
return "", true
}
// PublishAgain is builder_publish_again of name's fingerprint: what it did, the version, and its answer; an error refuses.
func (a Acts) PublishAgain(ctx context.Context, name, fp, why, by string, at time.Time) (Again, string, error) {
module, _, _ := strings.Cut(name, "/")
entries, err := a.Numberer.Versions.Of(ctx, name)
if err != nil {
return Again{}, "", err
}
done, err := a.Done(ctx, module, fp)
if err != nil {
return Again{}, "", fmt.Errorf("the build key of %s at %s cannot be read: %w", module, fp, err)
}
ag, err := PublishAgain(entries, fp, done)
if err != nil {
return Again{}, "", err
}
e := ag.Entry
if ag.Do == AgainRenumber {
_, ne, err := a.Numberer.Versions.Number(ctx, Claim{Name: e.Name, Kind: e.Kind, Series: e.Series, Module: e.Module, Fingerprint: e.Fingerprint,
Commit: e.Commit, Revision: e.Revision, Seat: e.Seat, Merge: e.Merge, Fresh: true})
if err != nil {
return Again{}, "", err
}
ag.Entry = ne
}
answer := ag.Why
if ag.Do == AgainBuild || (ag.Do == AgainRenumber && !done) {
said, refused := a.BuildAgain(ctx, module, fp, why, by)
if refused {
return Again{}, "", fmt.Errorf("%s; and the build again was refused: %s", ag.Why, said)
}
answer += "; " + said
}
if ag.Do == AgainPublish || (ag.Do == AgainRenumber && done) {
if a.Numberer.Said != nil {
b := NumberedOf(ag.Entry, "")
b.Again = at.UTC().Format(time.RFC3339Nano)
a.Numberer.Said(b)
}
answer += "; " + delivery.VersionNumberedEvent + " said for " + ag.Entry.Version
}
return ag, answer, nil
}
// FloorAgain is builder_floor_again of name: the floor counts only for a name the versions hold nothing of.
func (a Acts) FloorAgain(ctx context.Context, name string) (string, error) {
entries, err := a.Numberer.Versions.Of(ctx, name)
if err != nil {
return "", err
}
if len(entries) > 0 {
return "", fmt.Errorf("%s holds %d versions: its floor counts only for its first number, so nothing is asked", name, len(entries))
}
answer := name + " waits on no floor in this builder: the floor is asked at its next claim"
if a.Numberer.Waits != nil {
for _, w := range a.Numberer.Waits.Waiting() {
if w.Name == name {
answer = fmt.Sprintf("the floor of %s is asked again (%s were silent)", name, strings.Join(w.Silent, ", "))
}
}
a.Numberer.Waits.Wake(WakeByHand, "", "", name)
}
a.Reconsider(ctx)
return answer + "; builder_versions shows its version once numbered", nil
}
// TagFor is the cleaned tag a kept tag ask names for repository's head: the ask a check's build of that head is
// numbered under (design 51 *Tagged versions*). An untag, or an ask whose branch ended, names none.
func TagFor(asks []TagAsk, repository, head string) (string, bool) {
for _, t := range asks {
if t.Untag || t.State == TagRemoving || t.State == TagRemoved || t.Head == "" {
continue
}
if t.Repository == repository && t.Head == head {
return t.Tag, t.Tag != ""
}
}
return "", false
}
@@ -0,0 +1,92 @@
package versions
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): on the forge bridge's `merged`, a
// pull's `closed` or `branch-deleted`, heard on the builder's durable consumer of the stream `forge`, the builder calls
// each seat's `remove` and acknowledges the message only once every seat said `removed` (Heard marks the entry); until
// then the bus gives it again each minute (RemoveAgainAfter) and, past RemovalFindingAfter, the builder's finding names
// the seats still owing.
const (
RemoveAgainAfter = time.Minute
RemovalFindingAfter = 15 * time.Minute
// RemovalNotDone is the finding's kind.
RemovalNotDone = "tag-removal-not-done"
)
// BranchEnd is what one hearing of a branch's end did: the versions still not removed (name@version to its seat, with
// the error of a remove that failed), and whether all are gone so the message is acknowledged.
type BranchEnd struct {
Left map[string]string
Done bool
}
// Seats are the seats still owing a removal, sorted, each once.
func (b BranchEnd) Seats() []string {
seen := map[string]bool{}
var out []string
for _, s := range b.Left {
seat, _, _ := strings.Cut(s, ":")
if !seen[seat] {
seen[seat] = true
out = append(out, seat)
}
}
sort.Strings(out)
return out
}
// Finding is the removal finding's words: what is left, since when.
func (b BranchEnd) Finding(repository, branch string, since time.Time) string {
var at []string
for v, s := range b.Left {
at = append(at, v+" on "+s)
}
sort.Strings(at)
return fmt.Sprintf("the tagged versions of %s %s are not all removed since its end at %s: %s; the builder asks remove again each minute",
repository, branch, since.UTC().Format(time.RFC3339), strings.Join(at, ", "))
}
// BranchEnded removes repository's tagged versions of branch: every one not yet removed is asked of its seat again.
// A store that cannot be read is the error: nothing is acknowledged then.
func (a Acts) BranchEnded(ctx context.Context, repository, branch string) (BranchEnd, error) {
all, err := a.Numberer.Versions.All(ctx)
if err != nil {
return BranchEnd{}, err
}
left := map[string]string{}
for _, e := range Untagged(all, repository, branch) {
at := e.Name + "@" + e.Version
left[at] = e.Seat
if a.Remove == nil {
continue
}
// A seat that kept it whole (another tag names its digest) ends it here: recorded kept, never asked again.
if l, done := a.removeOne(ctx, e); done {
delete(left, at)
} else if l != "" {
left[at] = l
}
}
return BranchEnd{Left: left, Done: len(left) == 0}, nil
}
// TagWanted is the body of the checker's `tag-wanted` (design 51 *Tagged versions*): a group member's head to number
// tagged versions for.
type TagWanted struct {
Repository string `json:"repository"`
Branch string `json:"branch"`
Head string `json:"head"`
Tag string `json:"tag,omitempty"`
}
// TagWantedAsk is the tag-asks value a tag-wanted becomes, as builder_tag writes it by hand: by the checker.
func TagWantedAsk(t TagWanted, by string, at time.Time) (string, []byte, error) {
return NewTagAsk(t.Repository, t.Branch, t.Head, "the checker's tag-wanted for a group member's check", by, at)
}
@@ -0,0 +1,263 @@
package versions
import (
"context"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The registry seats a version is published to (design 51 *The seats*), by what is published.
const (
SeatImages = "container-image-registry"
SeatArtifacts = "oci-artifact-registry"
SeatGoPackages = "go-package-registry"
SeatNPM = "npm-package-registry"
)
// SeatOf is the registry seat an artifact of kind in language is published to, and the kind the builder numbers it as;
// ok is false for what is not numbered (an upstream copy is mirrored by its pinned digest, design 51 *Publishing*).
func SeatOf(kind, language string) (seat, numberedAs string, ok bool) {
switch kind {
case KindImage:
return SeatImages, KindImage, true
case KindBundle, KindArchive:
return SeatArtifacts, kind, true
case KindPackage:
switch language {
case "go":
return SeatGoPackages, KindPackage, true
case "typescript", "javascript", "node":
return SeatNPM, KindPackage, true
}
}
return "", "", false
}
// NameOf is the name a module's image, bundle or archive is numbered under: `<module>/<artifact>`, the name the seat
// answers it by (ADR 0605 §9's `<registry>/<module>/<artifact>@…`). A package is numbered under its name in its own
// ecosystem instead (Numberer.Names).
func NameOf(module, artifact string) string { return module + "/" + artifact }
// PackageNames answers a package's name in its own ecosystem (design 32: a Go package's module path, an npm package's
// package.json name) for the module and artifact it is built from, as the indexer's packages state holds it; known is
// false when the state names none for it. An error is a state that cannot be read.
type PackageNames func(ctx context.Context, module, artifact string) (name string, known bool, err error)
// TagPrefix is the git tag prefix of a package in the module's directory path: `go/v` for mesh-sdk's `go`, `v` at the
// root (design 51: the builder tags each Go version `go/v0.1.<n>`).
func TagPrefix(path string) string {
path = strings.Trim(path, "/")
if path == "" || path == "." {
return "v"
}
return path + "/v"
}
// ClaimArtifact is one artifact of a trunk build to number.
type ClaimArtifact struct {
Artifact, Kind, Language string
}
// BuildClaim is a trunk build at its claim: what Numberer.Number numbers.
type BuildClaim struct {
Module, Path, Repository, Fingerprint, Commit, Series string
// Revision is the source revision of the merge (a package is numbered per revision); Merge its sequence, for order.
Revision string
Merge uint64
Artifacts []ClaimArtifact
// Tag numbers a check's build of a head a tag ask names (design 51 *Tagged versions*): its packages only, each a
// tagged version kept with Repository for its branch's end; Head is the head, said in numbered. Empty for a trunk build.
Tag, Head string
// Done are the digests of the build's done key, by artifact, when the claim is a look at a build already done: what it
// left unnumbered (a package whose name was not known yet, a floor that was silent) is numbered with them, so
// version.numbered carries the digest its build module publishes late. Nil for a build still to make.
Done map[string]string
}
// Numbering is what the claim got.
type Numbering struct {
// Versions are each artifact's version, by artifact; an artifact built unnumbered (its floor not known) has none.
Versions map[string]string
// Unnumbered are the artifacts built and published by digest, numbered once their floor answers (ADR 0617 §1).
Unnumbered []string
// Wait is why the build waits: a package whose floor is not known (design 51 *The floor*). Nothing is claimed.
Wait string
// Said are the builder's narrations of each decision, for its process log and its build log (build.said).
Said []Narration
// Names are the names each numbered artifact was numbered under, by artifact (NameOf, or a package's name in its
// ecosystem).
Names map[string]string
}
// NameOf is the name artifact of module was numbered under in this numbering.
func (g Numbering) NameOf(module, artifact string) string {
if name := g.Names[artifact]; name != "" {
return name
}
return NameOf(module, artifact)
}
// Narration is one line the builder says in its build log (design 51: the build log says a version was kept, and
// why), and what it narrates.
type Narration struct{ Line, Why string }
// What a narration of the build log narrates (the build.said entry's why).
const (
SaidNumbered = "numbered"
SaidKept = "kept"
SaidWaiting = "waiting"
SaidUnnumbered = "unnumbered"
SaidUnanswered = "tag ask unanswered"
SaidOldFormClosed = "old-form-closed"
)
// narrationOf is a decision's narration.
func narrationOf(name string, d Decision) Narration {
why := SaidNumbered
switch {
case d.Keep:
why = SaidKept
case d.Wait:
why = SaidWaiting
case d.Unnumbered:
why = SaidUnnumbered
}
return Narration{Line: name + ": " + d.Why, Why: why}
}
// Numberer numbers trunk builds at their claim (design 51 *The versions*, DLV-B2, B3): it asks a name's floor only
// when versions holds nothing for it, records a silent floor as a wait (FloorWaits, woken by its source's event), and
// says each new number (Said, the event version.numbered).
type Numberer struct {
Versions Versions
Floor FloorSources
Waits *FloorWaits
// Holder is the module holding a seat, whose health condition's clear wakes a silent seat source; nil names none.
Holder func(seat string) string
// Said is told each version newly numbered; nil tells nobody.
Said func(delivery.VersionNumbered)
// Names answers a package's name in its ecosystem; nil numbers a package as NameOf names it.
Names PackageNames
// Publishing is told each version Built recorded that is still numbered, its holder's word not heard yet: the
// builder arms its publish bound (publish.go); nil arms none.
Publishing func(ctx context.Context, e Entry)
Now func() time.Time
}
// Number numbers every numbered artifact of c. An error is a store that cannot be read or written: the claim is then
// built as before, unnumbered, and said.
func (n *Numberer) Number(ctx context.Context, c BuildClaim) (Numbering, error) {
out := Numbering{Versions: map[string]string{}, Names: map[string]string{}}
arts := append([]ClaimArtifact(nil), c.Artifacts...)
sort.Slice(arts, func(i, j int) bool { return arts[i].Artifact < arts[j].Artifact })
for _, a := range arts {
seat, kind, ok := SeatOf(a.Kind, a.Language)
if !ok || (c.Tag != "" && kind != KindPackage) {
continue
}
name := NameOf(c.Module, a.Artifact)
if kind == KindPackage && n.Names != nil {
// A package is numbered, asked for its floor and published under its name in its ecosystem (design 32), the
// name the holder's builder_versions question and the seat's versions name it by.
named, known, err := n.Names(ctx, c.Module, a.Artifact)
if err != nil || !known {
why := "the indexer's packages state names no name of it in its ecosystem yet (it writes one at the module's next trunk merge)"
if err != nil {
why = "the indexer's packages state cannot be read: " + err.Error()
}
out.Unnumbered = append(out.Unnumbered, a.Artifact)
out.Said = append(out.Said, Narration{Line: name + ": built unnumbered: " + why, Why: SaidUnnumbered})
continue
}
name = named
}
out.Names[a.Artifact] = name
entries, err := n.Versions.Of(ctx, name)
if err != nil {
return out, err
}
var floor Floor
var why map[string]string
if len(entries) == 0 {
floor, why = n.askFloor(ctx, seat, name, c)
}
d, e, err := n.Versions.Number(ctx, Claim{Name: name, Kind: kind, Series: c.Series, Module: c.Module, Fingerprint: c.Fingerprint,
Commit: c.Commit, Revision: c.Revision, Digest: c.Done[a.Artifact], Seat: seat, Merge: c.Merge, Floor: floor, Tag: c.Tag, Repository: c.Repository})
if err != nil {
return out, err
}
out.Said = append(out.Said, narrationOf(name, d))
switch {
case d.Wait:
n.wait(name, seat, floor, why)
out.Wait = name + ": " + d.Why
return out, nil
case d.Unnumbered:
n.wait(name, seat, floor, why)
out.Unnumbered = append(out.Unnumbered, a.Artifact)
continue
}
if n.Waits != nil {
n.Waits.Answered(name)
}
out.Versions[a.Artifact] = d.Version
if !d.Keep && n.Said != nil {
n.Said(NumberedOf(e, c.Head))
}
}
return out, nil
}
func (n *Numberer) askFloor(ctx context.Context, seat, name string, c BuildClaim) (Floor, map[string]string) {
return n.Floor.Ask(ctx, FloorOf{Seat: seat, Name: name, Repository: c.Repository, Prefix: TagPrefix(c.Path)})
}
func (n *Numberer) wait(name, seat string, f Floor, why map[string]string) {
if n.Waits == nil {
return
}
holder := ""
if n.Holder != nil {
holder = n.Holder(seat)
}
now := time.Now()
if n.Now != nil {
now = n.Now()
}
n.Waits.Wait(name, seat, holder, f, why, now)
}
// Built records the digest each numbered artifact was built with, on an entry that holds none yet or was never published: what the holder's
// builder_versions question compares (ADR 0605 §4).
func (n *Numberer) Built(ctx context.Context, c BuildClaim, got Numbering, digests map[string]string) error {
var errs []string
for artifact, version := range got.Versions {
d := digests[artifact]
if d == "" {
continue
}
e, err := n.Versions.Mark(ctx, got.NameOf(c.Module, artifact), version, func(e *Entry) {
// The new digest replaces the recorded one only while no published was said for the number (design 51
// *Tools of the builder*): a rebuild of a gone file under the same number.
if e.Digest == "" || e.State == Numbered || e.State == Refused {
e.Digest = d
}
})
if err != nil {
errs = append(errs, err.Error())
continue
}
if e.State == Numbered && n.Publishing != nil {
n.Publishing(ctx, e)
}
}
if len(errs) > 0 {
sort.Strings(errs)
return fmt.Errorf("the built digests could not be recorded: %s", strings.Join(errs, "; "))
}
return nil
}
@@ -0,0 +1,319 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
)
// FloorSources are the three sources of a name's floor (design 51 *The floor*): each a tool call that answers the
// versions it knows of a name, or an error when it does not answer.
type FloorSources struct {
// Seat is `seat:<registry>.versions` of the registry seat the name is published to.
Seat func(ctx context.Context, seat, name string) ([]string, error)
// Tags is the forge bridge's bridge_tags: the git tags of the repository the name is built from, under prefix.
Tags func(ctx context.Context, repository, prefix string) ([]string, error)
// Requirements is the indexer's indexer_requirements: the highest pseudo-versions consumers require.
Requirements func(ctx context.Context, name string) ([]string, error)
}
// FloorOf names what a floor is asked for: the name on its seat, and the repository and tag prefix of its git tags.
type FloorOf struct{ Seat, Name, Repository, Prefix string }
// Ask asks the three sources of a name; a source that errs, or is not wired, is named in Silent with its error in why.
func (s FloorSources) Ask(ctx context.Context, of FloorOf) (Floor, map[string]string) {
seat, name := of.Seat, of.Name
var f Floor
why := map[string]string{}
ask := func(source string, call func() ([]string, error), into *[]string) {
if call == nil {
f.Silent, why[source] = append(f.Silent, source), "not asked: the builder has no path to it"
return
}
got, err := call()
if err != nil {
f.Silent, why[source] = append(f.Silent, source), err.Error()
return
}
*into = got
}
var seatCall, tagsCall, reqCall func() ([]string, error)
if s.Seat != nil {
seatCall = func() ([]string, error) { return s.Seat(ctx, seat, name) }
}
if s.Tags != nil {
tagsCall = func() ([]string, error) { return s.Tags(ctx, of.Repository, of.Prefix) }
}
if s.Requirements != nil {
reqCall = func() ([]string, error) { return s.Requirements(ctx, name) }
}
ask(SourceSeat, seatCall, &f.Seat)
ask(SourceTags, tagsCall, &f.Tags)
ask(SourceRequirements, reqCall, &f.Requirements)
return f, why
}
// What wakes a name waiting on its floor, never a timer (design 51, DLV-B3b): the source that was silent saying
// something, or its module's health condition clearing.
const (
// WakePublished is the holder's `published` on the builder's durable consumer of `<seat>.events`.
WakePublished = "published"
// WakeForge is the forge bridge's next message on its stream `forge`.
WakeForge = "forge"
// WakeAccepted is the indexer's next `manifest-accepted`.
WakeAccepted = "manifest-accepted"
// WakeCleared is the condition keeper's clear of a silent module's health condition on `mesh-conditions`.
WakeCleared = "condition-cleared"
// WakeByHand is builder_floor_again, a hand act.
WakeByHand = "by hand"
)
// sourceOfWake is the floor source each event says something of; a clear names the source by its module.
var sourceOfWake = map[string]string{WakePublished: SourceSeat, WakeForge: SourceTags, WakeAccepted: SourceRequirements}
// ModuleOfSource is the module whose health condition's clear wakes a silent source.
var ModuleOfSource = map[string]string{
SourceTags: "mesh-delivery-forge-bridge",
SourceRequirements: "mesh-delivery-indexer",
}
// FloorWait is one name waiting on its floor: the sources still silent and why, since when; the finding the builder
// writes names them, cleared once all three answer.
type FloorWait struct {
Name string `json:"name"`
Seat string `json:"seat"`
Silent []string `json:"silent"`
Why map[string]string `json:"why,omitempty"`
Since time.Time `json:"since"`
// Holder is the module holding the name's seat: its condition's clear wakes the seat source.
Holder string `json:"holder,omitempty"`
}
// Finding is the floor finding's words.
func (w FloorWait) Finding() string {
var parts []string
for _, s := range w.Silent {
p := s
if why := w.Why[s]; why != "" {
p += " (" + why + ")"
}
parts = append(parts, p)
}
return fmt.Sprintf("the floor of %s is not known since %s: %s did not answer; nothing of it is numbered until all three answer",
w.Name, w.Since.UTC().Format(time.RFC3339), strings.Join(parts, ", "))
}
// The builder's state floor-waits (DLV-B4): each name waiting on its floor, keyed by its escaped name, so a restarted
// builder still knows which names its silent sources' events wake; builder_floor_waits reads it.
const (
FloorWaitsState = "floor-waits"
FloorWaitsBucket = "mesh-delivery-builder_floor-waits"
FloorWaitsTool = "builder_floor_waits"
)
// FloorWaitKey is a wait's key in floor-waits.
func FloorWaitKey(name string) string { return EscapeToken(name) }
// FloorKeeper keeps the waits on the bus: Put a wait's value, Delete its key.
type FloorKeeper interface {
Put(key string, value []byte) error
Delete(key string) error
}
// FloorWaits are the names waiting on their floor. Wake answers which names to ask again for an event, Answered drops
// a name once all three answered; the builder's finding for each is the wait's Finding. Once Kept, every change is
// written to the state floor-waits as well.
type FloorWaits struct {
mu sync.Mutex
waits map[string]FloorWait
keeper FloorKeeper
// Logf says a wait that could not be kept; nil says nothing.
Logf func(string, ...any)
// Said is told each wait recorded (raised) and each answered (not raised): the builder's finding floor-silent.
Said func(w FloorWait, raised bool)
}
// FloorSilent is the kind of the floor finding.
const FloorSilent = "floor-silent"
// Keep binds the state: the waits it holds that are not known here are taken in (a restarted builder's), every wait
// known here is written to it, and every change from now on is.
func (fw *FloorWaits) Keep(k FloorKeeper, held []FloorWait) {
fw.mu.Lock()
if fw.waits == nil {
fw.waits = map[string]FloorWait{}
}
for _, w := range held {
if _, had := fw.waits[w.Name]; !had && w.Name != "" {
fw.waits[w.Name] = w
}
}
fw.keeper = k
all := make([]FloorWait, 0, len(fw.waits))
for _, w := range fw.waits {
all = append(all, w)
}
fw.mu.Unlock()
for _, w := range all {
fw.put(k, w)
}
}
func (fw *FloorWaits) put(k FloorKeeper, w FloorWait) {
if k == nil {
return
}
data, err := json.Marshal(w)
if err == nil {
err = k.Put(FloorWaitKey(w.Name), data)
}
if err != nil && fw.Logf != nil {
fw.Logf("the floor wait of %s is not kept in %s (a restart forgets it until its next claim): %v", w.Name, FloorWaitsState, err)
}
}
// DecodeFloorWait reads one value of floor-waits.
func DecodeFloorWait(raw []byte) (FloorWait, error) {
var w FloorWait
if err := json.Unmarshal(raw, &w); err != nil {
return FloorWait{}, err
}
if w.Name == "" {
return FloorWait{}, errors.New("a floor wait names no name")
}
return w, nil
}
// Wait records that name's floor is not known.
func (fw *FloorWaits) Wait(name, seat, holder string, f Floor, why map[string]string, now time.Time) FloorWait {
fw.mu.Lock()
if fw.waits == nil {
fw.waits = map[string]FloorWait{}
}
w, had := fw.waits[name]
if !had {
w.Since = now
}
w.Name, w.Seat, w.Holder, w.Silent, w.Why = name, seat, holder, slices.Clone(f.Silent), why
fw.waits[name] = w
k := fw.keeper
fw.mu.Unlock()
fw.put(k, w)
if fw.Said != nil {
fw.Said(w, true)
}
return w
}
// Answered drops name: its floor is known. It says whether the name waited, so its finding is cleared.
func (fw *FloorWaits) Answered(name string) bool {
fw.mu.Lock()
w, had := fw.waits[name]
delete(fw.waits, name)
k := fw.keeper
fw.mu.Unlock()
if had && fw.Said != nil {
fw.Said(w, false)
}
if had && k != nil {
if err := k.Delete(FloorWaitKey(name)); err != nil && fw.Logf != nil {
fw.Logf("the floor wait of %s is not cleared from %s: %v", name, FloorWaitsState, err)
}
}
return had
}
// FloorWaitsAnswer is what builder_floor_waits answers: every name waiting on its floor, oldest first, with its finding.
type FloorWaitsAnswer struct {
Waits []FloorWaitRead `json:"waits"`
}
// FloorWaitRead is one wait as builder_floor_waits shows it.
type FloorWaitRead struct {
FloorWait
Finding string `json:"finding"`
}
// AnswerFloorWaits is builder_floor_waits, read from the state floor-waits.
func AnswerFloorWaits(r Reader) (FloorWaitsAnswer, error) {
keys, err := r.Keys()
if err != nil {
return FloorWaitsAnswer{}, err
}
out := FloorWaitsAnswer{Waits: []FloorWaitRead{}}
for _, k := range keys {
raw, ok, err := r.Get(k)
if err != nil {
return FloorWaitsAnswer{}, err
}
if !ok {
continue
}
w, err := DecodeFloorWait(raw)
if err != nil {
return FloorWaitsAnswer{}, fmt.Errorf("%s.%s: %w", FloorWaitsState, k, err)
}
out.Waits = append(out.Waits, FloorWaitRead{FloorWait: w, Finding: w.Finding()})
}
sort.Slice(out.Waits, func(i, j int) bool {
if !out.Waits[i].Since.Equal(out.Waits[j].Since) {
return out.Waits[i].Since.Before(out.Waits[j].Since)
}
return out.Waits[i].Name < out.Waits[j].Name
})
return out, nil
}
// FloorWaitsDescription is builder_floor_waits' description.
const FloorWaitsDescription = "The names waiting on their floor (the builder's state floor-waits, design 51 *The floor*), oldest first: " +
"each name, its registry seat, the sources still silent and why, since when, and the finding the builder says for it. " +
"Nothing of a waiting name is numbered until all three sources answer; builder_floor_again asks one again by hand. Read-only."
// Wake answers the names to ask their floor again on an event: kind one of the Wake constants; seat the seat a
// `published` came on; module the module whose condition cleared. A name is woken only when the event says something
// of a source it waits on; WakeByHand with a name wakes that name alone.
func (fw *FloorWaits) Wake(kind, seat, module, name string) []string {
fw.mu.Lock()
defer fw.mu.Unlock()
var out []string
for n, w := range fw.waits {
switch kind {
case WakeByHand:
if n == name {
out = append(out, n)
}
case WakeCleared:
for _, s := range w.Silent {
if ModuleOfSource[s] == module || (s == SourceSeat && w.Holder != "" && w.Holder == module) {
out = append(out, n)
break
}
}
default:
src := sourceOfWake[kind]
if slices.Contains(w.Silent, src) && (kind != WakePublished || seat == "" || seat == w.Seat) {
out = append(out, n)
}
}
}
sort.Strings(out)
return out
}
// Waiting is every wait, by name.
func (fw *FloorWaits) Waiting() []FloorWait {
fw.mu.Lock()
defer fw.mu.Unlock()
out := make([]FloorWait, 0, len(fw.waits))
for _, w := range fw.waits {
out = append(out, w)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
@@ -0,0 +1,95 @@
package versions
import (
"context"
"fmt"
)
// The registry seats' events the builder hears on its durable consumer of each `<seat>.events` (design 51 *How a
// publish ends*, DLV-B4).
const (
HeardPublished = "published"
HeardRefused = "refused"
HeardRemoved = "removed"
HeardKept = "kept"
)
// Outcome is the body of a registry seat's published, refused and removed (mesh-contracts parts/registry Outcome), the
// fields the builder reads.
type Outcome struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Digest string `json:"digest"`
Kind string `json:"kind"`
Holder string `json:"holder"`
Reason string `json:"reason,omitempty"`
// Tags are, on kept, the other tags still naming its digest.
Tags []string `json:"tags,omitempty"`
}
// RemoveOutcome is what a seat's remove answered: kept whole (another tag names its digest), with why and those tags; or
// asked, the seat saying removed on its events.
type RemoveOutcome struct {
Kept bool
Reason string
Tags []string
}
// keep marks a tagged version kept by its seat, so it is asked no more.
func (n *Numberer) keep(ctx context.Context, e Entry, reason string, tags []string) error {
if reason == "" {
reason = "shared-digest"
}
_, err := n.Versions.Mark(ctx, e.Name, e.Version, func(x *Entry) {
x.State, x.Reason, x.KeptWith = Kept, reason, append([]string(nil), tags...)
})
return err
}
// Heard takes one seat event in: a numbered version's entry turns published (with its digest when it held none),
// refused with the holder's reason, removed with its reason and the event's id, or kept (another tag names its digest)
// with the other tags; a by-digest publish (no version)
// marks nothing. It answers the names waiting on their floor that the event wakes: a `published` says the seat
// answers (design 51 *The floor*).
func (n *Numberer) Heard(ctx context.Context, seat, event, id string, o Outcome) ([]string, error) {
var wake []string
if event == HeardPublished && n.Waits != nil {
wake = n.Waits.Wake(WakePublished, seat, "", "")
}
if o.Version == "" {
return wake, nil
}
var change func(*Entry)
switch event {
case HeardPublished:
change = func(e *Entry) {
e.State, e.Reason = Published, ""
if e.Digest == "" {
e.Digest = o.Digest
}
}
case HeardRefused:
change = func(e *Entry) {
if e.State != Published {
e.State, e.Reason, e.RefusedDigest = Refused, o.Reason, o.Digest
}
}
case HeardRemoved:
change = func(e *Entry) { e.State, e.Reason, e.RemovedBy = Removed, o.Reason, id }
case HeardKept:
change = func(e *Entry) {
if e.State != Removed {
e.State, e.Reason, e.KeptWith = Kept, o.Reason, append([]string(nil), o.Tags...)
if e.Reason == "" {
e.Reason = "shared-digest"
}
}
}
default:
return wake, fmt.Errorf("%s says %q, which the builder does not hear", seat, event)
}
if _, err := n.Versions.Mark(ctx, o.Name, o.Version, change); err != nil {
return wake, fmt.Errorf("%s's %s of %s@%s: %w", seat, event, o.Name, o.Version, err)
}
return wake, nil
}
@@ -0,0 +1,66 @@
package versions
import (
"context"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// PackagesState is the indexer's packages state as the builder reads it (its manifest's reads grant): its keys, and one
// key's value (found false when it holds none).
type PackagesState interface {
Keys(ctx context.Context) ([]string, error)
Get(ctx context.Context, key string) (value []byte, found bool, err error)
}
// NamesIn are a package's names in its ecosystem as the indexer's packages state holds them (design 32): the name of the
// series key that names module and artifact as what it is built from. A series key is named `<name>.<series>`; a key
// with no dot is a mesh Go module's published packages, not a series. A value that does not decode is passed over.
func NamesIn(s PackagesState) PackageNames {
return func(ctx context.Context, module, artifact string) (string, bool, error) {
keys, err := s.Keys(ctx)
if err != nil {
return "", false, err
}
sort.Strings(keys)
built := NameOf(module, artifact)
for _, key := range keys {
if !delivery.IsSeriesPackageKey(key) {
continue
}
value, found, err := s.Get(ctx, key)
if err != nil {
return "", false, err
}
if !found {
continue
}
v, err := delivery.DecodeSeriesPackage(value) // the indexer's value of a series key (mesh-sdk's contract, J4)
if err != nil || v.Package != built {
continue
}
return v.Name, true, nil
}
return "", false, nil
}
}
// NamedModule is the module a value of the indexer's packages state names a package's name for (design 32), and that
// name: a series key whose value names a name and the <module>/<artifact> it is built from. The builder looks at that module again when
// the value is heard, so a done build that left the package unnumbered (its name not known at its claim) is numbered then.
func NamedModule(key string, value []byte) (module, name string, ok bool) {
if !delivery.IsSeriesPackageKey(key) {
return "", "", false
}
v, err := delivery.DecodeSeriesPackage(value)
if err != nil || v.Name == "" {
return "", "", false
}
module, artifact, cut := strings.Cut(v.Package, "/")
if !cut || module == "" || artifact == "" {
return "", "", false
}
return module, v.Name, true
}
@@ -0,0 +1,157 @@
package versions
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4,
// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the
// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away,
// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys
// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement
// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the placement, a hand drop and
// each build key written; never on a timer.
// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it.
const (
OldFormState = "old-form"
OldFormKey = "window"
)
// OldFormBucket is that state's bucket.
const OldFormBucket = delivery.BuilderModule + "_" + OldFormState
// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the
// window closes or the node no longer holds it.
const OldFormHeld = "old-form-held"
// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not
// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A
// node stating nothing reads the first forms (fs.Reads), sorted.
func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string {
newest := map[string]string{} // kind -> newest form
var kinds []string
for _, f := range fs {
for _, k := range f.Kinds {
if _, ok := newest[k]; !ok {
kinds = append(kinds, k)
}
newest[k] = f.Name
}
}
var out []string
for node, stated := range counted {
if dropped[node] {
continue
}
for _, k := range kinds {
if !fs.Reads(stated, newest[k]) {
out = append(out, node)
break
}
}
}
sort.Strings(out)
return out
}
// OldFormWindow is the window's record: whether it is open, who holds it and since when (delivery.WindowHeld, the part
// every window keeper shares), and the nodes dropped by hand; what builder_old_form answers and what `old-form-closed`
// carries when it closes.
type OldFormWindow struct {
delivery.WindowHeld
Dropped []Dropped `json:"dropped,omitempty"`
}
// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why.
type Dropped struct {
Node string `json:"node"`
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped
// by hand.
type OldFormClosedBody struct {
At time.Time `json:"at"`
LastHolding []string `json:"last_holding,omitempty"`
Dropped []Dropped `json:"dropped,omitempty"`
}
// FindingAfter is how long a node holds the window open before it is the builder's finding naming the node.
const FindingAfter = 7 * 24 * time.Hour
// Step moves the window by mesh-sdk's one window rule (delivery.StepWindow): named is every counted node the read named,
// those dropped by hand included, and holding those of them holding the window (Holding). A read naming no node (no
// placement read yet, an empty node_haves) judges nothing, so it never closes the window. It answers the new window,
// whether it closed in this step (so the builder says `old-form-closed` and its log line once), and the nodes holding it
// past FindingAfter (the finding, cleared for every other node).
func (w OldFormWindow) Step(named, holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) {
held, closed, overdue := delivery.StepWindow(w.WindowHeld, named, holding, now, FindingAfter)
return OldFormWindow{WindowHeld: held, Dropped: w.Dropped}, closed, overdue
}
// Drop drops a retired node from the count by hand; dropping it again changes nothing.
func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) {
node, why = strings.TrimSpace(node), strings.TrimSpace(why)
if node == "" || why == "" {
return w, errors.New("name the retired node (node) and say why it leaves the count (why)")
}
if w.DroppedSet()[node] {
return w, nil
}
w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()})
return w, nil
}
// DroppedSet is the dropped nodes, as Holding takes them.
func (w OldFormWindow) DroppedSet() map[string]bool {
out := map[string]bool{}
for _, d := range w.Dropped {
out[d.Node] = true
}
return out
}
// Closing is the event's body of a window that closed in a Step.
func (w OldFormWindow) Closing() OldFormClosedBody {
return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped}
}
// DecodeOldFormWindow reads the state's value; none is a closed window no node holds.
func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) {
var w OldFormWindow
if len(raw) == 0 {
return w, nil
}
if err := json.Unmarshal(raw, &w); err != nil {
return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err)
}
return w, nil
}
// OldFormDescription is builder_old_form's description.
const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " +
"the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " +
"is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " +
"hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " +
"leaves the count by itself only when the placement no longer names it. builder_engine_reads shows what each node's engine states."
// AnswerOldForm is builder_old_form's read: the window as the state holds it.
func AnswerOldForm(r Reader) (OldFormWindow, error) {
raw, ok, err := r.Get(OldFormKey)
if err != nil || !ok {
return OldFormWindow{}, err
}
return DecodeOldFormWindow(raw)
}
@@ -0,0 +1,107 @@
package versions
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// **A publish that ends badly is loud** (design 51 *Health and conditions*; review round 1): the builder raises the
// finding publish-refused when a holder says refused, and publish-unanswered when a version it built and numbered is
// neither published nor refused within PublishBound. Each is about the name, so a later published of any version of it
// clears both. A seat's outcome that does not read is the finding seat-outcome-unread about the seat, cleared by its
// next outcome that reads.
//
// The bound is a key of the builder's state bounds per version (PublishBoundKey), written with its own time to live
// when the build is done and the version is still numbered, deleted at the holder's word; the bus's marker of its end,
// heard on the watch, is what raises publish-unanswered while the version is still numbered. No timer.
const (
// BoundsState is the builder's state of its expiring keys, each with its own time to live: a publish's bound
// (PublishBoundKey) and a node's seven days holding the old form's window (OldFormBoundKey). Its end, the bus's
// marker heard on the watch, is what acts.
BoundsState = "bounds"
BoundsBucket = "mesh-delivery-builder_bounds"
// PublishBound is the build module's own wait for the holder's word (design 51 *How a publish ends*: at most
// 10 minutes), so a version unanswered past it is one the holder never answered.
PublishBound = 10 * time.Minute
PublishRefused = "publish-refused"
PublishUnanswered = "publish-unanswered"
SeatOutcomeUnread = "seat-outcome-unread"
)
// The prefixes of the keys of BoundsState.
const (
publishBoundPrefix = "publish."
oldFormBoundPrefix = "old-form."
)
// PublishBoundKey is the bound of name's version: `publish.<versions key>`.
func PublishBoundKey(name, version string) string { return publishBoundPrefix + Key(name, version) }
// OldFormBoundKey is the bound of node's seven days holding the old form's window: `old-form.<node>`.
func OldFormBoundKey(node string) string { return oldFormBoundPrefix + EscapeToken(node) }
// PublishBoundOf is the versions key a publish's bound names, and whether key is one; a key that is no publish's
// bound is an old form's (IsOldFormBound) or nothing the builder wrote.
func PublishBoundOf(key string) (string, bool) { return strings.CutPrefix(key, publishBoundPrefix) }
// IsOldFormBound says key is a node's bound of the old form's window: its end steps the window, which raises the
// finding for every node past FindingAfter.
func IsOldFormBound(key string) bool { return strings.HasPrefix(key, oldFormBoundPrefix) }
// BoundsTool is builder_bounds: every key of BoundsState, what it bounds and when it ends.
const BoundsTool = "builder_bounds"
// BoundsDescription is builder_bounds' description.
const BoundsDescription = "The builder's expiring keys, its state " + BoundsState + " (design 51 *Health and conditions*): each key with what it " +
"bounds (a version's publish, whose end unanswered is the finding " + PublishUnanswered + "; or a node's seven days holding the old " +
"form's window, whose end is the finding " + OldFormHeld + "), when it ends, and how long is left. The bus ends each key by its own time to live."
// Bound is a key's value in BoundsState: what it bounds, in plain words, and when it ends.
type Bound struct {
Bounds string `json:"bounds"`
Ends time.Time `json:"ends"`
}
// EncodeBound is a Bound's bytes.
func EncodeBound(what string, ends time.Time) []byte {
raw, _ := json.Marshal(Bound{Bounds: what, Ends: ends.UTC()})
return raw
}
// BoundRow is one row of builder_bounds.
type BoundRow struct {
Key string `json:"key"`
Bounds string `json:"bounds"`
Ends time.Time `json:"ends,omitzero"`
Left string `json:"left,omitempty"`
}
// AnswerBounds is builder_bounds: every key, sorted, with what it bounds and when it ends; a value that does not read
// says so in bounds.
func AnswerBounds(r Reader, now time.Time) ([]BoundRow, error) {
keys, err := r.Keys()
if err != nil {
return nil, fmt.Errorf("the state %s cannot be listed: %w", BoundsState, err)
}
sort.Strings(keys)
out := []BoundRow{}
for _, k := range keys {
raw, ok, err := r.Get(k)
if err != nil || !ok {
continue
}
var b Bound
row := BoundRow{Key: k}
if json.Unmarshal(raw, &b) != nil || b.Bounds == "" {
row.Bounds = "a value that does not read: " + string(raw)
} else {
row.Bounds, row.Ends, row.Left = b.Bounds, b.Ends, b.Ends.Sub(now).Round(time.Second).String()
}
out = append(out, row)
}
return out, nil
}
@@ -0,0 +1,230 @@
package versions
import (
"context"
"errors"
"fmt"
"time"
)
// AllFilter is the key filter of every version of every name: two tokens.
const AllFilter = "*.*"
// ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision.
var ErrConflict = errors.New("the key exists or changed")
// Store is the bucket: create and compare-and-set, as the builder's builds bucket (internal/build.Store).
type Store interface {
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
Create(ctx context.Context, key string, value []byte) (uint64, error)
Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)
// KeysOf are the keys under a filter (NameFilter, or AllFilter for every key).
KeysOf(ctx context.Context, filter string) ([]string, error)
}
// Versions is the builder's state.
type Versions struct {
Store Store
Now func() time.Time
}
// Of is every entry of name.
func (v Versions) Of(ctx context.Context, name string) ([]Entry, error) {
keys, err := v.Store.KeysOf(ctx, NameFilter(name))
if err != nil {
return nil, fmt.Errorf("the versions of %s cannot be listed: %w", name, err)
}
var out []Entry
for _, k := range keys {
raw, _, found, err := v.Store.Get(ctx, k)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
if !found {
continue
}
e, err := Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
}
out = append(out, e)
}
return InOrder(out), nil
}
// Claim is one numbering asked of the builder.
type Claim struct {
Name, Kind, Series, Module, Fingerprint, Commit, Revision, Digest, Seat string
// Repository is the module's repository, kept on a tagged version for its branch's end.
Repository string
// Tag asks a tagged version for this cleaned tag; empty for a trunk version.
Tag string
Merge uint64
Floor Floor
// Fresh numbers anew a fingerprint or revision already numbered: builder_publish_again after a published (design 51
// *Tools of the builder*).
Fresh bool
}
// Number numbers a claim under compare-and-set: two claims of one name never get one number, the second reading the
// first's key and taking the next (ADR 0606 §2). It answers the decision and, when it numbered or kept a version, its
// entry. Attempts are bounded by the claims the name can meet at once; a store that keeps conflicting is an error.
func (v Versions) Number(ctx context.Context, c Claim) (Decision, Entry, error) {
if c.Series == "" {
c.Series = DefaultSeries
}
for attempt := 0; attempt < 16; attempt++ {
entries, err := v.Of(ctx, c.Name)
if err != nil {
return Decision{}, Entry{}, err
}
var d Decision
if c.Tag != "" {
if !c.Floor.Known() && len(entries) == 0 {
return Decision{Wait: true, Why: "the tagged version waits for its floor"}, Entry{}, nil
}
ver := Tagged(entries, c.Series, c.Tag, c.Floor)
d = Decision{Version: ver, Publish: true, Why: "tagged " + ver}
for _, e := range entries {
// The same fingerprint claimed again for its tag (a check's build looked at again) keeps its number.
if e.Tag == CleanTag(c.Tag) && e.Fingerprint == c.Fingerprint && e.State != Removed {
d = Decision{Version: e.Version, Keep: true, Publish: true, Why: "tagged " + e.Version + ", kept for this fingerprint"}
break
}
}
} else {
d = Decide(fresh(entries, c), c.Kind, c.Series, c.Fingerprint, c.Revision, c.Floor)
}
if d.Version == "" {
return d, Entry{}, nil
}
if d.Keep {
for _, e := range entries {
if e.Version == d.Version {
return d, e, nil
}
}
}
e := Entry{Name: c.Name, Kind: c.Kind, Series: c.Series, Version: d.Version, Tag: c.Tag, Module: c.Module,
Fingerprint: c.Fingerprint, Commit: c.Commit, Revision: c.Revision, Digest: c.Digest, Seat: c.Seat,
State: Numbered, At: v.now(), Merge: c.Merge}
if c.Tag != "" {
e.Repository = c.Repository
}
raw, err := Encode(e)
if err != nil {
return Decision{}, Entry{}, err
}
_, err = v.Store.Create(ctx, Key(c.Name, d.Version), raw)
if errors.Is(err, ErrConflict) {
continue // another claim took this number: read again and take the next
}
if err != nil {
return Decision{}, Entry{}, fmt.Errorf("%s@%s: %w", c.Name, d.Version, err)
}
return d, e, nil
}
return Decision{}, Entry{}, fmt.Errorf("%s: the versions kept changing under 16 claims", c.Name)
}
// Mark moves one version to a new state by compare-and-set, as the holders' events say (published, refused, removed):
// change edits the entry read; a version the state does not hold is an error naming it.
func (v Versions) Mark(ctx context.Context, name, version string, change func(*Entry)) (Entry, error) {
key := Key(name, version)
for attempt := 0; attempt < 16; attempt++ {
raw, rev, found, err := v.Store.Get(ctx, key)
if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
if !found {
return Entry{}, fmt.Errorf("%s@%s is not numbered by the builder", name, version)
}
e, err := Decode(raw)
if err != nil {
return Entry{}, fmt.Errorf("%s holds what is not a version: %w", key, err)
}
change(&e)
e.At = v.now()
out, err := Encode(e)
if err != nil {
return Entry{}, err
}
if _, err := v.Store.Update(ctx, key, out, rev); errors.Is(err, ErrConflict) {
continue
} else if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
return e, nil
}
return Entry{}, fmt.Errorf("%s@%s kept changing under 16 marks", name, version)
}
// Recorded is the holder's question through builder_versions (ADR 0605 §4): whether name, version and digest are a
// version the builder numbered. It answers the entry when they are, and why not otherwise.
func (v Versions) Recorded(ctx context.Context, name, version, digest string) (Entry, bool, string, error) {
raw, _, found, err := v.Store.Get(ctx, Key(name, version))
if err != nil {
return Entry{}, false, "", err
}
if !found {
return Entry{}, false, fmt.Sprintf("%s@%s is not a version the builder numbered", name, version), nil
}
e, err := Decode(raw)
if err != nil {
return Entry{}, false, "", err
}
switch {
case e.State == Removed:
return e, false, fmt.Sprintf("%s@%s was removed (%s)", name, version, e.Reason), nil
case e.Digest != "" && digest != "" && e.Digest != digest:
return e, false, fmt.Sprintf("%s@%s is recorded with digest %s, not %s", name, version, e.Digest, digest), nil
}
return e, true, "", nil
}
func (v Versions) now() time.Time {
if v.Now != nil {
return v.Now().UTC()
}
return time.Now().UTC()
}
// fresh is entries as Decide reads them for c: with Fresh, the entries of c's fingerprint and revision still count for the
// next number but keep none.
func fresh(entries []Entry, c Claim) []Entry {
if !c.Fresh {
return entries
}
out := make([]Entry, len(entries))
for i, e := range entries {
if e.Tag == "" && (e.Fingerprint == c.Fingerprint || (c.Revision != "" && e.Revision == c.Revision)) {
e.Fingerprint, e.Revision = "", ""
}
out[i] = e
}
return out
}
// All is every entry of every name, by name in order: what builder_untag and the tag asks read.
func (v Versions) All(ctx context.Context) ([]Entry, error) {
keys, err := v.Store.KeysOf(ctx, AllFilter)
if err != nil {
return nil, fmt.Errorf("the versions cannot be listed: %w", err)
}
var out []Entry
for _, k := range keys {
raw, _, found, err := v.Store.Get(ctx, k)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
if !found {
continue
}
e, err := Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
}
out = append(out, e)
}
return out, nil
}
@@ -0,0 +1,75 @@
package versions
import (
"fmt"
"maps"
"slices"
"strings"
"time"
)
// The bound of an unanswered tag ask (design 51 *Tagged versions*: "an ask the builder has not answered within five
// minutes is narrated in the build log with what it waits on"): a key of the state tag-ask-bounds, written with its own
// time to live, whose expiry the builder hears on its watch of the state. No timer: the bus ends the bound.
const (
TagAskBoundsState = "tag-ask-bounds"
TagAskBoundsBucket = "mesh-delivery-builder_tag-ask-bounds"
TagAskBound = 5 * time.Minute
)
// Unanswered says whether a tag ask still waits for its versions: kept, not an untag, none numbered yet.
func (t TagAsk) Unanswered() bool {
return !t.Untag && t.State == TagAsked && len(t.Versions) == 0
}
// WithNumbered is the tag ask once a check's build of its head numbered module's packages under its tag: numbered, each
// version by the name it was numbered under (names, by artifact; NameOf where it names none), the answer naming them. An
// untag, or an ask of another head, is left as it is.
func (t TagAsk) WithNumbered(module, head string, got, names map[string]string, at time.Time) (TagAsk, bool) {
if t.Untag || t.Head != head || len(got) == 0 || t.State == TagRemoving || t.State == TagRemoved {
return t, false
}
if t.Versions == nil {
t.Versions = map[string]string{}
}
changed := false
for artifact, v := range got {
name := Numbering{Names: names}.NameOf(module, artifact)
if t.Versions[name] != v {
t.Versions[name], changed = v, true
}
}
if !changed && t.State == TagNumbered {
return t, false
}
var said []string
for _, name := range slices.Sorted(maps.Keys(t.Versions)) {
said = append(said, name+" "+t.Versions[name])
}
t.State, t.Answered = TagNumbered, at.UTC()
t.Answer = "numbered: " + strings.Join(said, ", ")
return t, true
}
// NarrateUnanswered is the line said for a tag ask its bound found unanswered: since when, and what it waits on, the
// names waiting on their floor named with their silent sources.
func NarrateUnanswered(t TagAsk, waits []FloorWait, now time.Time) string {
on := "the check's build of " + short(t.Head) + " is not claimed yet (the checker builds a group member's head once it asks)"
var floors []string
for _, w := range waits {
floors = append(floors, w.Name+" waits on its floor ("+strings.Join(w.Silent, ", ")+" silent)")
}
if len(floors) > 0 {
slices.Sort(floors)
on += "; " + strings.Join(floors, "; ")
}
return fmt.Sprintf("the tag ask of %s %s at %s (%s) is not answered after %s: it waits on %s",
t.Repository, t.Branch, short(t.Head), t.Tag, now.Sub(t.At).Round(time.Second), on)
}
func short(head string) string {
if len(head) > 12 {
return head[:12]
}
return head
}
@@ -0,0 +1,111 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The builder's tools of its versions (design 51 *Events of the builder*, *Tools of the builder*).
const (
VersionsTool = "builder_versions"
PublishAgainTool = "builder_publish_again"
FloorAgainTool = "builder_floor_again"
TagTool = "builder_tag"
TagAsksTool = "builder_tag_asks"
UntagTool = "builder_untag"
OldFormTool = "builder_old_form"
// OldFormClosedEvent is, with delivery.VersionNumberedEvent, the builder's own family `version`, kept seven days,
// apart from its builds' ends, so a waiter on a build's end does not wake on them.
OldFormClosedEvent = "version.old-form-closed"
)
// NumberedOf is the version.numbered body of an entry (delivery.VersionNumbered, the contract a build module reads).
func NumberedOf(e Entry, head string) delivery.VersionNumbered {
return delivery.VersionNumbered{Name: e.Name, Kind: e.Kind, Version: e.Version, Tag: e.Tag, Module: e.Module, Fingerprint: e.Fingerprint,
Commit: e.Commit, Digest: e.Digest, Seat: e.Seat, Head: head}
}
// Reader reads the state as the tools bundle may: every key, and one.
type Reader struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, bool, error)
}
// readStore is a Store over a Reader that refuses every write: the tools bundle only reads versions.
type readStore struct{ r Reader }
var errReadOnly = errors.New("the versions are written by the builder alone")
func (s readStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
v, ok, err := s.r.Get(key)
return v, 0, ok, err
}
func (readStore) Create(context.Context, string, []byte) (uint64, error) { return 0, errReadOnly }
func (readStore) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, errReadOnly
}
func (s readStore) KeysOf(_ context.Context, filter string) ([]string, error) {
all, err := s.r.Keys()
if err != nil {
return nil, err
}
if filter == AllFilter {
return all, nil
}
prefix := strings.TrimSuffix(filter, "*")
var out []string
for _, k := range all {
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
// VersionsAnswer is what builder_versions answers.
type VersionsAnswer struct {
Name string `json:"name"`
Versions []Entry `json:"versions"`
// Asked, Recorded and Why answer a holder's question for one version and digest (ADR 0605 §4).
Asked string `json:"asked,omitempty"`
Recorded *bool `json:"recorded,omitempty"`
Why string `json:"why,omitempty"`
}
// AnswerVersions is builder_versions: a name's versions in order, in one call; with a version (and a digest), whether
// the builder numbered it with that digest, which the holder asks before it stores (ADR 0605 §4).
func AnswerVersions(ctx context.Context, r Reader, name, version, digest string) (VersionsAnswer, error) {
name = strings.TrimSpace(name)
if name == "" {
return VersionsAnswer{}, errors.New("name is required: the package, image, bundle or archive name")
}
v := Versions{Store: readStore{r}}
all, err := v.Of(ctx, name)
if err != nil {
return VersionsAnswer{}, err
}
out := VersionsAnswer{Name: name, Versions: all}
if out.Versions == nil {
out.Versions = []Entry{}
}
if version = strings.TrimSpace(version); version != "" {
_, ok, why, err := v.Recorded(ctx, name, version, strings.TrimSpace(digest))
if err != nil {
return VersionsAnswer{}, fmt.Errorf("%s@%s: %w", name, version, err)
}
out.Asked, out.Recorded, out.Why = version, &ok, why
}
return out, nil
}
// VersionsDescription is builder_versions' description.
const VersionsDescription = "A name's versions as the builder numbered them (its state versions, design 51): every trunk and tagged " +
"version of a package, image, bundle or archive in order, each with its kind, series, fingerprint, commit, source revision, digest, " +
"seat, state (numbered, published, refused with its reason, removed with its time, reason and the event that removed it, or kept: its seat kept it whole on remove, its digest shared, with the reason and the other tags naming it, kept_with) and when. " +
"With version, and digest, it also answers recorded: whether the builder numbered that version with that digest, and why not: the " +
"question a registry seat's holder asks before it stores a publish (ADR 0605 §4). Read-only."
@@ -0,0 +1,396 @@
// Package versions is the builder's state `versions` (novox/hq design 51 *The versions*, ADR 0606): one key per name,
// tag and version, written only by the builder under compare-and-set, read by others only through the builder's tool
// builder_versions or its events (ADR 0596 §2). It holds what each version is (its fingerprint, commit, source revision
// and digest) and where it stands: numbered, published, refused with its reason, or removed with its time, reason and the
// event that removed it.
//
// The state is declared in the builder's old `state` section (ADR 0619: the builder is one of the foundation's nine
// parts), history one; its cap is 64 MiB, the most the template state allows (ADR 0595 §3), and past 80 % of it the
// builder writes a finding, cleared below it (Fill).
package versions
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// State is the builder state's name; Bucket the bucket the bus keeps it in.
const (
State = "versions"
Bucket = "mesh-delivery-builder_versions"
// Cap is the bucket's cap in bytes (64 MiB); FullPercent the share of it past which the builder writes its finding.
Cap = 64 << 20
FullPercent = 80
// DefaultSeries is the series of a name whose manifest declares none (design 32).
DefaultSeries = "0.1"
)
// The states a version is in.
const (
Numbered = "numbered"
Published = "published"
Refused = "refused"
Removed = "removed"
// Kept is a tagged version its seat kept whole on remove: another tag names its digest, so the store cannot remove the
// tag alone (review round 2; the registry seats' `kept`). Its Reason is the seat's (shared-digest), KeptWith the
// other tags; it is asked no more.
Kept = "kept"
)
// The kinds the builder numbers: a package per source revision, the rest per fingerprint (design 51).
const (
KindPackage = "package"
KindImage = "image"
KindBundle = "bundle"
KindArchive = "archive"
)
// Entry is one key of the state: a version of a name, tagged or not.
type Entry struct {
Name string `json:"name"`
Kind string `json:"kind"`
Series string `json:"series"`
Version string `json:"version"`
// Tag is the cleaned tag of a tagged version (`feat-x`); empty for a trunk version.
Tag string `json:"tag,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
// Revision is the source revision a package's version is numbered for (one version per revision, design 51).
Revision string `json:"revision,omitempty"`
// Digest is the digest the build recorded (an image's manifest, a blob's, Go's h1: hash).
Digest string `json:"digest,omitempty"`
// Seat is the registry seat it is published to.
Seat string `json:"seat,omitempty"`
State string `json:"state"`
At time.Time `json:"at"`
// Reason is why it was refused or removed: the holder's reason, a branch's end, retention, the sweep.
Reason string `json:"reason,omitempty"`
// Repository is a tagged version's repository: its branch's end removes it (Untagged).
Repository string `json:"repository,omitempty"`
// KeptWith are the other tags naming its digest, when its seat kept it (Kept).
KeptWith []string `json:"kept_with,omitempty"`
// RemovedBy is the id of the event that removed it.
RemovedBy string `json:"removed_by,omitempty"`
// RefusedDigest is the digest the holder refused: one other than Digest means the seat was offered other bytes under
// this number, which builder_publish_again refuses to publish again (design 51 *Tools of the builder*).
RefusedDigest string `json:"refused_digest,omitempty"`
// Merge is the forge bridge's sequence of the merge that numbered it, for merge order.
Merge uint64 `json:"merge,omitempty"`
}
// Valid says what is wrong with an entry, or nil.
func (e Entry) Valid() error {
switch {
case strings.TrimSpace(e.Name) == "":
return errors.New("a version names no name")
case e.Version == "":
return fmt.Errorf("%s: a version with no version", e.Name)
}
switch e.State {
case Numbered, Published, Refused, Removed, Kept:
default:
return fmt.Errorf("%s@%s: unknown state %q", e.Name, e.Version, e.State)
}
switch e.Kind {
case KindPackage, KindImage, KindBundle, KindArchive:
default:
return fmt.Errorf("%s@%s: unknown kind %q", e.Name, e.Version, e.Kind)
}
if _, _, err := SplitSeries(e.Series); err != nil {
return fmt.Errorf("%s@%s: %w", e.Name, e.Version, err)
}
return nil
}
// Encode and Decode are the value's bytes.
func Encode(e Entry) ([]byte, error) {
if err := e.Valid(); err != nil {
return nil, err
}
return json.Marshal(e)
}
func Decode(raw []byte) (Entry, error) {
var e Entry
if err := json.Unmarshal(raw, &e); err != nil {
return Entry{}, err
}
return e, e.Valid()
}
// Key is the state's key of a name's version: two tokens, the name and the version each escaped (EscapeToken), so a
// name's versions are the keys under NameFilter and no name's keys fall under another's.
func Key(name, version string) string { return EscapeToken(name) + "." + EscapeToken(version) }
// NameFilter is the key filter of every version of name.
func NameFilter(name string) string { return EscapeToken(name) + ".*" }
// EscapeToken makes s one token of a bus key: letters, digits, '-', '_' and '/' stay; '=' and every other byte become
// '=' and two hex digits ('.' is "=2E", '@' "=40"). Two different strings never escape alike.
func EscapeToken(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '/':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "=%02X", c)
}
}
return b.String()
}
// SplitSeries reads a series `<major>.<minor>`.
func SplitSeries(series string) (major, minor int, err error) {
a, b, ok := strings.Cut(series, ".")
if !ok {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if major, err = strconv.Atoi(a); err != nil || major < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if minor, err = strconv.Atoi(b); err != nil || minor < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
return major, minor, nil
}
// Patch is the count of a trunk version of series (`0.1.14` → 14), read leniently: a leading 'v' and a path prefix
// (`go/v0.1.13`) are dropped; ok is false for a version of another series or a tagged one.
func Patch(series, version string) (int, bool) {
if i := strings.LastIndex(version, "/"); i >= 0 {
version = version[i+1:]
}
version = strings.TrimPrefix(version, "v")
if strings.ContainsAny(version, "-+") {
return 0, false
}
rest, ok := strings.CutPrefix(version, series+".")
if !ok {
return 0, false
}
n, err := strconv.Atoi(rest)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Floor is what the three sources answered for a name (design 51 *The floor*, ADR 0606 §3): the seat's versions, the
// package's git tags (the forge bridge's bridge_tags) and the pseudo-versions consumers require (the indexer's
// indexer_requirements). A source not answered is listed in Silent.
type Floor struct {
Seat []string `json:"seat,omitempty"`
Tags []string `json:"tags,omitempty"`
Requirements []string `json:"requirements,omitempty"`
// Silent names the sources that did not answer: "seat", "bridge_tags", "indexer_requirements".
Silent []string `json:"silent,omitempty"`
}
// The floor's sources, as Floor.Silent names them.
const (
SourceSeat = "seat"
SourceTags = "bridge_tags"
SourceRequirements = "indexer_requirements"
)
// Known is whether every source answered.
func (f Floor) Known() bool { return len(f.Silent) == 0 }
// Highest is the highest count of series the floor's three sources name, -1 when none names one. A pseudo-version
// (`v0.1.14-0.2026…-abc`) counts as the version below it was cut from, so its base (`0.1.13`) counts: Go orders it
// before `0.1.14`.
func (f Floor) Highest(series string) int {
high := -1
for _, list := range [][]string{f.Seat, f.Tags, f.Requirements} {
for _, v := range list {
n, ok := Patch(series, v)
if !ok {
n, ok = pseudoBase(series, v)
}
if ok && n > high {
high = n
}
}
}
return high
}
// pseudoBase is the count a Go pseudo-version stands on: `v0.1.14-0.<time>-<hash>` is cut after `v0.1.13`.
func pseudoBase(series, version string) (int, bool) {
version = strings.TrimPrefix(version, "v")
base, rest, ok := strings.Cut(version, "-")
if !ok || !strings.HasPrefix(rest, "0.") || strings.Count(rest, "-") != 1 {
return 0, false
}
n, ok := Patch(series, base)
if !ok || n == 0 {
return 0, false
}
return n - 1, true
}
// Next is the next trunk version of name in series: one above the highest of the trunk versions entries hold and the
// floor (design 51: a name with none starts at `<series>.0`).
func Next(entries []Entry, series string, floor Floor) string {
high := floor.Highest(series)
for _, e := range entries {
if e.Tag != "" || e.Series != series {
continue
}
if n, ok := Patch(series, e.Version); ok && n > high {
high = n
}
}
return fmt.Sprintf("%s.%d", series, high+1)
}
// Decision is what the builder does with a trunk build at its claim.
type Decision struct {
// Version is the version the build is published under; empty when it is built unnumbered (Unnumbered) or waits (Wait).
Version string
// Keep is true when the version is one entries already hold for this fingerprint or source revision.
Keep bool
// Publish is false only for a package's revision held published: it publishes nothing (design 51).
Publish bool
// Unnumbered: an image, bundle or archive whose floor is not known is built and published by its digest, numbered
// once the floor answers (ADR 0617 §1).
Unnumbered bool
// Wait: a package whose floor is not known waits for it (a package is published only under its version).
Wait bool
// Why is said in the build log.
Why string
}
// Decide numbers a trunk build of kind for name in series at fingerprint and source revision, from what entries hold of
// the name and what the floor answered (design 51 *The versions*, ADR 0606, ADR 0617 §1):
// - a package is numbered per source revision: a revision held published keeps its version and publishes nothing; a
// revision numbered or refused but never stored is published again under the same version;
// - an image, bundle or archive is numbered per fingerprint: a fingerprint already numbered keeps its number;
// - otherwise the next version, when the floor is known; else an image, bundle or archive is built unnumbered and a
// package waits.
func Decide(entries []Entry, kind, series, fingerprint, revision string, floor Floor) Decision {
for _, e := range latestFirst(entries) {
if e.Tag != "" || e.State == Removed {
continue
}
switch {
case kind == KindPackage && revision != "" && e.Revision == revision:
if e.State == Published {
return Decision{Version: e.Version, Keep: true, Why: fmt.Sprintf("version %s kept: source revision %s is published under it, and a package's content is its source", e.Version, revision)}
}
return Decision{Version: e.Version, Keep: true, Publish: true, Why: fmt.Sprintf("version %s kept: source revision %s was numbered %s but never stored, so it is published under it", e.Version, revision, e.State)}
case kind != KindPackage && e.Fingerprint == fingerprint:
return Decision{Version: e.Version, Keep: true, Publish: e.State != Published, Why: fmt.Sprintf("version %s kept: fingerprint %s is numbered under it", e.Version, fingerprint)}
}
}
if !floor.Known() {
silent := strings.Join(floor.Silent, ", ")
if kind == KindPackage {
return Decision{Wait: true, Why: "the package waits for its floor: " + silent + " did not answer"}
}
return Decision{Unnumbered: true, Publish: true, Why: "built unnumbered and published by its digest: the floor is not known (" + silent + " did not answer); numbered once it answers"}
}
v := Next(entries, series, floor)
return Decision{Version: v, Publish: true, Why: "numbered " + v}
}
// CleanTag makes a branch or tag name a semver prerelease identifier: lower case, every run of characters other than
// letters and digits one '-', none at either end; "tag" when nothing is left.
func CleanTag(tag string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(tag) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
out := strings.TrimSuffix(b.String(), "-")
if out == "" {
return "tag"
}
return out
}
// Tagged is the next tagged version of name for tag: below the next trunk version, never the latest
// (`0.1.15-feat-x.2`, design 51 *Tagged versions*); a package new in a group is numbered from its floor
// (`0.1.0-feat-x.1`).
func Tagged(entries []Entry, series, tag string, floor Floor) string {
base := Next(entries, series, floor)
tag = CleanTag(tag)
n := 0
prefix := base + "-" + tag + "."
for _, e := range entries {
if rest, ok := strings.CutPrefix(e.Version, prefix); ok {
if k, err := strconv.Atoi(rest); err == nil && k > n {
n = k
}
}
}
return fmt.Sprintf("%s%d", prefix, n+1)
}
// Fill is whether a bucket holding bytes of cap is past its finding's mark.
func Fill(bytes, capacity uint64) (share float64, full bool) {
if capacity == 0 {
return 0, false
}
share = float64(bytes) / float64(capacity)
return share, bytes*100 >= capacity*FullPercent
}
// InOrder sorts a name's entries by version, trunk versions by count and tagged ones after the trunk version they stand
// below: the order builder_versions answers.
func InOrder(entries []Entry) []Entry {
out := append([]Entry(nil), entries...)
sort.SliceStable(out, func(i, j int) bool {
ki, kj := orderKey(out[i]), orderKey(out[j])
if ki.n != kj.n {
return ki.n < kj.n
}
if ki.tagged != kj.tagged {
return ki.tagged // a tagged version stands below the trunk version of its count
}
return out[i].Version < out[j].Version
})
return out
}
type order struct {
n int
tagged bool
}
func orderKey(e Entry) order {
v := e.Version
tagged := false
if i := strings.IndexByte(v, '-'); i >= 0 {
v, tagged = v[:i], true
}
n, ok := Patch(e.Series, v)
if !ok {
n = -1
}
return order{n: n, tagged: tagged}
}
func latestFirst(entries []Entry) []Entry {
out := InOrder(entries)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
@@ -0,0 +1,813 @@
package versions
import (
"context"
"encoding/json"
"errors"
"maps"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// mem is a Store in memory; before runs once before the next Create, to stage a race.
type mem struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
rev uint64
before func()
}
func newMem() *mem { return &mem{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (m *mem) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[key]
return v, m.revs[key], ok, nil
}
func (m *mem) Create(ctx context.Context, key string, value []byte) (uint64, error) {
if f := m.before; f != nil {
m.before = nil
f()
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.vals[key]; ok {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) Update(_ context.Context, key string, value []byte, revision uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.revs[key] != revision {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) KeysOf(_ context.Context, filter string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
prefix := strings.TrimSuffix(filter, "*")
var out []string
for k := range m.vals {
if filter == AllFilter {
out = append(out, k)
continue
}
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
// keeperMem is the state floor-waits in memory.
type keeperMem map[string][]byte
func (k keeperMem) Put(key string, v []byte) error { k[key] = v; return nil }
func (k keeperMem) Delete(key string) error { delete(k, key); return nil }
func (k keeperMem) keys() ([]string, error) {
var out []string
for key := range k {
out = append(out, key)
}
return out, nil
}
func (k keeperMem) get(key string) (json.RawMessage, bool, error) { v, ok := k[key]; return v, ok, nil }
// packagesMem is the indexer's packages state as the builder reads it, in memory.
type packagesMem map[string][]byte
func (p packagesMem) Keys(context.Context) ([]string, error) {
if p == nil {
return nil, errors.New("not readable")
}
return slices.Collect(maps.Keys(p)), nil
}
func (p packagesMem) Get(_ context.Context, key string) ([]byte, bool, error) {
v, ok := p[key]
return v, ok, nil
}
func (p packagesMem) failing() packagesMem { return nil }
func known(seat, tags, reqs []string) Floor { return Floor{Seat: seat, Tags: tags, Requirements: reqs} }
// TestTheBuildersVersions holds design 51 *The versions*, *The floor* and *Tagged versions* (DLV-B1, DLV-B2, DLV-B3):
// one table per rule, the unit cases of the builder's numbering.
func TestTheBuildersVersions(t *testing.T) {
t.Run("keys", func(t *testing.T) {
names := []string{"@novox/mesh-sdk", "git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-sdk", "a=40b", "a@b"}
seen := map[string]string{}
for _, n := range names {
k := Key(n, "0.1.14")
if strings.Count(k, ".") != 1 {
t.Errorf("Key(%q) = %q, want two tokens", n, k)
}
if o, dup := seen[k]; dup {
t.Errorf("%q and %q escape alike: %q", n, o, k)
}
seen[k] = n
if !strings.HasPrefix(k, strings.TrimSuffix(NameFilter(n), "*")) {
t.Errorf("Key(%q) = %q is not under NameFilter %q", n, k, NameFilter(n))
}
}
})
t.Run("floor", func(t *testing.T) {
for _, c := range []struct {
name string
floor Floor
want string
}{
{"none starts at 0", known(nil, nil, nil), "0.1.0"},
{"the Go tag of today gives v0.1.14", known(nil, []string{"go/v0.1.13"}, []string{"v0.1.14-0.20261103221822-85ba0a9a70d8"}), "0.1.14"},
{"npm's 0.1.17 on the seat gives 0.1.18", known([]string{"0.1.17", "0.1.3"}, nil, nil), "0.1.18"},
{"another series and a tagged version count nothing", known([]string{"0.2.40", "0.1.9-feat-x.1"}, nil, nil), "0.1.0"},
{"the highest of the three", known([]string{"0.1.4"}, []string{"v0.1.9"}, []string{"v0.1.7"}), "0.1.10"},
} {
if got := Next(nil, "0.1", c.floor); got != c.want {
t.Errorf("%s: Next = %s, want %s", c.name, got, c.want)
}
}
if got := Next([]Entry{{Version: "0.1.20", Series: "0.1"}, {Version: "0.1.21-x.1", Series: "0.1", Tag: "x"}}, "0.1", known([]string{"0.1.3"}, nil, nil)); got != "0.1.21" {
t.Errorf("Next above the state's own = %s, want 0.1.21", got)
}
})
t.Run("decide", func(t *testing.T) {
pub := Entry{Name: "n", Kind: KindPackage, Series: "0.1", Version: "0.1.5", Revision: "r1", Fingerprint: "fA", State: Published}
ref := pub
ref.State, ref.Version, ref.Revision = Refused, "0.1.6", "r2"
img := Entry{Name: "i", Kind: KindBundle, Series: "0.1", Version: "0.1.2", Fingerprint: "fB", State: Published}
silent := Floor{Silent: []string{SourceSeat}}
for _, c := range []struct {
name string
entries []Entry
kind, fp, rev string
floor Floor
version string
keep, publish, unnum bool
wait bool
}{
{"a package's revision held published keeps its version and publishes nothing", []Entry{pub}, KindPackage, "fX", "r1", silent, "0.1.5", true, false, false, false},
{"a revision numbered but refused is published by its second build under the same version", []Entry{pub, ref}, KindPackage, "fY", "r2", known(nil, nil, nil), "0.1.6", true, true, false, false},
{"a new revision takes the next", []Entry{pub, ref}, KindPackage, "fZ", "r3", known(nil, nil, nil), "0.1.7", false, true, false, false},
{"a bundle's fingerprint keeps its number", []Entry{img}, KindBundle, "fB", "", silent, "0.1.2", true, false, false, false},
{"an unknown floor builds a bundle unnumbered", nil, KindBundle, "fC", "", silent, "", false, true, true, false},
{"an unknown floor builds an image unnumbered", nil, KindImage, "fC", "", silent, "", false, true, true, false},
{"an unknown floor makes a package wait", nil, KindPackage, "fC", "r9", silent, "", false, false, false, true},
} {
d := Decide(c.entries, c.kind, "0.1", c.fp, c.rev, c.floor)
if d.Version != c.version || d.Keep != c.keep || d.Publish != c.publish || d.Unnumbered != c.unnum || d.Wait != c.wait || d.Why == "" {
t.Errorf("%s: Decide = %+v", c.name, d)
}
}
})
t.Run("two claims never one number", func(t *testing.T) {
st := newMem()
v := Versions{Store: st, Now: func() time.Time { return time.Unix(0, 0) }}
ctx := context.Background()
st.before = func() { // a second claim takes 0.1.0 between the first's read and its create
if _, _, err := (Versions{Store: st}).Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f2"}); err != nil {
t.Error(err)
}
}
d, e, err := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"})
if err != nil || d.Version != "0.1.1" || e.State != Numbered || e.Series != DefaultSeries {
t.Fatalf("Number = %+v, %+v, %v, want 0.1.1 after the other claim's 0.1.0", d, e, err)
}
if d, _, _ := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"}); !d.Keep || d.Version != "0.1.1" {
t.Errorf("a numbered fingerprint claimed again = %+v, want it kept", d)
}
got, err := v.Mark(ctx, "n", "0.1.1", func(e *Entry) { e.State = Published })
if err != nil || got.State != Published {
t.Errorf("Mark = %+v, %v", got, err)
}
if _, err := v.Mark(ctx, "n", "0.9.9", func(*Entry) {}); err == nil {
t.Error("marking a version never numbered is no error")
}
})
t.Run("recorded", func(t *testing.T) {
st := newMem()
v := Versions{Store: st}
ctx := context.Background()
if _, _, err := v.Number(ctx, Claim{Name: "n", Kind: KindImage, Fingerprint: "f", Digest: "sha256:aa", Floor: known(nil, nil, nil)}); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
version, digest string
ok bool
}{{"0.1.0", "sha256:aa", true}, {"0.1.0", "sha256:bb", false}, {"0.1.7", "sha256:aa", false}} {
if _, ok, why, err := v.Recorded(ctx, "n", c.version, c.digest); err != nil || ok != c.ok || (!ok && why == "") {
t.Errorf("Recorded(%s, %s) = %v, %q, %v; want %v", c.version, c.digest, ok, why, err, c.ok)
}
}
// builder_versions answers the same through the tools bundle's read-only view.
r := Reader{Keys: func() ([]string, error) {
var all []string
for k := range st.vals {
all = append(all, k)
}
return all, nil
}, Get: func(k string) (json.RawMessage, bool, error) {
v, _, ok, err := st.Get(ctx, k)
return v, ok, err
}}
a, err := AnswerVersions(ctx, r, "n", "0.1.0", "sha256:bb")
if err != nil || len(a.Versions) != 1 || a.Recorded == nil || *a.Recorded || a.Why == "" {
t.Errorf("builder_versions = %+v, %v; want one version and not recorded for another digest", a, err)
}
if _, err := AnswerVersions(ctx, r, " ", "", ""); err == nil {
t.Error("builder_versions with no name answered")
}
})
t.Run("tagged", func(t *testing.T) {
es := []Entry{{Version: "0.1.14", Series: "0.1"}, {Version: "0.1.15-feat-x.1", Series: "0.1", Tag: "feat-x"}}
if got := Tagged(es, "0.1", "feat/X", known(nil, nil, nil)); got != "0.1.15-feat-x.2" {
t.Errorf("Tagged = %s, want 0.1.15-feat-x.2", got)
}
if got := Tagged(nil, "0.1", "feat-x", known(nil, nil, nil)); got != "0.1.0-feat-x.1" {
t.Errorf("a package new in a group = %s, want 0.1.0-feat-x.1", got)
}
if got := CleanTag("--Fix/ä_1965--"); got != "fix-1965" {
t.Errorf("CleanTag = %s", got)
}
})
t.Run("the floor wakes on its silent source, never a timer", func(t *testing.T) {
ctx := context.Background()
src := FloorSources{
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
Tags: func(context.Context, string, string) ([]string, error) { return nil, errors.New("no answer") },
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
}
f, why := src.Ask(ctx, FloorOf{Seat: "oci-artifact-registry", Name: "m/program"})
if f.Known() || !reflect.DeepEqual(f.Silent, []string{SourceTags}) || why[SourceTags] != "no answer" {
t.Fatalf("Ask = %+v, %v; want bridge_tags silent", f, why)
}
var fw FloorWaits
w := fw.Wait("m/program", "oci-artifact-registry", "distribution", f, why, time.Unix(0, 0))
if !strings.Contains(w.Finding(), "bridge_tags (no answer)") {
t.Errorf("finding = %q", w.Finding())
}
for _, c := range []struct {
kind, seat, module, name string
want []string
}{
{WakePublished, "oci-artifact-registry", "", "", nil}, // the seat answered already: a publish wakes nothing
{WakeAccepted, "", "", "", nil},
{WakeForge, "", "", "", []string{"m/program"}},
{WakeCleared, "", "mesh-delivery-forge-bridge", "", []string{"m/program"}}, // recovered with nothing to say
{WakeCleared, "", "mesh-delivery-indexer", "", nil},
{WakeByHand, "", "", "m/program", []string{"m/program"}},
{WakeByHand, "", "", "other", nil},
} {
if got := fw.Wake(c.kind, c.seat, c.module, c.name); !reflect.DeepEqual(got, c.want) {
t.Errorf("Wake(%s %s %s %s) = %v, want %v", c.kind, c.seat, c.module, c.name, got, c.want)
}
}
seatDown := Floor{Silent: []string{SourceSeat}}
fw.Wait("distribution/program", "container-image-registry", "distribution", seatDown, nil, time.Unix(0, 0))
if got := fw.Wake(WakePublished, "container-image-registry", "", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
t.Errorf("the holder's published woke %v, want its own image's name", got)
}
if got := fw.Wake(WakeCleared, "", "distribution", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
t.Errorf("the holder's clear woke %v", got)
}
if !fw.Answered("m/program") || fw.Answered("m/program") || len(fw.Waiting()) != 1 {
t.Error("Answered drops the wait once")
}
// DLV-B4: the waits are kept in floor-waits; a restarted builder takes them in, and its sources' events still wake them.
kept := keeperMem{}
restarted := &FloorWaits{}
restarted.Keep(kept, []FloorWait{{Name: "m/held", Seat: SeatImages, Silent: []string{SourceTags}, Since: time.Unix(5, 0)}})
if got := restarted.Wake(WakeForge, "", "", ""); !reflect.DeepEqual(got, []string{"m/held"}) {
t.Errorf("a wait held in floor-waits woke %v after a restart, want m/held", got)
}
restarted.Wait("m/new", SeatNPM, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(9, 0))
ans, err := AnswerFloorWaits(Reader{Keys: kept.keys, Get: kept.get})
if err != nil || len(ans.Waits) != 2 || ans.Waits[0].Name != "m/held" || ans.Waits[1].Name != "m/new" || !strings.Contains(ans.Waits[1].Finding, "seat did not answer") {
t.Fatalf("builder_floor_waits = %+v, %v", ans, err)
}
restarted.Answered("m/held")
if _, ok := kept[FloorWaitKey("m/held")]; ok {
t.Error("an answered floor stays in floor-waits")
}
// The lead's ruling (review round 1): builder_bounds lists each key of the state bounds with what it bounds and
// when it ends.
at := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC)
bounds := keeperMem{PublishBoundKey("m/program", "0.1.4"): EncodeBound("the publish of m/program@0.1.4 to oci-artifact-registry", at.Add(PublishBound)),
OldFormBoundKey("g14"): EncodeBound("the seven days g14 holds the old form's window", at.Add(FindingAfter))}
rows, err := AnswerBounds(Reader{Keys: bounds.keys, Get: bounds.get}, at)
if err != nil || len(rows) != 2 || rows[0].Key != OldFormBoundKey("g14") || rows[1].Left != "10m0s" ||
!strings.Contains(rows[1].Bounds, "m/program@0.1.4") || !rows[0].Ends.Equal(at.Add(FindingAfter)) {
t.Fatalf("builder_bounds = %+v, %v", rows, err)
}
})
t.Run("old form", func(t *testing.T) {
// One kind, a first form and a newer one.
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
both := []string{forms.FormNeed("oci")}
for _, c := range []struct {
name string
counted map[string][]string
dropped map[string]bool
want []string
}{
{"one node lacking the new form holds the old", map[string][]string{"a": both, "b": nil}, nil, []string{"b"}},
{"an away node counts, stating its last", map[string][]string{"a": both, "c": {"bus-state"}}, nil, []string{"c"}},
{"every counted node reads the newest: closed", map[string][]string{"a": both, "c": both}, nil, nil},
{"a retired node dropped by hand counts no more", map[string][]string{"a": both, "c": nil}, map[string]bool{"c": true}, nil},
{"the builder's own forms: no window", nil, nil, nil},
} {
if got := Holding(fs, c.counted, c.dropped); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: Holding = %v, want %v", c.name, got, c.want)
}
}
// The builder's own forms: the registry seat's form of a bundle is not a first form, so a node stating nothing
// holds the window and one stating form:seat-reference does not.
if got := Holding(forms.Current, map[string][]string{"a": nil, "b": {forms.FormNeed(forms.FormSeatReference)}}, nil); !reflect.DeepEqual(got, []string{"a"}) {
t.Errorf("forms.Current, a node stating nothing and one stating form:seat-reference: Holding = %v, want a", got)
}
t0 := time.Unix(0, 0)
named := []string{"c", "d"}
w, closed, overdue := OldFormWindow{}.Step(named, []string{"c"}, t0)
if closed || len(overdue) != 0 || !w.Open {
t.Fatalf("Step = %+v %v %v", w, closed, overdue)
}
w, _, overdue = w.Step(named, []string{"c"}, t0.Add(FindingAfter))
if !reflect.DeepEqual(overdue, []string{"c"}) {
t.Errorf("seven days held: overdue %v, want c", overdue)
}
w, err := w.Drop("d", "op@shanks", "retired", t0)
if err != nil || !w.DroppedSet()["d"] {
t.Fatalf("Drop = %+v, %v", w, err)
}
if again, _ := w.Drop("d", "op@shanks", "again", t0); len(again.Dropped) != 1 {
t.Errorf("a node dropped twice is kept twice: %+v", again.Dropped)
}
if _, err := w.Drop("e", "op@shanks", "", t0); err == nil {
t.Error("a drop with no why is taken")
}
// Security-upgrade F3 (review round 1), by mesh-sdk's delivery.StepWindow: a read naming no node (an empty
// node_haves, the placement not read yet) never closes the window, and its holder's seven days still count.
if held, shut, late := w.Step(nil, nil, t0.Add(FindingAfter+time.Minute)); shut || !held.Open || !reflect.DeepEqual(late, []string{"c"}) {
t.Fatalf("a read naming no node: %+v closed=%v overdue %v, want the window open and c overdue", held, shut, late)
}
w, closed, _ = w.Step(named, nil, t0.Add(FindingAfter+time.Hour))
if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) || w.Closing().Dropped[0].Node != "d" {
t.Errorf("closing: %+v closed=%v", w, closed)
}
raw, _ := json.Marshal(w)
if back, err := DecodeOldFormWindow(raw); err != nil || !back.Closed.Equal(w.Closed) {
t.Errorf("the window read back = %+v, %v", back, err)
}
if _, again, _ := w.Step(named, nil, t0.Add(FindingAfter+2*time.Hour)); again {
t.Error("a closed window closes again: old-form-closed would be said twice")
}
})
t.Run("at the claim", func(t *testing.T) {
ctx := context.Background()
tagsUp := false
var asked []string
src := FloorSources{
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
Tags: func(_ context.Context, repo, prefix string) ([]string, error) {
asked = append(asked, repo+" "+prefix)
if !tagsUp {
return nil, errors.New("no answer")
}
return []string{"go/v0.1.13"}, nil
},
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
}
var said []delivery.VersionNumbered
fw := &FloorWaits{}
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Waits: fw, Said: func(b delivery.VersionNumbered) { said = append(said, b) }}
c := BuildClaim{Module: "m", Path: "go", Repository: "novox/m", Fingerprint: "fp1", Commit: "c1", Revision: "7", Merge: 7,
Artifacts: []ClaimArtifact{{Artifact: "program", Kind: KindBundle, Language: "go"}, {Artifact: "base", Kind: "upstream"}}}
got, err := n.Number(ctx, c)
if err != nil || len(got.Versions) != 0 || !reflect.DeepEqual(got.Unnumbered, []string{"program"}) || got.Wait != "" {
t.Fatalf("silent floor: %+v, %v; want program unnumbered, the upstream copy left out", got, err)
}
if !reflect.DeepEqual(asked, []string{"novox/m go/v"}) || len(fw.Waiting()) != 1 || len(said) != 0 {
t.Fatalf("asked %v, waits %+v, said %v", asked, fw.Waiting(), said)
}
pkg := c
pkg.Artifacts = []ClaimArtifact{{Artifact: "lib", Kind: KindPackage, Language: "go"}}
if got, _ := n.Number(ctx, pkg); got.Wait == "" || len(got.Versions) != 0 {
t.Fatalf("a package with no floor = %+v; want it to wait", got)
}
tagsUp = true
got, err = n.Number(ctx, c)
if err != nil || got.Versions["program"] != "0.1.14" || len(said) != 1 || said[0].Version != "0.1.14" || len(fw.Waiting()) != 1 {
t.Fatalf("floor answered: %+v, %v, said %v, waits %+v; want 0.1.14 said once, lib still waiting", got, err, said, fw.Waiting())
}
// DLV-B6: a check's build of a head a tag ask names numbers only its packages, tagged below the next version, kept
// with its repository for the branch's end, said with the head; claimed again it keeps its number.
tagged := c
tagged.Tag, tagged.Head, tagged.Revision, tagged.Merge = "feat-x", "h1", "", 0
tagged.Artifacts = append(tagged.Artifacts, ClaimArtifact{Artifact: "tlib", Kind: KindPackage, Language: "go"})
saidBefore, askedBefore := said, asked
said = nil
got, err = n.Number(ctx, tagged)
if err != nil || len(got.Versions) != 1 || !strings.Contains(got.Versions["tlib"], "-feat-x.1") || len(said) != 1 || said[0].Head != "h1" || said[0].Tag != "feat-x" {
t.Fatalf("tagged claim: %+v, %v, said %+v; want tlib alone tagged, said with its head", got, err, said)
}
if es, _ := n.Versions.Of(ctx, "m/tlib"); len(es) != 1 || es[0].Repository != "novox/m" {
t.Errorf("the tagged version kept = %+v, want its repository", es)
}
if again, _ := n.Number(ctx, tagged); again.Versions["tlib"] != got.Versions["tlib"] || len(said) != 1 {
t.Errorf("claimed again: %+v, said %d; want the same number, said once", again, len(said))
}
said, asked = saidBefore, askedBefore
got, _ = n.Number(ctx, c)
var publishing []string // review round 1: a version built and still numbered arms its publish bound
n.Publishing = func(_ context.Context, e Entry) { publishing = append(publishing, e.Name+"@"+e.Version) }
if err := n.Built(ctx, c, got, map[string]string{"program": "sha256:aa"}); err != nil {
t.Fatal(err)
}
if n.Publishing = nil; !reflect.DeepEqual(publishing, []string{"m/program@0.1.14"}) {
t.Fatalf("the publish bounds armed: %v, want m/program@0.1.14", publishing)
}
again, _ := n.Number(ctx, c)
if again.Versions["program"] != "0.1.14" || len(said) != 1 || len(asked) != 3 {
t.Fatalf("again: %+v, said %d, asked %v; want the number kept, nothing said, the floor not asked", again, len(said), asked)
}
if _, ok, why, _ := n.Versions.Recorded(ctx, "m/program", "0.1.14", "sha256:bb"); ok {
t.Fatalf("another digest recorded: %s", why)
}
// DLV-B4: the seat's events move the entry; a published wakes the names waiting on that seat.
if wake, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e1", Outcome{Name: "m/other", Digest: "sha256:cc"}); err != nil || len(wake) != 0 {
t.Fatalf("a by-digest publish of another seat's wait: %v, %v", wake, err)
}
n.Waits.Wait("m/lib", SeatGoPackages, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(0, 0))
if wake, _ := n.Heard(ctx, SeatGoPackages, HeardPublished, "e2", Outcome{Name: "m/x", Digest: "sha256:dd"}); !reflect.DeepEqual(wake, []string{"m/lib"}) {
t.Fatalf("a published on the Go seat woke %v, want m/lib, whose seat was silent", wake)
}
for _, h := range []struct{ event, state, reason string }{{HeardRefused, Refused, "bytes differ"}, {HeardPublished, Published, ""}, {HeardRefused, Published, ""}, {HeardRemoved, Removed, "retention"}} {
if _, err := n.Heard(ctx, SeatArtifacts, h.event, "e3", Outcome{Name: "m/program", Version: "0.1.14", Digest: "sha256:aa", Reason: h.reason}); err != nil {
t.Fatal(err)
}
all, _ := n.Versions.Of(ctx, "m/program")
if all[0].State != h.state {
t.Fatalf("after %s: %+v, want %s", h.event, all[0], h.state)
}
}
if _, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e4", Outcome{Name: "m/none", Version: "0.1.0"}); err == nil {
t.Fatal("a published of a version the builder never numbered is not said")
}
})
t.Run("a package is numbered under its name in its ecosystem", func(t *testing.T) {
// design 32: the indexer's packages state names, in each series key, the package's name and the <module>/<artifact>
// it is built from; the builder numbers, asks the floor of and records the package under that name.
ctx := context.Background()
state := packagesMem{
"git=2enovox=2ebe/novox/mesh-sdk/go.0.1": []byte(`{"v":1,"name":"git.novox.be/novox/mesh-sdk/go","package":"mesh-sdk/go","series":"0.1"}`),
"@novox/mesh-sdk.0.1": []byte(`{"v":1,"name":"@novox/mesh-sdk","package":"mesh-sdk/ts","series":"0.1"}`),
"git=2enovox=2ebe/novox/mesh-sdk/go": []byte(`{"v":1,"module":"git.novox.be/novox/mesh-sdk/go"}`),
"broken.0.1": []byte(`not json`),
}
var floorOf []string
src := FloorSources{
Seat: func(_ context.Context, _, name string) ([]string, error) {
floorOf = append(floorOf, name)
return []string{"0.1.3"}, nil
},
Tags: func(context.Context, string, string) ([]string, error) { return nil, nil },
Requirements: func(_ context.Context, name string) ([]string, error) {
floorOf = append(floorOf, name)
return nil, nil
},
}
var said []delivery.VersionNumbered
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Names: NamesIn(state), Said: func(b delivery.VersionNumbered) { said = append(said, b) }}
c := BuildClaim{Module: "mesh-sdk", Path: "go", Repository: "novox/mesh-sdk", Fingerprint: "fp1", Commit: "c1", Revision: "3", Merge: 3,
Artifacts: []ClaimArtifact{{Artifact: "go", Kind: KindPackage, Language: "go"}, {Artifact: "ts", Kind: KindPackage, Language: "typescript"},
{Artifact: "new", Kind: KindPackage, Language: "go"}, {Artifact: "program", Kind: KindBundle, Language: "go"}}}
got, err := n.Number(ctx, c)
if err != nil || got.Versions["go"] != "0.1.4" || got.Versions["ts"] != "0.1.4" || !reflect.DeepEqual(got.Unnumbered, []string{"new"}) {
t.Fatalf("Number = %+v, %v; want go and ts numbered, new unnumbered (no name yet)", got, err)
}
if got.Names["go"] != "git.novox.be/novox/mesh-sdk/go" || got.Names["ts"] != "@novox/mesh-sdk" || got.Names["program"] != "mesh-sdk/program" {
t.Errorf("names = %v", got.Names)
}
if !slices.Contains(floorOf, "git.novox.be/novox/mesh-sdk/go") || slices.Contains(floorOf, "mesh-sdk/go") {
t.Errorf("the floor was asked of %v, want the Go module path", floorOf)
}
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Version != "0.1.4" {
t.Errorf("versions of the Go module path = %+v", es)
}
if es, _ := n.Versions.Of(ctx, "mesh-sdk/go"); len(es) != 0 {
t.Errorf("numbered under the builder's name too: %+v", es)
}
names := map[string]bool{}
for _, b := range said {
names[b.Name] = true
}
if !names["git.novox.be/novox/mesh-sdk/go"] || !names["@novox/mesh-sdk"] {
t.Errorf("version.numbered said %v, want the ecosystem names", names)
}
if err := n.Built(ctx, c, got, map[string]string{"go": "sha256:aa"}); err != nil {
t.Fatal(err)
}
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Digest != "sha256:aa" {
t.Errorf("the built digest is not recorded under the name: %+v", es)
}
tagged, _ := TagAsk{Head: "h", State: TagAsked}.WithNumbered("mesh-sdk", "h", map[string]string{"go": "0.1.4-x.1"}, got.Names, time.Unix(1, 0))
if tagged.Versions["git.novox.be/novox/mesh-sdk/go"] != "0.1.4-x.1" {
t.Errorf("a tag ask names %v, want the Go module path", tagged.Versions)
}
// The indexer writes the name of `new` later: its series key names the module, and the look at the done build
// numbers it with the done key's digest, said in version.numbered for the late publish; the rest are kept.
state["new.0.1"] = []byte(`{"v":1,"name":"new","package":"mesh-sdk/new","series":"0.1"}`)
for key, want := range map[string]string{"new.0.1": "mesh-sdk", "git=2enovox=2ebe/novox/mesh-sdk/go": "", "broken.0.1": ""} {
if module, name, ok := NamedModule(key, state[key]); module != want || ok != (want != "") || (ok && name != "new") {
t.Errorf("NamedModule(%s) = %q, %q, %v, want %q", key, module, name, ok, want)
}
}
said = nil
done := c
done.Done = map[string]string{"go": "sha256:aa", "new": "sha256:bb"}
late, err := n.Number(ctx, done)
if err != nil || late.Versions["new"] != "0.1.4" || late.Versions["go"] != "0.1.4" || len(late.Unnumbered) != 0 {
t.Fatalf("a look at the done build = %+v, %v; want new numbered and go kept", late, err)
}
if len(said) != 1 || said[0].Name != "new" || said[0].Digest != "sha256:bb" {
t.Errorf("version.numbered said %+v, want new once with the done build's digest", said)
}
if es, _ := n.Versions.Of(ctx, "new"); len(es) != 1 || es[0].Digest != "sha256:bb" {
t.Errorf("versions of new = %+v, want its done digest recorded", es)
}
if _, _, err := NamesIn(packagesMem(nil).failing())(ctx, "m", "a"); err == nil {
t.Error("a packages state that cannot be read is no error")
}
// A packages state not on the bus yet leaves the package unnumbered, said, and numbers the rest of the claim.
n.Names = NamesIn(packagesMem(nil).failing())
bare, err := n.Number(ctx, c)
if err != nil || !reflect.DeepEqual(bare.Unnumbered, []string{"go", "new", "ts"}) || bare.Versions["program"] == "" ||
!strings.Contains(bare.Said[0].Line, "cannot be read") {
t.Errorf("an unreadable packages state: %+v, %v", bare, err)
}
})
t.Run("fill", func(t *testing.T) {
if _, full := Fill(Cap*79/100, Cap); full {
t.Error("79 % is full")
}
if _, full := Fill((Cap*80+99)/100, Cap); !full {
t.Error("80 % is not full")
}
})
t.Run("publish again", func(t *testing.T) {
num := Entry{Name: "m/p", Kind: KindBundle, Version: "0.1.3", Fingerprint: "f1", Digest: "sha256:aa", State: Numbered}
ref := num
ref.State, ref.RefusedDigest = Refused, "sha256:aa"
other := ref
other.RefusedDigest = "sha256:bb"
pub := num
pub.State = Published
gone := pub
gone.State = Removed
tagged := num
tagged.Tag, tagged.Version = "feat-x", "0.1.3-feat-x.1"
for _, c := range []struct {
name string
entries []Entry
fp string
built bool
do string
}{
{"a held build is said numbered again", []Entry{num}, "f1", true, AgainPublish},
{"refused for its own bytes is published again", []Entry{ref}, "f1", true, AgainPublish},
{"a gone file is built again under the same number", []Entry{ref}, "f1", false, AgainBuild},
{"after a published it takes a new number", []Entry{pub}, "f1", true, AgainRenumber},
{"after a removal it takes a new number", []Entry{gone}, "f1", false, AgainRenumber},
{"refused for other bytes is refused", []Entry{other}, "f1", true, ""},
{"a fingerprint not numbered is refused", []Entry{num}, "f2", true, ""},
{"a tagged version is not a trunk fingerprint's", []Entry{tagged}, "f1", true, ""},
} {
a, err := PublishAgain(c.entries, c.fp, c.built)
if (err != nil) != (c.do == "") || a.Do != c.do {
t.Errorf("%s: PublishAgain = %+v, %v; want %q", c.name, a, err, c.do)
}
}
ctx := context.Background()
// A fresh claim of a published fingerprint takes the next number; the old one stays.
st := newMem()
v := Versions{Store: st}
raw, _ := Encode(Entry{Name: "m/p", Kind: KindBundle, Series: "0.1", Version: "0.1.3", Fingerprint: "f1", State: Published, At: time.Unix(1, 0)})
_, _ = st.Create(ctx, Key("m/p", "0.1.3"), raw)
if d, _, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1"}); err != nil || d.Version != "0.1.3" || !d.Keep {
t.Fatalf("a published fingerprint keeps its number: %+v %v", d, err)
}
if d, e, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1", Fresh: true}); err != nil || d.Version != "0.1.4" || d.Keep || e.Fingerprint != "f1" {
t.Fatalf("a fresh claim takes the next number: %+v %+v %v", d, e, err)
}
// A refusal records the digest refused; Built replaces a digest no published was said for, never a published one.
n := &Numberer{Versions: v}
_, _ = n.Heard(ctx, SeatArtifacts, HeardRefused, "r1", Outcome{Name: "m/p", Version: "0.1.4", Digest: "sha256:old", Reason: "x"})
if es, _ := v.Of(ctx, "m/p"); es[1].RefusedDigest != "sha256:old" {
t.Errorf("refused records no digest: %+v", es[1])
}
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.Digest = "sha256:old" })
bc := BuildClaim{Module: "m"}
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.4"}}, map[string]string{"p": "sha256:new"})
_, _ = v.Mark(ctx, "m/p", "0.1.3", func(e *Entry) { e.Digest = "sha256:held" })
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.3"}}, map[string]string{"p": "sha256:other"})
es, _ := v.Of(ctx, "m/p")
if es[0].Digest != "sha256:held" || es[1].Digest != "sha256:new" {
t.Errorf("Built: published %s (want sha256:held), refused %s (want sha256:new)", es[0].Digest, es[1].Digest)
}
if all, err := v.All(ctx); err != nil || len(all) != 2 {
t.Errorf("All = %d entries, %v", len(all), err)
}
// The hand acts: a held build is said numbered once more under a fresh dedup id; a gone one is built again; a
// published one takes a new number; a name with versions asks no floor.
if _, _, err := (Acts{Numberer: &Numberer{Versions: v}, Done: func(context.Context, string, string) (bool, error) { return true, nil }}).PublishAgain(ctx, "m/p", "f1", "w", "", time.Unix(4, 0)); err == nil {
t.Error("a version refused for other bytes is published again")
}
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.State = Published })
var said []delivery.VersionNumbered
var rebuilt []string
done := true
acts := Acts{Numberer: &Numberer{Versions: v, Said: func(b delivery.VersionNumbered) { said = append(said, b) }, Waits: &FloorWaits{}},
Done: func(context.Context, string, string) (bool, error) { return done, nil },
BuildAgain: func(_ context.Context, m, fp, _, _ string) (string, bool) {
rebuilt = append(rebuilt, m+" "+fp)
return "built again", false
},
Reconsider: func(context.Context) {}}
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(5, 0)); err != nil || ag.Do != AgainRenumber || ag.Entry.Version != "0.1.5" ||
len(said) != 1 || said[0].Version != "0.1.5" || said[0].Again == "" || len(rebuilt) != 0 {
t.Errorf("publish again of a published, held build: %+v %v said %+v rebuilt %v", ag, err, said, rebuilt)
}
done = false
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(6, 0)); err != nil || ag.Do != AgainBuild || ag.Entry.Version != "0.1.5" || len(rebuilt) != 1 || len(said) != 1 {
t.Errorf("publish again of a gone build: %+v %v said %d rebuilt %v", ag, err, len(said), rebuilt)
}
if _, err := acts.FloorAgain(ctx, "m/p"); err == nil {
t.Error("a name with versions asks its floor again")
}
if ans, err := acts.FloorAgain(ctx, "m/new"); err != nil || ans == "" {
t.Errorf("floor again of a new name: %q %v", ans, err)
}
})
t.Run("tag asks", func(t *testing.T) {
at := time.Unix(100, 0)
k, raw, err := NewTagAsk("novox/mesh-sdk", "feat/X y", "abc", "a check", "me@shanks", at)
if err != nil || k != TagAskKey("novox/mesh-sdk", "feat/X y") || strings.Count(k, ".") != 1 {
t.Fatalf("NewTagAsk = %q, %v", k, err)
}
a, err := DecodeTagAsk(raw)
if err != nil || a.Tag != "feat-x-y" || a.State != TagAsked || a.Untag {
t.Fatalf("DecodeTagAsk = %+v, %v", a, err)
}
for _, bad := range [][4]string{{"mesh-sdk", "b", "h", "w"}, {"o/r", "", "h", "w"}, {"o/r", "b", "", "w"}, {"o/r", "b", "h", ""}} {
if _, _, err := NewTagAsk(bad[0], bad[1], bad[2], bad[3], "", at); err == nil {
t.Errorf("NewTagAsk%v is not refused", bad)
}
}
_, uraw, err := NewUntag("o/r", "feat/x", "branch gone by hand", "", at.Add(time.Second))
if u, _ := DecodeTagAsk(uraw); err != nil || !u.Untag {
t.Fatalf("NewUntag = %+v, %v", u, err)
}
vals := map[string][]byte{"a": uraw, "b": raw}
r := Reader{Keys: func() ([]string, error) { return []string{"a", "b"}, nil },
Get: func(k string) (json.RawMessage, bool, error) { return vals[k], true, nil }}
if got, err := AnswerTagAsks(r, ""); err != nil || len(got.Asks) != 2 || got.Asks[0].Repository != "novox/mesh-sdk" {
t.Errorf("AnswerTagAsks = %+v, %v", got, err)
}
if got, _ := AnswerTagAsks(r, "o/r"); len(got.Asks) != 1 || !got.Asks[0].Untag {
t.Errorf("AnswerTagAsks(o/r) = %+v", got)
}
es := []Entry{{Name: "n", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r"}, {Name: "n", Version: "0.1.2-feat-x.2", Tag: "feat-x", Series: "0.1", State: Removed, Repository: "o/r"},
{Name: "n2", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/other"},
{Name: "n", Version: "0.1.1", Series: "0.1"}, {Name: "n", Version: "0.1.2-feat-y.1", Tag: "feat-y", Series: "0.1"}}
for _, c := range []struct {
asks []TagAsk
repo, head string
want string
}{
{[]TagAsk{a}, "novox/mesh-sdk", "abc", "feat-x-y"},
{[]TagAsk{a}, "novox/mesh-sdk", "other", ""},
{[]TagAsk{{Repository: "novox/mesh-sdk", Head: "abc", Tag: "t", State: TagRemoved}}, "novox/mesh-sdk", "abc", ""},
} {
if got, _ := TagFor(c.asks, c.repo, c.head); got != c.want {
t.Errorf("TagFor(%s, %s) = %q, want %q", c.repo, c.head, got, c.want)
}
}
if got := Untagged(es, "o/r", "feat/x"); len(got) != 1 || got[0].Version != "0.1.2-feat-x.1" {
t.Errorf("Untagged = %+v", got)
}
st := newMem()
for _, e := range append(es, Entry{Name: "n3", Version: "0.1.0-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r", Seat: SeatNPM}) {
e.Kind, e.Module, e.Fingerprint, e.At = KindPackage, "m", "f", at
if e.State == "" {
e.State = Numbered
}
raw, err := Encode(e)
if err != nil {
t.Fatal(err)
}
_, _ = st.Create(context.Background(), Key(e.Name, e.Version), raw)
}
var removed []string
var kept map[string]bool // review round 2: the names whose seat keeps them whole (a shared digest)
acts := Acts{Numberer: &Numberer{Versions: Versions{Store: st}}, Remove: func(_ context.Context, seat string, e Entry) (RemoveOutcome, error) {
removed = append(removed, e.Name+"@"+e.Version)
if kept[e.Name] {
return RemoveOutcome{Kept: true, Reason: "shared-digest", Tags: []string{"0.1.2"}}, nil
}
if seat == SeatNPM {
return RemoveOutcome{}, errors.New("down")
}
return RemoveOutcome{}, nil
}}
u, _ := DecodeTagAsk(uraw)
got := acts.TagAsked(context.Background(), u, at)
if got.State != TagRemoving || len(removed) != 2 || len(got.Left) != 2 || !strings.Contains(got.Left["n3@0.1.0-feat-x.1"], "down") || got.Answered.IsZero() {
t.Errorf("untag: %+v removed %v", got, removed)
}
if k := acts.TagAsked(context.Background(), a, at); k.State != TagAsked || k.Answer == "" {
t.Errorf("a tag ask is not kept: %+v", k)
}
// DLV-B6: a branch's end asks remove of every tagged version not removed, and is done once each seat said removed.
removed = nil
end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x")
if err != nil || end.Done || len(removed) != 2 || !reflect.DeepEqual(end.Seats(), []string{"", SeatNPM}) || !strings.Contains(end.Finding("o/r", "feat/x", at), "n3@0.1.0-feat-x.1 on npm-package-registry: down") {
t.Fatalf("BranchEnded = %+v %v, removed %v", end, err, removed)
}
for _, v := range [][2]string{{"n", "0.1.2-feat-x.1"}, {"n3", "0.1.0-feat-x.1"}} {
if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardRemoved, "rm", Outcome{Name: v[0], Version: v[1], Reason: "tag-gone"}); err != nil {
t.Fatal(err)
}
}
if end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x"); !end.Done {
t.Errorf("every seat said removed, and the branch's end is not done: %+v", end)
}
// Review round 2: a remove its seat answers kept (another tag names the digest) ends there, with no retry: the
// version is recorded kept on the store with the reason and the other tags, which builder_versions shows.
raw, _ = Encode(Entry{Name: "n4", Kind: KindPackage, Series: "0.1", Version: "0.1.2-feat-x.1", Tag: "feat-x", Repository: "o/r", Seat: SeatNPM, State: Numbered})
_, _ = st.Create(context.Background(), Key("n4", "0.1.2-feat-x.1"), raw)
kept, removed = map[string]bool{"n4": true}, nil
if end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x"); err != nil || !end.Done || len(removed) != 1 {
t.Fatalf("a kept remove: %+v %v, removed %v; want done at once", end, err, removed)
}
if es, _ := acts.Numberer.Versions.Of(context.Background(), "n4"); len(es) != 1 || es[0].State != Kept || es[0].Reason != "shared-digest" || !reflect.DeepEqual(es[0].KeptWith, []string{"0.1.2"}) {
t.Errorf("the kept version = %+v, want kept, shared-digest, with 0.1.2", es)
}
if removed = nil; func() bool {
end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x")
return !end.Done || len(removed) != 0
}() {
t.Errorf("a kept version is asked again: removed %v", removed)
}
if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardKept, "k", Outcome{Name: "n", Version: "0.1.2-feat-x.1", Tags: []string{"0.1.2"}}); err != nil {
t.Fatal(err)
}
if es, _ := acts.Numberer.Versions.Of(context.Background(), "n"); slices.ContainsFunc(es, func(e Entry) bool { return e.Version == "0.1.2-feat-x.1" && e.State == Kept }) {
t.Error("a seat's kept turned a removed version back to kept")
}
// B6: a check's build of the ask's head marks it numbered with its versions; another head, or an untag, is left.
if !a.Unanswered() {
t.Errorf("a kept ask with no version is answered: %+v", a)
}
if _, changed := a.WithNumbered("mesh-sdk", "other", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); changed {
t.Error("an ask is marked numbered by a build of another head")
}
if _, changed := u.WithNumbered("o", "", map[string]string{"go": "1"}, nil, at); changed {
t.Error("an untag is marked numbered")
}
n, changed := a.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at.Add(time.Minute))
if !changed || n.State != TagNumbered || n.Versions["mesh-sdk/go"] != "0.1.15-feat-x-y.1" || n.Unanswered() || !strings.Contains(n.Answer, "mesh-sdk/go 0.1.15-feat-x-y.1") {
t.Errorf("WithNumbered = %+v, %v", n, changed)
}
if _, again := n.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); again {
t.Error("the same versions mark the ask again")
}
said := NarrateUnanswered(a, []FloorWait{{Name: "mesh-sdk/go", Silent: []string{"bridge_tags"}}}, at.Add(6*time.Minute))
for _, w := range []string{"novox/mesh-sdk feat/X y", "after 6m0s", "not claimed yet", "mesh-sdk/go waits on its floor (bridge_tags silent)"} {
if !strings.Contains(said, w) {
t.Errorf("NarrateUnanswered = %q, want %q in it", said, w)
}
}
if k, v, err := TagWantedAsk(TagWanted{Repository: "o/r", Branch: "feat/x", Head: "abc"}, "mesh-delivery-checker", at); err != nil || k != TagAskKey("o/r", "feat/x") || !strings.Contains(string(v), `"by":"mesh-delivery-checker"`) {
t.Errorf("TagWantedAsk = %q %s %v", k, v, err)
}
})
}
+31 -6
View File
@@ -22,6 +22,7 @@ import (
type state struct {
Name string `json:"name"`
History int `json:"history,omitempty"`
KeyTTL bool `json:"key-ttl,omitempty"`
}
type artifact struct {
@@ -93,13 +94,13 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
if m.Module != "mesh-delivery-builder" {
t.Errorf("module = %q", m.Module)
}
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "old-form", History: 1}, {Name: "bounds", History: 1, KeyTTL: true}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-ask-bounds", History: 1, KeyTTL: true}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
t.Errorf("state = %+v, want %+v", m.State, want)
}
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
// holds no capacity; it puts asks on the three build seats and reads their steps (ADR 0525 §1, §4, §5).
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
"mesh-delivery-assigner.placement", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules"}; !reflect.DeepEqual(sorted(m.Reads), want) {
"mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"node-build-go", "node-build-image", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -108,13 +109,35 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// 1937/AC3: the builder asks the controller whether the artifact store still holds a done key's blobs (held), and no
// other tool; it holds no capacity.
// 1955/L7 (ADR 0571 §2): the seat mesh-artifact-sweep's held, the controller's `held` is no longer asked (issue 1955).
if want := []string{"seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
// DLV-M1 (ADR 0615 §1): the indexer's modules, manifests and candidates and the assigner's placement are asked by their
// read tools and followed by their views families, never read; so are the indexer's packages, whose names a package is
// numbered under (design 32).
for _, r := range m.Reads {
if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") {
t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r)
}
}
// Design 51 *The floor*: the floor's three sources, the registry seats' versions, bridge_tags and indexer_requirements.
if want := []string{"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_requirements", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
"seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:mesh-hand-acts.hand-act", "seat:node-engine.manifest-reads", "seat:npm-package-registry.remove",
"seat:npm-package-registry.versions", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
}
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it
// starts; and by the seat mesh-artifact-sweep's artifacts-gone, a blob the sweep found gone (1937/AC4, 1955/L7), the
// controller's no longer (1126/B9, ADR 0590).
if want := []string{"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
// Design 51 *The floor*: the indexer's manifest-accepted and the condition keeper's condition-cleared wake a silent floor.
// DLV-B4: the four registry seats' outcomes, each a seat event `<seat>.<event>` on the shared events stream.
// DLV-M1: the indexer's and the assigner's views families of the states it follows.
if want := []string{"container-image-registry.kept", "container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed",
"go-package-registry.kept", "go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed",
"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
"mesh-conditions.condition-cleared", "mesh-delivery-assigner.views.placement", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**",
"mesh-delivery-indexer.manifest-accepted", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.packages", "node-engine.manifest-reads-changed",
"npm-package-registry.kept", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed",
"oci-artifact-registry.kept", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
@@ -124,10 +147,12 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"builder_announce_again", "builder_ask_again", "builder_bounds", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_first_view_again", "builder_first_views",
"builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
if want := []string{"build.asked-again"}; !reflect.DeepEqual(m.Emits, want) {
// Design 51 *Events of the builder*: numbered and old-form-closed on the family version, apart from the builds' ends.
if want := []string{"build.asked-again", "version.numbered", "version.old-form-closed"}; !reflect.DeepEqual(m.Emits, want) {
t.Errorf("emits = %v, want %v", m.Emits, want)
}
// 1955/L7 (ADR 0571 §1): its module log, each build's start and end, kept 30 days; no bare stream.
+72 -10
View File
@@ -5,28 +5,94 @@
"tools": [
"builder_announce_again",
"builder_ask_again",
"builder_bounds",
"builder_build_again",
"builder_builds_since",
"builder_engine_reads",
"builder_floor_again",
"builder_floor_waits",
"builder_old_form",
"builder_publish_again",
"builder_seat_queue",
"builder_stuck"
"builder_stuck",
"builder_tag",
"builder_tag_asks",
"builder_untag",
"builder_versions",
"builder_first_views",
"builder_first_view_again"
],
"emits": [
"build.asked-again"
"build.asked-again",
"version.numbered",
"version.old-form-closed"
],
"consumes": [
"container-image-registry.kept",
"container-image-registry.published",
"container-image-registry.refused",
"container-image-registry.removed",
"go-package-registry.kept",
"go-package-registry.published",
"go-package-registry.refused",
"go-package-registry.removed",
"mesh-artifact-sweep.artifacts-gone",
"mesh-build-go.toolchain.reported",
"mesh-build-node.toolchain.reported"
"mesh-build-node.toolchain.reported",
"mesh-conditions.condition-cleared",
"mesh-delivery-checker.tag-wanted",
"mesh-delivery-forge-bridge.forge.**",
"mesh-delivery-indexer.manifest-accepted",
"node-engine.manifest-reads-changed",
"npm-package-registry.kept",
"npm-package-registry.published",
"npm-package-registry.refused",
"npm-package-registry.removed",
"oci-artifact-registry.kept",
"oci-artifact-registry.published",
"oci-artifact-registry.refused",
"oci-artifact-registry.removed",
"mesh-delivery-assigner.views.placement",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.packages"
],
"invokes": [
"seat:mesh-artifact-sweep.held"
"mesh-delivery-forge-bridge.bridge_tags",
"mesh-delivery-indexer.indexer_requirements",
"seat:container-image-registry.remove",
"seat:container-image-registry.versions",
"seat:go-package-registry.remove",
"seat:go-package-registry.versions",
"seat:mesh-artifact-sweep.held",
"seat:mesh-hand-acts.hand-act",
"seat:node-engine.manifest-reads",
"seat:npm-package-registry.remove",
"seat:npm-package-registry.versions",
"seat:oci-artifact-registry.remove",
"seat:oci-artifact-registry.versions",
"mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-indexer.indexer_read_candidates",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages"
],
"state": [
{ "name": "builds", "history": 5 },
{ "name": "engine-reads", "history": 1 },
{ "name": "findings", "history": 5 },
{ "name": "floor-waits", "history": 1 },
{ "name": "hearing", "history": 1 },
{ "name": "instances", "history": 1 },
{ "name": "old-form", "history": 1 },
{ "name": "bounds", "history": 1, "key-ttl": true },
{ "name": "queues", "history": 1 },
{ "name": "reasks", "history": 1 }
{ "name": "reasks", "history": 1 },
{ "name": "tag-ask-bounds", "history": 1, "key-ttl": true },
{ "name": "tag-asks", "history": 1 },
{ "name": "versions", "history": 1 },
{ "name": "first-views", "history": 1 }
],
"logs": [
{ "name": "build-log", "max-age-days": 30 }
@@ -37,11 +103,7 @@
"mesh-build-image.steps",
"mesh-build-node.steps",
"mesh-build-node.toolchains",
"mesh-delivery-assigner.placement",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules"
"mesh-delivery-gate.source"
],
"uses": [
"node-build-go",
@@ -13,6 +13,8 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-checker/internal/check"
)
@@ -326,6 +328,9 @@ func main() {
return err
}}
tools := append(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...)
// checker_first_views and checker_first_view_again: where the checker's first views of the indexer's and the assigner's
// states stand, and asking one again by hand (ADR 0615, DLV-M2).
tools = append(tools, views.StatusTools(delivery.CheckerModule, "checker_first_views", "checker_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, time.Now)...)
if err := stdio.Serve("", logged(tools)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
os.Exit(1)
@@ -11,6 +11,8 @@ import (
"github.com/nats-io/nats.go/jetstream"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
@@ -23,14 +25,14 @@ import (
// composition code, the composer built from its head run on the Go build seat.
// composeCandidateKey is the seat verb's call key, asked of one node (process.Conn.CallOn).
var composeCandidateKey = "seat:" + catalogue.EngineSeat + "." + delivery.EngineComposeCandidateVerb
var composeCandidateKey = "seat:" + catalogue.EngineSeat + "." + ne.VerbComposeCandidate
// engineAsks asks one node's engine over the bus, its answer waited for at most ctx's deadline (the answer bound).
type engineAsks struct {
call func(node, key string, body any) (json.RawMessage, error)
}
func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error) {
func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error) {
type answer struct {
raw json.RawMessage
err error
@@ -42,12 +44,12 @@ func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask deliv
}()
select {
case <-ctx.Done():
return delivery.ComposeCandidateAnswer{}, fmt.Errorf("no answer within its bound: %w", ctx.Err())
return ne.ComposeCandidateAnswer{}, fmt.Errorf("no answer within its bound: %w", ctx.Err())
case a := <-got:
if a.err != nil {
return delivery.ComposeCandidateAnswer{}, a.err
return ne.ComposeCandidateAnswer{}, a.err
}
return delivery.DecodeComposeCandidateAnswer(a.raw)
return nec.DecodeAnswer(a.raw)
}
}
@@ -59,7 +61,7 @@ type placedNodes struct {
}
func (p placedNodes) Placed() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound)
defer cancel()
kv, err := p.kv(ctx)
if err != nil {
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -22,10 +23,16 @@ import (
"github.com/nats-io/nats.go/jetstream"
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
"mesh-delivery-checker/internal/check"
)
@@ -91,6 +98,8 @@ type fakeBuildModules struct {
beside map[string][]string
// names are the nodes' names the last check ask carried, for the names check (ADR 0567 §12).
names []string
// packages are, by repository, the package versions the last Go check ask of it carried (StepAsk.Packages).
packages map[string]delivery.PackageVersions
}
func (f *fakeBuildModules) count(tool string) int {
@@ -197,6 +206,12 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key
if a.Check != nil {
f.mu.Lock()
f.names = a.Check.NodeNames
if repository, _ := a.Check.Fetch["repository"].(string); a.Language == delivery.AskGo {
if f.packages == nil {
f.packages = map[string]delivery.PackageVersions{}
}
f.packages[repository] = a.Packages
}
f.mu.Unlock()
}
return gobuild.RunAsk(ctx, steps, a)
@@ -220,6 +235,15 @@ func (b *bed) serveForge(t *testing.T, nc *nats.Conn) {
var a map[string]string
_ = json.Unmarshal(m.Data, &a)
switch {
case tool == "gitea_get_file" && strings.HasSuffix(a["path"], "module.json"):
// A manifest at a member's head (design 51 *Tagged versions*): the bed's own, else the forge's 404.
if content, ok := b.heads.Load(a["owner"] + "/" + a["repo"] + " " + a["path"] + " " + a["ref"]); ok {
answer, _ := json.Marshal(map[string]any{"result": map[string]any{"file": map[string]any{"content": content}}, "error": "", "node": "n0"})
_ = m.Respond(answer)
return
}
answer, _ := json.Marshal(map[string]any{"result": nil, "error": "Gitea API /repos/x/contents/" + a["path"] + ": 404 Not Found", "node": "n0"})
_ = m.Respond(answer)
case tool == "gitea_get_file" && b.stalePin.Load():
content := strings.Replace(string(mustRead(t, "forge-get-file-go-mod")), "cb54756c3865", "9aaa5c80ac5c", 1)
var result map[string]any
@@ -252,6 +276,8 @@ func mustRead(t *testing.T, name string) []byte {
type bed struct {
stalePin atomic.Bool
// heads are the manifests the forge holds at a member's head, by "<repository> <path> <ref>".
heads sync.Map
ctx context.Context
js jetstream.JetStream
buckets map[string]jetstream.KeyValue
@@ -284,7 +310,7 @@ func (b *bed) said(t *testing.T, words string) {
// engine places each node and serves compose-candidate for the first one, as its node-engine does on the bus (answered on
// the seat verb's caller-named and plain subjects); the others never answer. It answers what it was asked.
func (b *bed) engine(t *testing.T, nodes ...string) func() []delivery.ComposeCandidateAsk {
func (b *bed) engine(t *testing.T, nodes ...string) func() []ne.ComposeCandidateInput {
t.Helper()
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.PlacementBucket, History: 1})
if err != nil {
@@ -303,9 +329,9 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []delivery.ComposeCan
}
t.Cleanup(nc.Close)
var mu sync.Mutex
var asked []delivery.ComposeCandidateAsk
var asked []ne.ComposeCandidateInput
answer := func(m *nats.Msg) {
ask, err := delivery.DecodeComposeCandidateAsk(m.Data)
ask, err := nec.DecodeAsk(m.Data)
if err != nil {
_ = m.Respond([]byte(`{"error":"` + err.Error() + `"}`))
return
@@ -313,9 +339,9 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []delivery.ComposeCan
mu.Lock()
asked = append(asked, ask)
mu.Unlock()
a := delivery.ComposeCandidateAnswer{Node: nodes[0], Candidate: ask.Candidate, Verdict: delivery.ComposeCandidateComposed,
Own: delivery.ModuleComposed{Module: "node", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("b", 64)}}
raw, err := delivery.EncodeComposeCandidateAnswer(a)
a := ne.ComposeCandidateAnswer{Node: nodes[0], Candidate: ask.Candidate, Verdict: nec.Composed,
Own: ne.Module{Module: nec.Own, State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("b", 64)}}
raw, err := nec.EncodeAnswer(a)
if err != nil {
t.Error(err)
}
@@ -327,7 +353,7 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []delivery.ComposeCan
t.Fatal(err)
}
}
return func() []delivery.ComposeCandidateAsk {
return func() []ne.ComposeCandidateInput {
mu.Lock()
defer mu.Unlock()
return slices.Clone(asked)
@@ -390,8 +416,20 @@ func startBed(t *testing.T) *bed {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: buildrun.DeadLettersStream, Subjects: []string{"mesh.events.dead.>"}}); err != nil {
t.Fatal(err)
}
// The checker's stream of events, which the indexer and the forge bridge hear.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CHECKS", Subjects: []string{"mesh.stream.mesh-delivery-checker.event.>"}}); err != nil {
// The checker's streams as its manifest declares them (ADR 0619, ADR 0620): checks, which the indexer and the forge
// bridge hear, and asks, with verdict and tag-wanted at the subjects the mesh-delivery-checker definition names.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CHECKS", Subjects: []string{meshdeliverychecker.HolderSubjectPreviewWritten(delivery.CheckerModule)}}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASKS", Subjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule),
meshdeliverychecker.HolderSubjectTagWanted(delivery.CheckerModule)}}); err != nil {
t.Fatal(err)
}
// The shared events stream with the package registry seats' published, and the checker's durable consumer of it.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-checker", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
for name, history := range map[string]int{delivery.CheckerVerdictsBucket: delivery.CheckerVerdictsHistory, delivery.CheckerKeptBucket: delivery.CheckerKeptHistory,
@@ -405,6 +443,12 @@ func startBed(t *testing.T) *bed {
}
b.buckets[name] = kv
}
// The indexer and the assigner as the owners of the states the checker follows: their read tools and views families
// (ADR 0615, DLV-M2).
for _, st := range []string{candidatesState, modulesState, manifestsState, delivery.PackagesState, delivery.IndexerPredefinedState, seatDefinitionsState} {
viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, delivery.IndexerModule+".indexer_read_"+strings.ReplaceAll(st, "-", "_"), indexerViews, "n1_"+delivery.CheckerModule)
}
viewstest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews, "n1_"+delivery.CheckerModule)
// 1929/AC1: the indexer's value of the repository's module, with no TypeScript manifest, so the Go build module alone
// judges the repository (an unknown repository is judged by both, issue 1887 A12).
known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", None2: "the fingerprint is computed with its chain"})
@@ -638,7 +682,7 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300
t.Fatalf("verdict = %+v", v)
}
if c := v.Composition; c == nil || c.State != delivery.CompositionPass || !slices.Equal(c.Machines, []string{"n1"}) ||
!strings.Contains(c.Words, "n2: "+delivery.ComposeNoAnswer) || len(v.Machines) != 1 || v.Machines[0] != "n1" {
!strings.Contains(c.Words, "n2: "+nec.NoAnswer) || len(v.Machines) != 1 || v.Machines[0] != "n1" {
t.Errorf("composition %+v machines %v", v.Composition, v.Machines)
}
if v.Composition != nil && strings.Contains(v.Composition.Words, "did not end") {
@@ -830,6 +874,81 @@ func TestAMemberNamingAMeshSDKCommitOffTheTrunkIsJudgedAndNotRefused(t *testing.
if v := b.verdict(t, key, ended); v.State != delivery.VerdictPass {
t.Fatalf("verdict = %+v", v)
}
// DLV-C3, DLV-C6 (design 51 *Tagged versions*, ADR 0607 §3, §5, ADR 0620): a group whose mesh-sdk member declares a Go
// package, read at its head with no preview yet, says tag-wanted on the checker's stream asks, at the subject and in the body the mesh-delivery-checker
// definition names; once the Go seat's published of the tagged version is heard, the other member is built against it.
const sdkRepo = "novox/mesh-sdk"
b.setTrunk(t, map[string]string{repo: trunk, sdkRepo: trunk})
// The member carries no preview of its merge yet, so its manifest is read at its head (the package new in the group).
b.heads.Store(sdkRepo+" module.json "+head2, `{"module":"mesh-sdk","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`)
asks, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectTagWanted(delivery.CheckerModule)}})
if err != nil {
t.Fatal(err)
}
sdk := pull(sdkRepo, 12, head2)
sdk.Modules, sdk.Fps = []string{"mesh-sdk"}, map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("e", 64)}}
key = b.candidate(t, "feat/x", pull(repo, 8, head1), sdk)
msg, err := asks.Next(jetstream.FetchMaxWait(20 * time.Second))
if err != nil {
t.Fatalf("no tag-wanted on the stream asks: %v", err)
}
var wanted meshdeliverychecker.TagWantedHolderEvent
dec := json.NewDecoder(bytes.NewReader(msg.Data()))
dec.DisallowUnknownFields()
if err := dec.Decode(&wanted); err != nil {
t.Fatalf("tag-wanted %s is not the definition's body: %v", msg.Data(), err)
}
if want := (meshdeliverychecker.TagWantedHolderEvent{Repository: sdkRepo, Branch: "feat/x", Head: head2, Module: "mesh-sdk", Package: "go", Pull: 12}); wanted != want {
t.Errorf("tag-wanted = %+v, want %+v", wanted, want)
}
if v, err := b.buckets[delivery.CheckerVerdictsBucket].Get(b.ctx, key); err == nil && !strings.Contains(string(v.Value()), `"running"`) {
t.Fatalf("the group was judged before its tagged version was published: %s", v.Value())
}
published, _ := json.Marshal(gopackageregistry.PublishedEvent{Holder: "gitea", Kind: "package", Name: "mesh-sdk/go", Module: check.SDKModulePath, Commit: head2, Tag: "feat-x", Version: "0.1.15-feat-x.1",
Digest: "sha256:" + strings.Repeat("0", 64)})
if _, err := b.js.Publish(b.ctx, gopackageregistry.SubjectPublished, published); err != nil {
t.Fatal(err)
}
if v := b.verdict(t, key, ended); v.State != delivery.VerdictPass {
t.Fatalf("the group's verdict = %+v", v)
}
b.fake.mu.Lock()
got := b.fake.packages[repo]
b.fake.mu.Unlock()
if got[check.SDKModulePath] != "v0.1.15-feat-x.1" {
t.Errorf("mesh-delivery was built against %v, want mesh-sdk's Go module at the group's tagged version v0.1.15-feat-x.1", got)
}
// DLV-C2, DLV-C6 (design 26 *Out of step*, ADR 0620): each check's end says one verdict per pull request on the stream
// asks, at the definition's subject and in its body (decoded strictly): the lone pull request's, then the group's two.
verdicts, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule)}})
if err != nil {
t.Fatal(err)
}
said := map[int64]meshdeliverychecker.VerdictHolderEvent{}
for len(said) < 3 {
msg, err := verdicts.Next(jetstream.FetchMaxWait(20 * time.Second))
if err != nil {
t.Fatalf("verdicts said %+v, then none: %v", said, err)
}
var e meshdeliverychecker.VerdictHolderEvent
dec := json.NewDecoder(bytes.NewReader(msg.Data()))
dec.DisallowUnknownFields()
if err := dec.Decode(&e); err != nil {
t.Fatalf("verdict %s is not the definition's body: %v", msg.Data(), err)
}
if _, twice := said[e.Pull]; twice {
t.Errorf("a second verdict of pull %d: %+v", e.Pull, e)
}
said[e.Pull] = e
}
for pull, want := range map[int64]meshdeliverychecker.VerdictHolderEvent{7: {Repository: repo, Pull: 7, Head: head1, Verdict: delivery.VerdictPass},
8: {Repository: repo, Pull: 8, Head: head1, Verdict: delivery.VerdictPass}, 12: {Repository: sdkRepo, Pull: 12, Head: head2, Verdict: delivery.VerdictPass}} {
if got := said[pull]; got.Repository != want.Repository || got.Head != want.Head || got.Verdict != want.Verdict || len(got.OutOfStep) != 0 {
t.Errorf("verdict of pull %d = %+v, want %+v", pull, got, want)
}
}
}
// triesStore is the tries bucket as the tools write it.
@@ -938,7 +1057,9 @@ func TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed(t *testing.T) {
// the readable ones, which is what grants the forge module its read of owed (ADR 0495 §2-§3, ADR 0521; R4e finding R2-1).
func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
b := startBed(t)
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
// The bed's shared events stream carries the registry seats' published; the module events go on it too.
if _, err := b.js.UpdateStream(b.ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>",
gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil {
t.Fatal(err)
}
b.start(t, nil)
@@ -959,7 +1080,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
}
if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") ||
!said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") ||
!said.Keeps("asks") || said.Readable("asks") || len(said.States) != 7 {
t.Fatalf("the checker said %+v, want its seven states, asks and hearing its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
!said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || len(said.States) != 9 {
t.Fatalf("the checker said %+v, want its nine states, asks, hearing and first-views its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
}
}
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -25,6 +26,8 @@ import (
// judgeDeps are the parts the judge of a check is made of.
type judgeDeps struct {
asks check.Asks
renames check.Renames
seatDefs check.SeatDefinitions
tools *buildrun.ToolchainSet
trunks *check.Trunks
kept check.KV
@@ -45,18 +48,16 @@ type judgeDeps struct {
nodes *nodeNames
// predefined is the indexer's kept list of predefined keys; nil reads none.
predefined check.PredefinedList
logf func(format string, args ...any)
// tags says a group's tag-wanted and hears its packages' published (design 51 *Tagged versions*); nil asks none.
tags check.TagAsks
logf func(format string, args ...any)
}
// keptPredefined reads the indexer's key predefined.list (delivery.PredefinedKept), bound once its state appears.
type keptPredefined struct{ b *process.AwaitedBucket }
type keptPredefined struct{ kv jetstream.KeyValue }
func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, error) {
kv := k.b.KV()
if kv == nil {
return nil, process.ErrNotYet
}
e, err := kv.Get(ctx, delivery.PredefinedListKey)
e, err := k.kv.Get(ctx, delivery.PredefinedListKey)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
@@ -70,6 +71,68 @@ func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, er
return kept.List, nil
}
// keptSeatDefinitions reads a pull request's entries of the indexer's seat definitions from its first view: each whose
// `unparsed` says why its head's definition does not parse (the entry's shape is the indexer's read tool's answer).
type keptSeatDefinitions struct {
keys func(ctx context.Context) ([]string, error)
get func(ctx context.Context, key string) ([]byte, error)
}
// seatDefinitionsOf reads the state's picture (a key gone between the keys and its get is none).
func seatDefinitionsOf(kv jetstream.KeyValue) keptSeatDefinitions {
return keptSeatDefinitions{keys: func(ctx context.Context) ([]string, error) {
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}, get: func(ctx context.Context, key string) ([]byte, error) {
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return e.Value(), nil
}}
}
func (k keptSeatDefinitions) Unparsed(ctx context.Context, repository string, pull int) ([]check.UnparsedSeat, error) {
suffix := ".pull." + delivery.EscapeName(repository) + "." + strconv.Itoa(pull)
keys, err := k.keys(ctx)
if err != nil {
return nil, err
}
sort.Strings(keys)
var out []check.UnparsedSeat
for _, key := range keys {
if !strings.HasSuffix(key, suffix) {
continue
}
raw, err := k.get(ctx, key)
if err != nil {
return nil, err
}
if raw == nil {
continue
}
var v struct {
Seat string `json:"seat"`
Module string `json:"module"`
Directory string `json:"directory"`
Unparsed string `json:"unparsed"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("%s is no seat definition entry: %w", key, err)
}
if v.Unparsed != "" {
out = append(out, check.UnparsedSeat{Seat: v.Seat, Module: v.Module, Directory: v.Directory, Why: v.Unparsed})
}
}
return out, nil
}
// nodeNames reads every node's name from the node-engine's have, one key `<node>.node` per node with a record (novox/hq
// ADR 0567 §12), at each ask: a node enrolled since is named in the next check.
type nodeNames struct{ kv jetstream.KeyValue }
@@ -157,7 +220,7 @@ func newJudge(d judgeDeps) check.Judge {
nodes = d.nodes
}
ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Logf: d.logf}
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, Logf: d.logf}
if d.nodes != nil && d.builds != nil && d.repos != nil {
ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf}
}
@@ -288,6 +351,15 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted
r.mu.Unlock()
}
// ModuleDir is a module's directory in its repository as the indexer's modules hold it (check.Examiner.Dirs); false for a
// module the indexer holds no directory of.
func (r *repoModules) ModuleDir(module string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
d, ok := r.path[module]
return d, ok && d != ""
}
// PackagePaths are the paths, relative to the repository, that the TypeScript package artifacts of a repository's modules
// are built from, sorted: the named paths the indexer keeps in each module's value (delivery.Module.ArtifactPaths; only the
// indexer reads them from a manifest, the lead's decision L8), under the module's directory. None for a module whose value
@@ -4,14 +4,18 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-checker/internal/check"
)
type memManifests map[string][]byte
@@ -120,6 +124,14 @@ func TestTheTypeScriptPackagesPathsComeFromTheIndexersModulesNotTheManifest(t *t
// there and computed nowhere here: a key's module at its commit, a deleted key gone, an unreadable value left out.
func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) {
var p publishedPackages
// Not read yet, the state names no package and judges none; read and empty, it is an empty list judged against.
if got := p.PackageNames(); got != nil {
t.Errorf("before the first reading the known packages = %v, want nil", got)
}
p.Answered()
if got := p.PackageNames(); got == nil || len(got) != 0 {
t.Errorf("read and empty, the known packages = %#v, want an empty map", got)
}
put := func(module, repository, dir, commit string) {
key, _ := delivery.PackagesKey(module)
raw, err := delivery.EncodePublishedPackages(delivery.PublishedPackages{Module: module, Repository: repository, Dir: dir,
@@ -142,6 +154,30 @@ func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) {
if got := p.LatestPackages(); len(got) != 1 || got[sdk] == "" {
t.Errorf("after a delete: %v", got)
}
// A series key names its package by its ecosystem name, the judge's known packages (DLV-I14, design 32), and is no
// module's published version.
series, err := delivery.EncodeSeriesPackage(delivery.SeriesPackage{Name: sdk, Package: "mesh-sdk/go", Series: "0.1", At: time.Now().UTC()})
if err != nil {
t.Fatal(err)
}
p.Put(delivery.SeriesPackageKey(sdk, "0.1"), series, false)
if got := p.PackageNames(); !reflect.DeepEqual(got, map[string]string{sdk: "mesh-sdk/go"}) || len(p.LatestPackages()) != 1 {
t.Errorf("the known packages = %v, the latest %v", got, p.LatestPackages())
}
t.Run("a pull request's unparsed seat definitions are its entries of the indexer's seat-definitions", func(t *testing.T) {
state := map[string]string{
"web-api.trunk": `{"seat":"web-api","source":"trunk","digest":"d"}`,
"web-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"web-api","source":"pull","module":"web-contracts","directory":"go","unparsed":"seat.json: bad"}`,
"mail-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"mail-api","source":"pull","digest":"e"}`,
"web-api.pull." + delivery.EscapeName("novox/web") + ".17": `{"seat":"web-api","source":"pull","unparsed":"another pull request's"}`,
}
k := keptSeatDefinitions{keys: func(context.Context) ([]string, error) { return slices.Collect(maps.Keys(state)), nil },
get: func(_ context.Context, key string) ([]byte, error) { return []byte(state[key]), nil }}
got, err := k.Unparsed(context.Background(), "novox/web", 7)
if want := []check.UnparsedSeat{{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: "seat.json: bad"}}; err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("unparsed of novox/web#7 = %+v, %v; want %+v", got, err, want)
}
})
}
// A manifest whose config value falls back to a predefined key (ADR 0348 §7) is read, not logged as unreadable for the
@@ -8,6 +8,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -24,6 +25,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-checker/internal/check"
)
@@ -61,6 +63,26 @@ func durationEnv(getenv func(string) string, name string, def time.Duration) (ti
return d, nil
}
// The checker's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
// first view unanswered past its bound (ADR 0615 §6); and the indexer's states it follows (delivery.CandidatesBucket,
// ModulesBucket, ManifestsBucket).
var (
firstViewsBucket = delivery.CheckerModule + "_" + views.StatusState
findingsBucket = delivery.CheckerModule + "_findings"
)
const (
candidatesState = "candidates"
// seatDefinitionsState is the indexer's state of the seat definitions (design 26), keyed `<seat>.trunk` and
// `<seat>.pull.<escaped repository>.<number>`.
seatDefinitionsState = "seat-definitions"
// The owners' views families, as their manifests' `streams` name them.
indexerViews = "indexer-views"
assignerViews = "assigner-views"
modulesState = "modules"
manifestsState = "manifests"
)
// run is the program: it returns when ctx ends or the bus cannot be used.
// keptStates is every state the checker keeps and which others may read (ADR 0495 §2-§3): owed by the forge module,
// which writes mesh/check from it (ADR 0521), and by the forge bridge; previews by the indexer; verdicts by the gate; tries by the delivery view.
@@ -77,6 +99,10 @@ var keptStates = []announce.State{
// The tries are read by the delivery view, which tells a try's asks from a candidate's by the asks each names (build_usage).
{Name: "tries", Readable: true},
{Name: "verdicts", Readable: true},
// Where its first views of the indexer's and the assigner's states stand (read by checker_first_views alone), and its
// findings of one unanswered past its bound, which the condition keeper observes as it does every reader's (ADR 0615 §6).
{Name: views.StatusState},
{Name: "findings"},
}
func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error {
@@ -90,11 +116,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
defer conn.Close()
// Its own states, the candidates it checks, and the modules and manifests that say which build modules judge a repository:
// a check without one of them would be wrong or unwritten, so each stops the start, naming its grant (issue 1945).
// Its own states: a check without one of them would be unwritten, so each stops the start, naming its grant (issue 1945).
buckets := map[string]jetstream.KeyValue{}
for _, name := range []string{delivery.CheckerVerdictsBucket, delivery.CheckerKeptBucket, delivery.CheckerOwedBucket, delivery.PreviewsBucket,
delivery.CandidatesBucket, delivery.ModulesBucket, delivery.ManifestsBucket, check.TriesBucket} {
check.TriesBucket} {
if buckets[name], err = process.EssentialBucket(ctx, js, "checker", name, "judge or keep a check"); err != nil {
return err
}
@@ -111,6 +136,31 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := caller.Keeps(keptStates...); err != nil {
logger.Printf("what the checker keeps could not be said on the bus, so readers of owed, previews and verdicts are not granted until it is: %v", err)
}
// The candidates it checks, the modules and manifests that say which build modules judge a repository, the published
// packages, the predefined keys and the placement are their owners' states (the indexer's, the assigner's), read by the
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, ADR 0596, DLV-M2).
// An owner that is down never holds the start: the checks start once every first view is answered, and until then the
// unanswered first view is the checker's finding.
reader := &views.ProcessReader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf}
reader.Findings = process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf).KV
// Each by the owner's read tool and views family as the owner's manifest names them.
var followed error
followOwner := func(owner, state, tool, stream string) *views.Picture {
p, err := reader.Follow(owner, state, owner+"."+tool, stream)
followed = errors.Join(followed, err)
return p
}
candidates := followOwner(delivery.IndexerModule, candidatesState, "indexer_read_candidates", indexerViews)
modules := followOwner(delivery.IndexerModule, modulesState, "indexer_read_modules", indexerViews)
manifests := followOwner(delivery.IndexerModule, manifestsState, "indexer_read_manifests", indexerViews)
packages := followOwner(delivery.IndexerModule, delivery.PackagesState, "indexer_read_packages", indexerViews)
predefined := followOwner(delivery.IndexerModule, delivery.IndexerPredefinedState, "indexer_read_predefined", indexerViews)
placement := followOwner(delivery.AssignerModule, delivery.PlacementState, "assigner_read_placement", assignerViews)
// The seventh: the indexer's seat definitions, so a pull request whose head's definition does not parse fails.
seatDefs := followOwner(delivery.IndexerModule, seatDefinitionsState, "indexer_read_seat_definitions", indexerViews)
if followed != nil {
return followed
}
stepBound, err := durationEnv(getenv, "MESH_CHECKER_STEP_BOUND", check.DefaultStepBound)
if err != nil {
@@ -183,7 +233,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
verdicts := &manifestVerdicts{}
compositions := check.EngineCompositions{
Asker: engineAsks{call: caller.CallOn},
Placed: placedNodes{kv: func(at context.Context) (jetstream.KeyValue, error) { return js.KeyValue(at, delivery.PlacementBucket) }}.Placed,
Placed: placedNodes{kv: func(context.Context) (jetstream.KeyValue, error) { return placement, nil }}.Placed,
Bound: answerBound,
Sandbox: seatSandbox{asker: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}},
Verdicts: verdicts.all,
@@ -196,32 +246,42 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
logger.Printf("%s of %s was written and the part that reads it could not be told: %v", event, key, err)
}
}
repos := &repoModules{manifests: kvStore{buckets[delivery.ManifestsBucket]}, logf: logger.Printf, verdicts: verdicts}
repos := &repoModules{manifests: kvStore{manifests}, logf: logger.Printf, verdicts: verdicts}
compositions.Composer = func() string { return repos.CommitOf(check.NodeEngineModule) }
compositions.Manifests = repos
// The indexer's packages state (ADR 0593 §2): followed once the bus has it, so a Go check builds every mesh package at
// its published version; until then a check builds each at the version its go.mod names.
// The indexer's packages state (ADR 0593 §2), so a Go check builds every mesh package at its published version: read
// once the first views are answered, before any check starts (afterFirstViews).
published := &publishedPackages{}
process.AwaitBucket(ctx, js, conn, delivery.PackagesBucket, "a Go check builds each mesh package at the version its go.mod names",
logger.Printf).Ready(func(kv jetstream.KeyValue) {
w, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
if err != nil {
logger.Printf("%s cannot be watched, so a Go check builds each mesh package at the version its go.mod names: %v", delivery.PackagesBucket, err)
return
}
go func() {
_ = follow(ctx, w, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
}()
})
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
// A group's tagged versions (design 51 *Tagged versions*): tag-wanted on the checker's stream asks, and the package
// registry seats' published heard on the checker's durable consumer of the shared events stream. Where that consumer is
// not there yet a group's check asks and waits in vain until its bound, said in its verdict.
tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now}
ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*)
switch cons, err := cred.EventConsumer(ctx, js, process.EventsStream); {
case err != nil:
logger.Printf("the consumer %s_%s on %s does not exist yet, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again: %v",
cred.Node, cred.Module, process.EventsStream, err)
default:
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
if !tags.heardPublished(m) {
_ = m.Ack() // nothing else the checker consumes rides the shared stream
}
}, logger.Printf)
if err != nil {
return err
}
defer cc.Stop()
}
ch.Judge = newJudge(judgeDeps{
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks,
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]},
repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
announce: announce, compositions: compositions,
nodes: openNodeNames(ctx, js, logger.Printf),
predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState,
"a change's manifests are judged with no predefined keys, each fallback as its value declares it", logger.Printf)},
announce: announce, compositions: compositions, tags: tags,
nodes: openNodeNames(ctx, js, logger.Printf),
predefined: keptPredefined{predefined},
renames: seatRenames{call: caller.Call},
seatDefs: seatDefinitionsOf(seatDefs),
})
ch.Announce = announce
tryer.Judge = ch.Judge
@@ -232,10 +292,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// placement is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, ch.NodesChanged, logger.Printf)
if err != nil {
return err
}
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
placementErrs := make(chan error, 1)
go func() {
select {
case <-ctx.Done():
return
case <-placement.Follower().Ready():
}
pe, err := buildrun.FollowPlacement(ctx, reader, gate, ch.NodesChanged, logger.Printf)
if err != nil {
placementErrs <- err
return
}
placementErrs <- <-pe
}()
if err := watch.start(); err != nil {
return err
}
@@ -318,65 +389,90 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return nil
}
lazy.ensure()
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
mw, err := settle(ctx, buckets[delivery.ModulesBucket], func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
go func() {
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
before := repos.CommitOf(check.NodeEngineModule)
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
if after := repos.CommitOf(check.NodeEngineModule); after != before {
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
}
})
}()
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.Candidate(ctx, e.Key(), e.Value())
} else {
ch.Forget(e.Key())
afterFirstViews := func() error {
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
pw, err := settle(ctx, packages, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
}
w, err := settle(ctx, buckets[delivery.CandidatesBucket], each)
if err != nil {
return err
}
// A key left running for heads no candidate holds is closed; the candidates are all known now.
if err := ch.Sweep(ctx); err != nil {
return err
}
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
if err != nil {
return err
}
tryer.Reconsider(ctx)
go func() {
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
published.Answered() // read whole, an empty state too: its names are judged from here on
go func() {
errs <- follow(ctx, pw, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
}()
mw, err := settle(ctx, modules, func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
go func() {
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
before := repos.CommitOf(check.NodeEngineModule)
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
if after := repos.CommitOf(check.NodeEngineModule); after != before {
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
}
})
}()
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
tryer.Put(ctx, e.Key(), e.Value())
}
})
}()
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
if err != nil {
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
}
go func() {
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.VerdictWritten(ctx, e.Key(), e.Value())
ch.Candidate(ctx, e.Key(), e.Value())
} else {
ch.VerdictDeleted(ctx, e.Key())
ch.Forget(e.Key())
}
})
}
w, err := settle(ctx, candidates, each)
if err != nil {
return err
}
// A key left running for heads no candidate holds is closed; the candidates are all known now.
if err := ch.Sweep(ctx); err != nil {
return err
}
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
if err != nil {
return err
}
tryer.Reconsider(ctx)
go func() {
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
tryer.Put(ctx, e.Key(), e.Value())
}
})
}()
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
if err != nil {
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
}
go func() {
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.VerdictWritten(ctx, e.Key(), e.Value())
} else {
ch.VerdictDeleted(ctx, e.Key())
}
})
}()
go func() { errs <- follow(ctx, w, each) }()
return nil
}
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "checker_first_views reads nothing and checker_first_view_again is not heard", logger.Printf))
// The checks, the tries and the hand asks, once every first view is answered (the lead's ruling for DLV-M2): a check
// judged without the candidates, the modules, the manifests, the published packages, the predefined keys or the
// placement would be another check.
go func() {
select {
case <-ctx.Done():
return
case <-reader.Ready():
}
if err := afterFirstViews(); err != nil {
errs <- err
}
}()
logger.Printf("started on %s", cred.Node)
go func() { errs <- follow(ctx, w, each) }()
select {
case <-ctx.Done():
ch.Wait()
@@ -10,10 +10,24 @@ import (
// module, the latest published commit of its repository the indexer read its packages at. The indexer alone computes a
// package's version; the checker reads it, so a Go check builds every mesh package at the version the mesh publishes
// (check.Examiner.Published) and computes none.
//
// Its series keys (delivery.SeriesPackage, decoded with delivery.DecodeSeriesPackage) name each package by its name in its
// own ecosystem and the `<module>/<artifact>` it is built from (design 32): the known packages the judge takes
// (PackageNames).
type publishedPackages struct {
mu sync.Mutex
byKey map[string]delivery.PublishedPackages
series map[string]delivery.SeriesPackage
unread map[string]string
// answered is whether the state's first reading ended (Answered): until then the checker knows no package's name.
answered bool
}
// Answered marks the state's first reading as ended, so PackageNames names what it holds, none included.
func (p *publishedPackages) Answered() {
p.mu.Lock()
defer p.mu.Unlock()
p.answered = true
}
// Put takes one key of the state in: its value, or its deletion. A value that does not decode is kept out and remembered
@@ -22,13 +36,23 @@ func (p *publishedPackages) Put(key string, raw []byte, deleted bool) {
p.mu.Lock()
defer p.mu.Unlock()
if p.byKey == nil {
p.byKey, p.unread = map[string]delivery.PublishedPackages{}, map[string]string{}
p.byKey, p.series, p.unread = map[string]delivery.PublishedPackages{}, map[string]delivery.SeriesPackage{}, map[string]string{}
}
delete(p.byKey, key)
delete(p.series, key)
delete(p.unread, key)
if deleted {
return
}
if delivery.IsSeriesPackageKey(key) {
s, err := delivery.DecodeSeriesPackage(raw)
if err != nil {
p.unread[key] = err.Error()
return
}
p.series[key] = s
return
}
v, err := delivery.DecodePublishedPackages(raw)
if err != nil {
p.unread[key] = err.Error()
@@ -51,3 +75,21 @@ func (p *publishedPackages) LatestPackages() delivery.PackageVersions {
}
return out
}
// PackageNames are the packages the state's series keys name, by the name in its own ecosystem: the `<module>/<artifact>`
// each is built from (empty when the key does not say). Nil while the state is not read yet (Answered), which judges no
// name; once read never nil, an empty state included, so the judge judges every name against it.
func (p *publishedPackages) PackageNames() map[string]string {
p.mu.Lock()
defer p.mu.Unlock()
if !p.answered {
return nil
}
out := map[string]string{}
for _, s := range p.series {
if out[s.Name] == "" {
out[s.Name] = s.Package
}
}
return out
}
@@ -0,0 +1,36 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"mesh-delivery-checker/internal/check"
)
// seatRenames asks the seat mesh-config's verb renames as the checker's own account (its `invokes`
// seat:mesh-config.renames; design 32). A call nobody answers is a holder serving no renames (the verb is optional), said
// as check.ErrRenamesNotServed with the bus's words; any other error is an ask that could not be made.
type seatRenames struct {
call func(key string, body any) (json.RawMessage, error)
}
func (s seatRenames) Renames(_ context.Context, module string) (meshconfig.RenamesAnswer, error) {
raw, err := s.call(check.RenamesSeat, meshconfig.RenamesInput{Module: module})
if errors.Is(err, nats.ErrNoResponders) {
return meshconfig.RenamesAnswer{}, fmt.Errorf("%w (nothing answers it: %v)", check.ErrRenamesNotServed, err)
}
if err != nil {
return meshconfig.RenamesAnswer{}, err
}
var a meshconfig.RenamesAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return meshconfig.RenamesAnswer{}, fmt.Errorf("%s answered what is not its answer: %w", check.RenamesSeat, err)
}
return a, nil
}
@@ -8,7 +8,7 @@ import (
"os"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-checker/internal/check"
@@ -28,7 +28,7 @@ type settings struct {
// readSettings reads the settings file at path: each value's default where no file or no value is there; a value that is
// no whole number of seconds within its range is refused, naming it.
func readSettings(path string) (settings, error) {
out := settings{answer: delivery.ComposeAnswerBound, buildWait: check.DefaultBuildWait}
out := settings{answer: nec.AnswerBound, buildWait: check.DefaultBuildWait}
if path == "" {
return out, nil
}
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-checker/internal/check"
@@ -22,7 +22,7 @@ func TestTheAnswerBoundIsTheCheckersSetting(t *testing.T) {
want time.Duration
err bool
buildWait time.Duration
}{{"", delivery.ComposeAnswerBound, false, check.DefaultBuildWait}, {`{}`, delivery.ComposeAnswerBound, false, check.DefaultBuildWait},
}{{"", nec.AnswerBound, false, check.DefaultBuildWait}, {`{}`, nec.AnswerBound, false, check.DefaultBuildWait},
{`{"compose-answer-bound": 90}`, 90 * time.Second, false, check.DefaultBuildWait}, {`{"compose-answer-bound": 0}`, 0, true, 0},
{`{"compose-answer-bound": 1.5}`, 0, true, 0},
{`{"compose-answer-bound": 60, "build-wait-bound": 600}`, 60 * time.Second, false, 10 * time.Minute},
@@ -0,0 +1,98 @@
package main
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-checker/internal/check"
)
// publishedSeats are the two package registry seats' published, by subject (their contracts in mesh-contracts): heard on
// the checker's durable consumer of the shared events stream, made from the manifest's consumes.
var publishedSeats = map[string]string{
gopackageregistry.SubjectPublished: gopackageregistry.Seat,
npmpackageregistry.SubjectPublished: npmpackageregistry.Seat,
}
// tagAsks is check.TagAsks and check.VerdictSayer on the bus: tag-wanted and verdict published on the checker's own stream
// asks, and the published heard.
type tagAsks struct {
js jetstream.JetStream
node string
heard *check.PublishedHeard
logf func(string, ...any)
now func() time.Time
nextID func() string
}
// Want publishes one tag-wanted with the checker's event headers; the message id makes a second publish of the same
// member's head and package within the stream's duplicate window one event.
func (t tagAsks) Want(ctx context.Context, w check.TagWanted) error {
body, err := json.Marshal(w)
if err != nil {
return err
}
msg := nats.NewMsg(eventSubject(check.EventTagWanted))
id := fmt.Sprintf("tag-wanted.%s.%s.%s.%s", w.Repository, w.Head, w.Module, w.Package)
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", delivery.CheckerModule)
msg.Header.Set("x-node", t.node)
msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = body
_, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
return err
}
// Say publishes one verdict on the checker's own stream asks with the checker's event headers; the message id makes a
// second publish of the same pull request's head, verdict and out-of-step files within the duplicate window one event.
func (t tagAsks) Say(ctx context.Context, v check.VerdictEvent) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
msg := nats.NewMsg(eventSubject(check.EventVerdict))
id := fmt.Sprintf("verdict.%s.%d.%s.%s.%x", v.Repository, v.Pull, v.Head, v.Verdict, sha256.Sum256([]byte(strings.Join(v.OutOfStep, "\n"))))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", delivery.CheckerModule)
msg.Header.Set("x-node", t.node)
msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = body
_, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
return err
}
func (t tagAsks) Await(ctx context.Context, seat, head string, names []string) (check.TaggedPublished, bool) {
return t.heard.Await(ctx, seat, head, names)
}
// heardPublished takes a message of the events consumer in when it is a package registry seat's published, and answers
// whether it was: a tagged version wakes the checks waiting on it; any other published is passed over.
func (t tagAsks) heardPublished(m jetstream.Msg) bool {
seat, ok := publishedSeats[m.Subject()]
if !ok {
return false
}
var p check.TaggedPublished
if err := json.Unmarshal(m.Data(), &p); err != nil {
t.logf("%s holds what is no published of %s, left: %v", m.Subject(), seat, err)
} else if t.heard.Heard(seat, p) {
t.logf("%s published %s %s from %s (tag %s)", seat, p.Name, p.Version, p.Commit, p.Tag)
}
if err := m.Ack(); err != nil {
t.logf("%s cannot be acknowledged, so the bus gives it again: %v", m.Subject(), err)
}
return true
}
+22 -6
View File
@@ -3,29 +3,45 @@ module mesh-delivery-checker
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 // indirect
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 // indirect
)
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+38 -12
View File
@@ -1,17 +1,43 @@
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459 h1:DA4SFG/gKyG+aPJbfUpAUad2VeITIiKjJ6qOPmDhSA8=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459/go.mod h1:zR1HsAKZVbQsBIY5wPyrqI2WHInK268tITzGl2oqHro=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:I6uVDO9E+i3D9/n6KQ2RWYRs3kXYCqueiKXynifQo8w=
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:ER2NVrd81uJo1lN0YCGA7OBxL910IHRgWYtxpsnHGF0=
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:Rs5jOa7EWlpKuaH6+ExyGLN9k9UUi8N7upP9Ac3k+GY=
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:fNfjFkP1+suYb12HJkapQe5W3cF98JsTcLBI/syoLHc=
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8=
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs=
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:VG+sZltyBIKrd9cwFJyKkInz04SxWBxUlJWfAuo6774=
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Nq7glLZSZ8zOLTh1pXDnKZi+MxsMRyHw/bZODC3mvrY=
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 h1:ROS9UG/Dg0NpsaDuEmDFV9CsIC1R92sOm3QauF7IvL0=
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/Pke1v2VnSFcR8ic+fo/kNCg3JFYK6ILTal72Ca9520=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639 h1:oAXU1t8A1rGIr82JPBaNYSGLicXqhWglKaEAtPmrD0c=
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:Y5kgBh0hBbL/hgc+abCF9Vbhov+vCsB0ckpRzUw1D+0=
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639 h1:G211QsOsnL9e8WJjhd9sE8hTRxs/UZlS3hT0M0faJG0=
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639/go.mod h1:n0f3CXlnvKt2qAPjQu+FTBnGjZuwd9E1xXy7/GsiauQ=
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639 h1:Khu2mBhPbQhUQPNpqIb18mH7mUFW6GQvSjEf5lM/uDU=
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639/go.mod h1:Fr+FPZ5xC57oQlL9jnfHBz0yKhDn8Cw7w56SU8lIeDw=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639 h1:ILh7uctNwgx/89qHoR/H2kz5TpWeKEeIH0O2pDPE7Iw=
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:JRtTjIjMTkzn59IN4vCYei9ZwJGFnN1Puwame4V/sD0=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -25,5 +51,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
@@ -45,7 +45,11 @@ type Case struct {
notesMu sync.Mutex
// warnings are, by pull request, what part of its check did not run (Case.warn).
warnings map[string]string
notes []string
// seriesOf are, by pull request, the changes of a package series it makes (series.go), named in its status line.
seriesOf map[string][]string
// seriesChanges are, by pull request, the same changes as structures (series.go), named in the verdict and its event.
seriesChanges map[string][]delivery.SeriesChange
notes []string
// asks are the ids of the asks the check put on the build seats' streams, in order: a try names them (Try.Asks).
asks []string
// Watch, when set, hears each ask the check puts, as it is put (step nil), and each value its step key takes while
@@ -48,6 +48,9 @@ type Outcome struct {
// Beside are the siblings the check read at their trunk's head, by repository (ADR 0548 §6): the verdict names them, and
// a move of one is carried.
Beside map[string]string
// OutOfStep are, by pull request (pullKey), the generated files out of step with their seat definitions that its steps
// named (design 26): the verdict event carries them.
OutOfStep map[string][]string
}
// BuildNote is the state of a candidate's module build, by module: built, failed or waiting (ADR 0484 clause 7).
@@ -90,8 +93,10 @@ type Checker struct {
Link string
// Announce says a status was written (EventOwedWritten); nil says nothing.
Announce Announcer
Now func() time.Time
Logf func(format string, args ...any)
// Says publishes the verdict event of each pull request whose check ended (EventVerdict); nil says none.
Says VerdictSayer
Now func() time.Time
Logf func(format string, args ...any)
// Settle is how long a group's heads must stay the same after the candidate last changed them before its check starts
// (issue 1890): the members of a group are announced one after another, and a check of each partial set is thrown
// away at the next. None when 0; DefaultSettle in the module.
@@ -888,6 +893,7 @@ func (ch *Checker) try(ctx, run context.Context, key string, cs *Case) {
return
}
ch.writeOwed(ctx, cs, v, out)
ch.sayVerdict(ctx, cs, v, out)
ch.logf("%s: %s", cs.Key, v.State)
}
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -430,9 +431,13 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
raw, _, _, _ := r.owed.Get(ctx, key)
o, _ := delivery.DecodeCheckerOwed(raw)
seen = append(seen, o.State)
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}}, nil
c.series(repoA+"#7", []string{"web/pkg series 0.1 → 0.2"}, []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}})
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}},
OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil
}}
r = newRig(t, j)
says := &verdictsSaid{}
r.ch.Says = says
r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))
r.ch.Wait()
key, _ := delivery.CheckerOwedKey(repoA, 7, head1)
@@ -444,6 +449,27 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
if len(seen) != 1 || seen[0] != "pending" || o.State != "failure" || o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Target, "https://view.invalid/checks/") {
t.Errorf("while running %v, at the end %+v", seen, o)
}
// DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step
// files; the running status says none.
if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 ||
says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" ||
// The join (A2j finding 2): and its series changes, as the seat's event names them.
len(says.said[0].Series) != 1 || says.said[0].Series[0] != (meshdeliverychecker.VerdictHolderEventSeriesItem{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}) {
t.Errorf("verdict events %+v", says.said)
}
}
// verdictsSaid is a VerdictSayer that keeps what it was told.
type verdictsSaid struct {
mu sync.Mutex
said []VerdictEvent
}
func (s *verdictsSaid) Say(_ context.Context, v VerdictEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
s.said = append(s.said, v)
return nil
}
// No check of heads no candidate holds is left running (issue 1890): at start the sweep ends a key left running; while the
@@ -9,6 +9,8 @@ import (
"sync"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/composer"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -23,12 +25,12 @@ import (
//
// **A group that changes the node-engine's composition code** (a member of the node-engine's repository) is not judged by
// the running engines, which cannot judge their own next code: each node's engine answers the composition input it
// composed from (ComposeCandidateAsk.Input), and the composer program built from that member's head composes it in a
// composed from (ne.ComposeCandidateInput.Input), and the composer program built from that member's head composes it in a
// build module's sandbox (Sandbox), with every manifest of the catalogue as the case leaves it and the indexer's verdicts.
// EngineAsker asks one node's engine to compose a check's candidate (the verb node-engine.compose-candidate on that node).
type EngineAsker interface {
ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error)
ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error)
}
// Sandbox runs a compose job on the Go build seat and answers the composer's verdict.
@@ -42,7 +44,7 @@ type EngineCompositions struct {
// Placed names every node in placement now (the seat mesh-placement's state), or why it cannot be read: then no node
// can be asked, and the composition is not made (an error), never passed with nobody asked.
Placed func() ([]string, error)
// Bound is the answer bound (the setting compose-answer-bound); zero is delivery.ComposeAnswerBound.
// Bound is the answer bound (the setting compose-answer-bound); zero is nec.AnswerBound.
Bound time.Duration
// Composer is the commit of the node-engine the mesh runs, named as what composed (ADR 0548 §6's running commit).
Composer func() string
@@ -78,11 +80,11 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
}
bound := e.Bound
if bound <= 0 {
bound = delivery.ComposeAnswerBound
bound = nec.AnswerBound
}
head := composerHead(c)
sandboxed := head != "" && e.Sandbox != nil
answers := map[string]delivery.ComposeCandidateAnswer{}
answers := map[string]ne.ComposeCandidateAnswer{}
silent := map[string]string{}
var mu sync.Mutex
var wg sync.WaitGroup
@@ -92,13 +94,13 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
defer wg.Done()
at, cancel := context.WithTimeout(ctx, bound)
defer cancel()
a, err := e.Asker.ComposeCandidate(at, node, delivery.ComposeCandidateAsk{Candidate: key, Input: sandboxed})
if err == nil && sandboxed && a.Verdict != delivery.ComposeCandidateUnready {
a, err := e.Asker.ComposeCandidate(at, node, ne.ComposeCandidateInput{Candidate: key, Input: sandboxed})
if err == nil && sandboxed && a.Verdict != nec.Unready {
// The composer's build and run are the check's to bound, not the engine's answer bound. A composer that
// cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming
// the head.
if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil {
a = delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateRefused,
a = ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Refused,
Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()}
} else {
a = composed
@@ -109,7 +111,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
switch {
case err != nil:
silent[node] = err.Error()
case a.Verdict == delivery.ComposeCandidateUnready:
case a.Verdict == nec.Unready:
silent[node] = "unready"
default:
answers[node] = a
@@ -135,8 +137,8 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's
// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine);
// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named
// with delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure.
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
// with nec.NoAnswer as a warning. composer names what composed, for the line. Pure.
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]ne.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.CompositionPass, SDK: composerName}
sorted := append([]string(nil), nodes...)
sort.Strings(sorted)
@@ -144,17 +146,17 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
for _, node := range sorted {
a, answered := answers[node]
if !answered {
v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer)
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer)
continue
}
if a.Verdict == delivery.ComposeCandidateNotJudged {
if a.Verdict == nec.NotJudged {
// Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the
// verdict, never by the words (ADR 0601 §4's kind).
v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")")
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer+" ("+a.Refused+")")
continue
}
v.Machines = append(v.Machines, node)
if a.Verdict != delivery.ComposeCandidateRefused {
if a.Verdict != nec.Refused {
continue
}
if v.Machine == "" {
@@ -164,8 +166,8 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
if a.Refused != "" {
words = append(words, a.Refused)
}
for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) {
if m.State == delivery.ModuleRefusedState {
for _, m := range append(append([]ne.Module(nil), a.Modules...), a.Own) {
if m.State == nec.ModuleRefused {
words = append(words, m.Module+": "+m.Words)
}
}
@@ -186,7 +188,7 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
// inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the
// node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the
// composer refuses the node.
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a delivery.ComposeCandidateAnswer) (delivery.ComposeCandidateAnswer, error) {
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
if len(a.Input) == 0 {
return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node)
}
@@ -214,7 +216,13 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
delete(verdicts, name) // a manifest the change brings is the checker's own judge's (ADR 0578 §2)
}
}
req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node},
// The answer carries its input as the object the seat's contract names, decoded with its numbers as written
// (json.Number), so encoding it again gives the composer the same values the engine sent.
input, err := json.Marshal(a.Input)
if err != nil {
return a, fmt.Errorf("the engine of %s answered a composition input that cannot be encoded again: %w", node, err)
}
req := composer.Request{V: composer.Version, Input: input, Depth: composer.DepthCompose, Machines: []string{node},
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}}
raw, err := json.Marshal(req)
if err != nil {
@@ -226,25 +234,25 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
if err != nil {
return a, fmt.Errorf("the composer at the node-engine's head %s did not answer for %s: %w", head, node, err)
}
out := delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateComposed}
out := ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Composed}
notJudged := ""
for _, cv := range verdict.Cases {
if m, ok := cv.Machines[node]; ok && m.NotJudged != "" {
notJudged = m.NotJudged // the composer did not judge the node: neither composed nor refused (ADR 0593 §9)
} else if ok && !m.Composes {
out.Verdict = delivery.ComposeCandidateRefused
out.Verdict = nec.Refused
out.Refused = strings.Join(append(append([]string{m.Reason}, m.Problems...), m.Refusals...), "; ")
}
for name, read := range cv.Manifests {
if !read.Accepted {
out.Verdict = delivery.ComposeCandidateRefused
out.Modules = append(out.Modules, delivery.ModuleComposed{Module: name, State: delivery.ModuleRefusedState, Words: read.Refused})
out.Verdict = nec.Refused
out.Modules = append(out.Modules, ne.Module{Module: name, State: nec.ModuleRefused, Words: read.Refused})
}
}
}
sort.Slice(out.Modules, func(i, j int) bool { return out.Modules[i].Module < out.Modules[j].Module })
if notJudged != "" && out.Verdict == delivery.ComposeCandidateComposed {
out.Verdict, out.Refused = delivery.ComposeCandidateNotJudged, notJudged
if notJudged != "" && out.Verdict == nec.Composed {
out.Verdict, out.Refused = nec.NotJudged, notJudged
}
return out, nil
}
@@ -9,6 +9,8 @@ import (
"testing"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/composer"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -16,26 +18,26 @@ import (
// fakeEngines answers compose-candidate by node from memory; a node it holds no answer for never answers.
type fakeEngines struct {
mu sync.Mutex
answers map[string]delivery.ComposeCandidateAnswer
answers map[string]ne.ComposeCandidateAnswer
errs map[string]error
asked map[string]delivery.ComposeCandidateAsk
asked map[string]ne.ComposeCandidateInput
}
func (f *fakeEngines) ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error) {
func (f *fakeEngines) ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error) {
f.mu.Lock()
if f.asked == nil {
f.asked = map[string]delivery.ComposeCandidateAsk{}
f.asked = map[string]ne.ComposeCandidateInput{}
}
f.asked[node] = ask
a, ok := f.answers[node]
err := f.errs[node]
f.mu.Unlock()
if err != nil {
return delivery.ComposeCandidateAnswer{}, err
return ne.ComposeCandidateAnswer{}, err
}
if !ok {
<-ctx.Done()
return delivery.ComposeCandidateAnswer{}, ctx.Err()
return ne.ComposeCandidateAnswer{}, ctx.Err()
}
return a, nil
}
@@ -72,10 +74,10 @@ func (d digests) ManifestDigests() map[string]string { return d }
// warning, decided by the verdict and never by its words; a group with a node-engine member is composed by the composer
// built at that member's head, over the input each node's engine composed from.
func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
composed := func(node string) delivery.ComposeCandidateAnswer {
return delivery.ComposeCandidateAnswer{Node: node, Verdict: delivery.ComposeCandidateComposed,
Modules: []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("a", 64)}},
Own: delivery.ModuleComposed{Module: "node", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("b", 64)}}
composed := func(node string) ne.ComposeCandidateAnswer {
return ne.ComposeCandidateAnswer{Node: node, Verdict: nec.Composed,
Modules: []ne.Module{{Module: "web", State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("a", 64)}},
Own: ne.Module{Module: nec.Own, State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("b", 64)}}
}
cs := caseOf(t, pullOf(repoA, 7, head1))
key, _ := delivery.CompositionKey(cs.Group, cs.HeadsHash)
@@ -92,10 +94,10 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) {
refusing := composed("laptop")
refusing.Verdict = delivery.ComposeCandidateRefused
refusing.Modules = []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleRefusedState, Words: "a required value is unset"},
{Module: "mail", State: delivery.ModuleHeldState, Words: "held as today"}}
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "laptop": refusing}}
refusing.Verdict = nec.Refused
refusing.Modules = []ne.Module{{Module: "web", State: nec.ModuleRefused, Words: "a required value is unset"},
{Module: "mail", State: nec.ModuleHeld, Words: "held as today"}}
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": composed("anchor"), "laptop": refusing}}
v := compose(e, "anchor", "laptop")
if v.State != delivery.CompositionFail || v.Machine != "laptop" || !strings.Contains(v.Words, "not composed on laptop: web: a required value is unset") ||
strings.Contains(v.Words, "mail") || len(v.Machines) != 2 {
@@ -112,8 +114,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
})
t.Run("a silent, unready or unreachable node is no answer, and the check passes naming it", func(t *testing.T) {
unready := delivery.ComposeCandidateAnswer{Node: "g14", Verdict: delivery.ComposeCandidateUnready, Refused: "a module refused it"}
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "g14": unready},
unready := ne.ComposeCandidateAnswer{Node: "g14", Verdict: nec.Unready, Refused: "a module refused it"}
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": composed("anchor"), "g14": unready},
errs: map[string]error{"ace": errors.New("no responders")}}
began := time.Now()
v := compose(e, "anchor", "laptop", "g14", "ace")
@@ -124,14 +126,14 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
t.Fatalf("verdict = %+v", v)
}
for _, node := range []string{"ace", "g14", "laptop"} {
if !strings.Contains(strings.Join(v.Warnings, "; "), node+": "+delivery.ComposeNoAnswer) {
if !strings.Contains(strings.Join(v.Warnings, "; "), node+": "+nec.NoAnswer) {
t.Errorf("%s is not named: %v", node, v.Warnings)
}
}
var ver delivery.Verdict
ver.State = delivery.VerdictPass
withComposition(&ver, v)
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) {
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+nec.NoAnswer) {
t.Errorf("the line = %q", composition(ver.Composition, 0, false))
}
})
@@ -143,8 +145,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
}
host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA})
withInput := composed("anchor")
withInput.Input = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`)
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}}
withInput.Input = map[string]any{"nodes": []any{map[string]any{"name": "anchor"}}}
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": withInput}}
sb := &fakeSandbox{v: func(node string) composer.Verdict {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change",
Machines: map[string]composer.Machine{node: {Composes: false, Reason: "the new composer refuses it"}}}}}
@@ -162,7 +164,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
var req composer.Request
_ = json.Unmarshal(job.Request, &req)
if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package ||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" {
string(req.Input) != `{"nodes":[{"name":"anchor"}]}` || req.Cases[0].Manifests["web"].Digest == "" {
t.Errorf("job = %+v, request %+v", job, req)
}
if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
@@ -182,7 +184,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}}
}
v, err = ec.Compose(context.Background(), host)
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)")
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+nec.NoAnswer+" (its node-engine sends no running state for web)")
if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass {
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
}
@@ -55,6 +55,19 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
t.Errorf("%s: the verdict is not written: %v", name, err)
}
}
// Design 26 *Out of step* (DLV-C2): the out-of-step files the compare step names, in each result, are the outcome's for
// the pull request, each once and sorted; a level naming none leaves the outcome with none.
step := func(files ...string) json.RawMessage {
raw, _ := json.Marshal(Tested{Level: delivery.LevelUnit, Tests: 1, Passed: false, Why: "out of step", OutOfStep: files})
return raw
}
_, st := judgeLevel(t, step("go/seats/b/contract.go", "go/seats/a/contract.go"), step("go/seats/a/contract.go"))
if got := st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep["novox/x#0"]; strings.Join(got, ",") != "go/seats/a/contract.go,go/seats/b/contract.go" {
t.Errorf("out of step %v", got)
}
if _, st := judgeLevel(t, failingResult(t, 2, a)); st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep != nil {
t.Errorf("a level naming no out-of-step file has %v", st.outOfStep)
}
}
// Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many
@@ -19,6 +19,7 @@ import (
"unicode/utf8"
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/composer"
@@ -1035,8 +1036,35 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
// mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6).
cycle string
trunkCycle bool
// series is the series of web's package the change raises it to, and the words its status line names (DLV-C4).
series, seriesWords string
// renames is what the change does to web's renamed-from ("bring", "drop") and what the seat mesh-config's verb
// renames answers ("served", "open" on g14, "not served", "unasked" with no seat to ask, "error"); DLV-C5.
renames, seat string
// unparsed is the indexer's words of why the pull request's head's seat definition of web-api does not parse.
unparsed string
// known are the judge's known packages the request hands it (DLV-I14, design 32): the packages state's names and,
// of web's package new in the group, the name its go.mod says at the member's head.
known []string
// emptyState is a packages state answered and empty, no member declaring a package: the judge is handed an empty
// list, never nil, so every name a manifest's packages names is refused (review round 1, DLV-I14).
emptyState bool
}{
{name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input",
fails: "novox/mesh-delivery#7: the seat definition of web-api (module web-contracts, go) does not parse: seats/web-api/seat.json: unexpected end of JSON input"},
{name: "a rename brought, the verb not served", renames: "bring", seat: "not served",
fails: "web (novox/mesh-delivery#7) renames new-key from old-key: refused, the holder of the seat mesh-config does not serve its verb renames"},
{name: "a rename brought, the verb served", renames: "bring", seat: "served"},
{name: "a renamed-from dropped while its window is open", renames: "drop", seat: "open",
fails: "web (novox/mesh-delivery#7) drops renamed-from old-key of new-key while its rename window is open on g14"},
{name: "a renamed-from dropped once its window closed", renames: "drop", seat: "served"},
{name: "a rename the check cannot ask of", renames: "bring", seat: "error", fails: "the check cannot ask seat:mesh-config.renames: the bus said no"},
{name: "a rename with no seat to ask", renames: "drop", seat: "unasked", fails: "this checker cannot ask seat:mesh-config.renames"},
{name: "accepted"},
{name: "a package series raised", series: "0.2", seriesWords: "web/pkg series 0.1 → 0.2"},
{name: "a package series kept", series: "0.1"},
{name: "a package new in the group", series: "0.1", known: []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/web/go"}},
{name: "an answered packages state holding no name", emptyState: true, known: []string{}},
{name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"},
{name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps",
fails: "refuses the manifest of mail: mail reads a state web no longer keeps"},
@@ -1077,6 +1105,48 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed}
}
if row.series != "" {
pkg := func(series string) *catalogue.Build {
return &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Series: series}}}
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web", Build: pkg("")},
web: {Module: "web", Build: pkg(row.series)}}
}
if row.known != nil {
b.ex.KnownPackages = knownNames{"git.novox.be/novox/mesh-sdk/go": "mesh-sdk/go"}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"}, web: {Module: "web",
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}}}}}}
b.ex.Forge = headFiles{repoA + " modules/web/go/go.mod " + head1: "module git.novox.be/novox/web/go\n\ngo 1.26\n"}
}
if row.emptyState {
b.ex.KnownPackages = knownNames{}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"}, web: {Module: "web"}}
}
if row.renames != "" {
renamed := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString, RenamedFrom: "old-key"}}
plain := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString}}
trunkWeb, headWeb := catalogue.Manifest{Module: "web", Settings: plain}, catalogue.Manifest{Module: "web", Settings: renamed}
if row.renames == "drop" {
trunkWeb, headWeb = headWeb, trunkWeb
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): trunkWeb, web: headWeb}
// The verb faked from its definition: the answer of the seat's fixture (mesh-contracts seats/mesh-config).
fake := fakeRenames{}
switch row.seat {
case "not served":
fake.err = fmt.Errorf("%w (nothing answers it)", ErrRenamesNotServed)
case "error":
fake.err = errors.New("the bus said no")
case "open":
fake.answer = meshconfig.RenamesAnswer{Renames: []meshconfig.RenamesAnswerRenamesItem{{Module: "web", From: "old-key", To: "new-key", At: "2026-11-04T12:00:00Z", Open: []string{"g14"}}}}
}
if row.seat != "unasked" {
b.ex.Renames = fake
}
}
if row.unparsed != "" {
b.ex.SeatDefinitions = fakeSeatDefinitions{"novox/mesh-delivery#7": {{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: row.unparsed}}}
}
var judged []delivery.ComposeJob
answer := b.asks.Answer
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
@@ -1094,7 +1164,19 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
p := pullOf(repoA, 7, head1)
p.Modules = []string{"web"}
p.Fps = map[string]delivery.CandidateModule{"web": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
v := b.judge(t, caseOf(t, p)).Verdict
cs := caseOf(t, p)
v := b.judge(t, cs).Verdict
if line := Line(v, cs, cs.Pulls[0], nil, nil, nil); row.seriesWords != "" && !strings.Contains(line, row.seriesWords) || row.seriesWords == "" && strings.Contains(line, "series") {
t.Errorf("%s: the line %q, want it to name %q", row.name, line, row.seriesWords)
}
// The join (A2j finding 2): the verdict names each series change as {module, artifact, from, to}.
if want := []delivery.SeriesChange(nil); row.series == "0.2" {
if want = []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}}; !reflect.DeepEqual(v.Series, want) {
t.Errorf("%s: the verdict's series %+v, want %+v", row.name, v.Series, want)
}
} else if len(v.Series) != 0 {
t.Errorf("%s: the verdict's series %+v, want none", row.name, v.Series)
}
if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk ||
judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir {
t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v)
@@ -1106,6 +1188,10 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
req.Cases[0].Manifests["mail"].Digest != strings.Repeat("a", 64) {
t.Fatalf("%s: the request %+v: %v", row.name, req, err)
}
if !reflect.DeepEqual(req.Packages, row.known) || len(req.Input) != 0 && string(req.Input) != "null" {
t.Errorf("%s: the judge's known packages %v and input %s, want the known packages %v and no input", row.name,
req.Packages, req.Input, row.known)
}
switch {
case row.fails == "" && v.State != delivery.VerdictPass:
t.Errorf("%s: %+v", row.name, v)
@@ -1119,3 +1205,25 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
t.Error("which merge changes the judge")
}
}
// knownNames are the packages state's names as a test gives them, each with the <module>/<artifact> it is built from.
type knownNames map[string]string
func (k knownNames) PackageNames() map[string]string { return k }
// fakeRenames is the seat mesh-config's verb renames as its definition answers it.
type fakeRenames struct {
answer meshconfig.RenamesAnswer
err error
}
func (f fakeRenames) Renames(context.Context, string) (meshconfig.RenamesAnswer, error) {
return f.answer, f.err
}
// fakeSeatDefinitions are the indexer's unparsed seat definitions by pull request (pullKey).
type fakeSeatDefinitions map[string][]UnparsedSeat
func (f fakeSeatDefinitions) Unparsed(_ context.Context, repository string, pull int) ([]UnparsedSeat, error) {
return f[fmt.Sprintf("%s#%d", repository, pull)], nil
}
@@ -99,6 +99,13 @@ type Examiner struct {
Published interface {
LatestPackages() delivery.PackageVersions
}
// KnownPackages names the packages the indexer's packages state knows (its series keys, delivery.SeriesPackage), by the
// name in its own ecosystem, each with the `<module>/<artifact>` it is built from: the names a manifest's `packages` is
// judged against (manifests_judge.go, design 32). Nil, or PackageNames answering nil while the state is not read yet,
// hands the judge no names, and it judges none; an empty map is a state read and empty, against which every name fails.
KnownPackages interface {
PackageNames() map[string]string
}
// Builds says how the build of a fingerprint the candidate carries stands (the builder's builds bucket); nil says nothing
// of the builds.
Builds BuildStates
@@ -109,6 +116,15 @@ type Examiner struct {
BuildWait time.Duration
// Announce says a preview was written under the key (EventPreviewWritten); nil says nothing.
Announce Announcer
// Tags says a group's tag-wanted and hears its packages' published (design 51 *Tagged versions*); nil asks none.
Tags TagAsks
// PublishBound is how long a group's check waits for each package's published; DefaultPublishBound when 0.
PublishBound time.Duration
// Dirs names a module's directory in its repository, from the indexer's modules, so a manifest is read at a member's
// head (tagwanted.go); nil, or a module it does not know, tries modules/<module> and the repository's root.
Dirs interface {
ModuleDir(module string) (string, bool)
}
// Packages names, of a repository, the paths (repository-relative) its TypeScript package artifacts are built from, from
// the indexer's modules (delivery.Module.ArtifactPaths, lead decision L8); nil serves no group package.
Packages interface {
@@ -119,6 +135,11 @@ type Examiner struct {
// Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the
// indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest.
Manifests TrunkManifests
// SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none.
SeatDefinitions SeatDefinitions
// Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go,
// design 32); nil fails such a change, saying it cannot ask.
Renames Renames
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq
// ADR 0603 §2); nil works out no graph.
Read ManifestReader
@@ -246,6 +267,10 @@ type state struct {
// unpreviewed are the members (pullKey) with no preview of a merge: a head that does not merge, or a fetch that did not
// answer. The indexer holds no manifest of them, so the assigner's composition would only wait: it is not awaited.
unpreviewed []string
// tags are the group's tagged versions its members are built against (ADR 0607 §3), once the check heard them.
tags groupVersions
// outOfStep are, by pull request (pullKey), the generated files a level's compare step found out of step (design 26).
outOfStep map[string][]string
}
// Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot
@@ -256,7 +281,8 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) {
// Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR
// 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02).
tests := st.judgeAll(ctx)
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil {
st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil {
return st.finish(ctx, *v), nil
}
return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil
@@ -308,6 +334,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
// outcome fills the verdict's levels, times, machines and composition from what ran.
func (st *state) outcome(v delivery.Verdict) Outcome {
v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2)
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
v.Levels = map[string]delivery.LevelResult{}
for level, a := range st.levels {
@@ -342,7 +369,7 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
}
}
out := Outcome{Verdict: v, Slowest: slow}
out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep}
if len(st.beside) > 0 {
out.Beside = st.beside
}
@@ -357,6 +384,20 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
return out
}
// stepOutOfStep keeps the out-of-step files a step of the pull request named, each once, sorted.
func (st *state) stepOutOfStep(p Pull, files []string) {
if len(files) == 0 {
return
}
if st.outOfStep == nil {
st.outOfStep = map[string][]string{}
}
k := pullKey(Pull{Repository: p.Repository, Number: p.Number})
all := append(st.outOfStep[k], files...)
slices.Sort(all)
st.outOfStep[k] = slices.Compact(all)
}
// fail is a verdict of failure at a machine.
func fail(machine, words string) *delivery.Verdict {
v := failure(machine, words)
@@ -388,6 +429,11 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
}
return 0
})
// A group's members are built only after each package they declare is published as the group's tagged version
// (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built.
if v := st.tagged(ctx); v != nil {
return v
}
var groupPackage map[string]any
var failures []*delivery.Verdict
for _, p := range pulls {
@@ -406,8 +452,12 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
t.agreed = &delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe}
}
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
if bm == buildrun.NodeBuildModule && groupPackage != nil && p.Repository != SDKRepository {
args["package"] = groupPackage
if bm == buildrun.NodeBuildModule {
if np, ok := st.tags.nodePackageFor(p.Repository); ok {
args["package"] = map[string]any{"name": np.Name, "version": np.Version} // the group's tagged version (ADR 0607 §3)
} else if groupPackage != nil && p.Repository != SDKRepository {
args["package"] = groupPackage
}
}
if bm == buildrun.GoBuildModule {
beside, err := st.j.besideFor(ctx, st.c, p)
@@ -440,7 +490,10 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
st.unpreviewed = append(st.unpreviewed, pullKey(p))
}
st.judgedByMember(p, merged)
if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && st.j.changesPackage(p, merged) {
// The old form (ADR 0500 §5): the group's package member packed by the Node fetch, kept only while no tagged
// TypeScript version is heard, the form the running checker hands while the new build modules are delivered. Its
// removal is a subtask of the milestone after this one's delivery (issue 1966).
if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && len(st.tags.node) == 0 && st.j.changesPackage(p, merged) {
groupPackage = map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number)}
}
}
@@ -692,6 +745,7 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
if !res.Passed {
failedResults = append(failedResults, res)
}
st.stepOutOfStep(t.pull, res.OutOfStep)
}
if len(failedResults) > 0 {
return levelFailure(t, level, failedResults...), elapsed
@@ -117,18 +117,20 @@ type pieces struct {
carried string
// warning says that a part of the check did not run, for instance that an older build module could not refuse a compiled program; never dropped.
warning string
// series names each change of a package series the pull request makes (design 32, DLV-C4); never dropped.
series string
}
// pullKey names a pull request of a case: `<owner/repo>#<number>`.
func pullKey(p Pull) string { return fmt.Sprintf("%s#%d", p.Repository, p.Number) }
func (p pieces) join(drop int) string {
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged}
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged, p.series}
switch {
case drop < 3 && p.times != "":
parts = append(parts, p.times)
case drop == 3 && p.shortTimes != "":
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.shortTimes}
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.series, p.shortTimes}
}
if p.over != "" {
parts = append(parts, p.over)
@@ -244,6 +246,7 @@ func carriedWords(from map[string]string, v delivery.Verdict, cs *Case, p Pull)
func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) pieces {
var pc pieces
pc.judged = judged[pullKey(p)]
pc.series = seriesWords(cs, p)
if cs != nil {
pc.warning = cs.Warning(pullKey(p))
}
@@ -356,7 +359,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
pc := parts(v, cs, p, builds, slowest, judged)
if v.State == delivery.VerdictFail {
// The head gives way first to the commits and what is never dropped: its count and "and N more" stay whole.
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged)
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged, pc.series)
pc.head = failedHead(v, MaxLine-utf8.RuneCountInString(" | "+kept))
}
for drop := 0; drop <= 4; drop++ {
@@ -364,13 +367,13 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
return s
}
}
if pc.judged != "" {
if pc.judged != "" || pc.series != "" {
for _, counts := range []string{pc.shortCounts, ""} {
if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
if s := joinSet(pc.head, pc.commits, counts, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
rest := " | " + joinSet(pc.commits, pc.judged)
rest := " | " + joinSet(pc.commits, pc.judged, pc.series)
room := MaxLine - utf8.RuneCountInString(rest) - 1
if head := []rune(pc.head); room > 0 && len(head) > room {
return string(head[:room]) + "…" + rest
@@ -378,7 +381,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
}
if v.State == delivery.VerdictFail {
// A failure's head, sized above, and the commits fit: the counts and the composition are in the report.
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
@@ -3,6 +3,7 @@ package check
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
@@ -12,6 +13,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/composer"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/packagename"
)
// A change's manifests are judged by the indexer's judge (novox/hq ADR 0578 §2): the checker asks the Go build seat to
@@ -120,6 +123,7 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict {
req.Predefined = list
st.c.note("the manifests are judged against the predefined keys of " + from)
}
req.Packages = st.knownPackages(ctx)
raw, err := json.Marshal(req)
if err != nil {
return fail(goModule, err.Error())
@@ -257,3 +261,64 @@ func (st *state) judgedByMember(p Pull, merged []Fetched) {
}
}
}
// knownPackages are the packages a manifest's `packages` may name, sorted (DLV-I14, design 32): every name the indexer's
// packages state holds, and, of each package a member of the case declares that the state does not know by its
// `<module>/<artifact>`, the name read at that member's head through the forge (packagename.Of, the indexer's own reader
// at a merge). A name that cannot be read is said and left out, so a manifest naming it is refused naming it. Nil, which
// judges no name, when the checker follows no packages state or has not read it yet (PackageNames nil); an answered
// state that holds no name is an empty list, never nil, so every name is judged against it.
func (st *state) knownPackages(ctx context.Context) []string {
if st.j.KnownPackages == nil {
return nil
}
names := st.j.KnownPackages.PackageNames()
if names == nil {
return nil
}
built := map[string]bool{}
for _, b := range names {
built[b] = true
}
known := make([]string, 0, len(names))
known = append(known, slices.Collect(maps.Keys(names))...)
declared, err := st.declaredPackages(ctx)
if err != nil {
st.c.note("the packages the group's members declare are not known, so only the packages state's names are known to the judge: " + err.Error())
}
for _, d := range declared {
if built[d.module+"/"+d.name] {
continue
}
if st.j.Forge == nil {
st.c.note(fmt.Sprintf("the name of the package %s/%s new in the group is not read: this checker reads no forge", d.module, d.name))
continue
}
read := func(ctx context.Context, repository, commit, file string) ([]byte, error) {
text, err := st.j.Forge.File(ctx, repository, file, commit)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
return []byte(text), err
}
dirs := []string{d.dir}
if d.dir == "" {
dirs = []string{"modules/" + d.module, delivery.RootPath}
}
var why []string
for _, dir := range dirs {
a := catalogue.Artifact{Name: d.name, Kind: catalogue.ArtifactPackage, Language: d.language, Paths: d.paths}
name, err := packagename.Of(ctx, read, d.pull.Repository, d.pull.Head, dir, a)
if err == nil {
known, why = append(known, name), nil
break
}
why = append(why, err.Error())
}
if len(why) > 0 {
st.c.note(fmt.Sprintf("the name of the package %s/%s new in the group is not read at the head of %s: %s", d.module, d.name, pullKey(d.pull), strings.Join(why, "; ")))
}
}
slices.Sort(known)
return slices.Compact(known)
}
@@ -31,10 +31,12 @@ type Forge interface {
File(ctx context.Context, repository, path, ref string) (string, error)
}
// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4): the latest
// published version of each package the mesh's modules use (Published), and for a package one of the case's pull
// requests publishes, that pull request's head (mesh-sdk's Go module whenever mesh-sdk is a member). A package of
// repository itself comes with the tree the check fetched, and is left out. Nil for none.
// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4, as ADR 0607
// §5 changes §4): the latest published version of each package the mesh's modules use (Published); for a package a
// member of the case publishes, the group's tagged version of it once the check heard it (design 51 *Tagged versions*,
// also a package new in the group, which has no published version yet), else that member's head (mesh-sdk's Go module
// whenever mesh-sdk is a member). A package of repository itself comes with the tree the check fetched, and is left out.
// Nil for none.
func (st *state) packagesOf(repository string) delivery.PackageVersions {
versions := delivery.PackageVersions{}
if st.j.Published != nil {
@@ -45,13 +47,18 @@ func (st *state) packagesOf(repository string) delivery.PackageVersions {
versions[SDKModulePath] = ""
}
}
for pkg := range st.tags.goPackages {
versions[pkg] = ""
}
for pkg := range versions {
repo, _, ok := delivery.PackageRepository(pkg)
if !ok || delivery.SameRepository(repo, repository) {
delete(versions, pkg)
continue
}
if m := st.c.member(repo); m != nil && len(m.Head) == 40 {
if tagged := st.tags.goPackages[pkg]; tagged != "" {
versions[pkg] = tagged
} else if m := st.c.member(repo); m != nil && len(m.Head) == 40 {
versions[pkg] = m.Head
}
if versions[pkg] == "" {
@@ -1,12 +1,19 @@
package check
import (
"context"
"encoding/json"
"maps"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -60,6 +67,151 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes
if got := packagesOfFetch(t, b, "novox/mesh-catalog"); len(got) != 2 || got[SDKModulePath] != trunkS || got[contract] != trunkA {
t.Errorf("a pull request alone is built against %v, want every package at its latest published version", got)
}
// Design 51 *Tagged versions* (DLV-C3): a group's check says tag-wanted for each package a member declares and builds no
// member before its published; a lone pull request asks nothing; no published within the bound fails naming the package.
sdk := catalogue.Manifest{Module: "mesh-sdk", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "go", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}}, {Name: "lib", Kind: catalogue.ArtifactPackage, Language: "typescript"}}}}
contracts := catalogue.Manifest{Module: "mesh-contracts", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
{Name: "go", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}},
{Name: "go-gopackageregistry", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go/seats/gopackageregistry"}}}}}
digest, contractsDigest := strings.Repeat("c", 64), strings.Repeat("d", 64)
sdkPull := func() delivery.CandidatePull {
p := pullOf(repoSDK, 12, head2)
p.Modules = []string{"mesh-sdk"}
p.Fps = map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: digest}}
return p
}
unpreviewed := func() delivery.CandidatePull { // a package new in the group: no preview carries its manifest yet
p := sdkPull()
p.Fps = map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("f", 64)}}
return p
}
contractsPull := func() delivery.CandidatePull {
p := pullOf("novox/mesh-catalog", 4, strings.Repeat("3", 40))
p.Modules = []string{"mesh-contracts"}
p.Fps = map[string]delivery.CandidateModule{"mesh-contracts": {FP2: "src2:" + strings.Repeat("e", 64), Manifest: contractsDigest}}
return p
}
const sdkGo, contractsGo, seatGo = SDKModulePath, "git.novox.be/novox/mesh-catalog/go", "git.novox.be/novox/mesh-catalog/go/seats/gopackageregistry"
for _, row := range []struct {
name string
pulls []delivery.CandidatePull
published []string // the seats that publish the members' heads
wants int
fails string
// built is the Go packages mesh-delivery's member is built against (ADR 0607 §3, §5), node the package its Node
// fetch takes from the registry; nil checks none.
built map[string]string
node map[string]any
// noModule: the Go seat's published names no Go module path (ruling 1 on A2d's finding 1: never worked out).
noModule bool
}{
{name: "a group", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), sdkPull()},
published: []string{"go-package-registry", "npm-package-registry"}, wants: 2,
built: map[string]string{sdkGo: "v0.1.15-feat-x.1"}, node: map[string]any{"name": "lib", "version": "0.1.15-feat-x.1"}},
{name: "a package new in the group, read at its head", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), unpreviewed()},
published: []string{"go-package-registry", "npm-package-registry"}, wants: 2, built: map[string]string{sdkGo: "v0.1.15-feat-x.1"}},
{name: "one head, two Go packages (as mesh-contracts' head)", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), contractsPull()},
published: []string{"go-package-registry"}, wants: 2,
built: map[string]string{contractsGo: "v0.1.15-feat-x.1", seatGo: "v0.1.3-feat-x.1"}},
{name: "a lone pull request", pulls: []delivery.CandidatePull{sdkPull()}},
{name: "a Go published naming no module", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), sdkPull()},
published: []string{"go-package-registry", "npm-package-registry"}, wants: 2, noModule: true,
fails: "no published of the tagged version of go of mesh-sdk (novox/mesh-sdk#12) on go-package-registry within 50ms"},
{name: "a package never published", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), sdkPull()},
published: []string{"go-package-registry"}, wants: 2,
fails: "no published of the tagged version of lib of mesh-sdk (novox/mesh-sdk#12) on npm-package-registry within 50ms"},
} {
b := newBench(t)
b.fetch.Modules = []string{"modules/m"}
b.ex.Read = fixedManifests{digest: sdk, contractsDigest: contracts}
b.ex.Modules = langs{repoA: {buildrun.GoBuildModule, buildrun.NodeBuildModule}}
raw, _ := json.Marshal(sdk)
b.ex.Forge = headFiles{repoSDK + " module.json " + head2: string(raw)}
tags := &recordedTags{heard: &PublishedHeard{}, publish: row.published, noModule: row.noModule}
b.ex.Tags, b.ex.PublishBound = tags, 50*time.Millisecond
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
if len(tags.wanted) != row.wants {
t.Errorf("%s: a member was asked before every tag-wanted was said: %d of %d", row.name, len(tags.wanted), row.wants)
}
return b.answer(a)
}
v := b.judge(t, caseOf(t, row.pulls...)).Verdict
if len(tags.wanted) != row.wants {
t.Errorf("%s: tag-wanted said %d times, want %d: %+v", row.name, len(tags.wanted), row.wants, tags.wanted)
}
for _, w := range tags.wanted {
if !slices.Contains([]string{repoSDK, "novox/mesh-catalog"}, w.Repository) || w.Branch != "feat/x" || w.Head == head1 || w.Module == "" || w.Pull == 7 || w.Tag != "" {
t.Errorf("%s: tag-wanted = %+v", row.name, w)
}
}
switch {
case row.fails == "" && v.State != delivery.VerdictPass:
t.Errorf("%s: verdict = %+v", row.name, v)
case row.fails != "" && (v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, row.fails)):
t.Errorf("%s: verdict = %+v, want it to fail saying %q", row.name, v, row.fails)
case row.fails != "" && len(b.steps.tool("fetch")) != 0:
t.Errorf("%s: a member was built though a package was not published", row.name)
}
if row.built != nil {
got := packagesOfFetch(t, b, repoA)
for pkg, version := range row.built {
if got[pkg] != version {
t.Errorf("%s: mesh-delivery is built against %s %q, want the group's tagged version %s (all: %v)", row.name, pkg, got[pkg], version, got)
}
}
}
if row.node != nil {
if got := nodeFetchOf(b, repoA).args["package"]; !reflect.DeepEqual(got, row.node) {
t.Errorf("%s: mesh-delivery's Node fetch takes %v, want the group's tagged version %v from the registry", row.name, got, row.node)
}
}
}
}
// headFiles are the forge's files by "<repository> <path> <ref>"; any other is not found.
type headFiles map[string]string
func (h headFiles) File(_ context.Context, repository, path, ref string) (string, error) {
if text, ok := h[repository+" "+path+" "+ref]; ok {
return text, nil
}
return "", ErrNotFound
}
// recordedTags are the checker's tag asks as a test hears them: each tag-wanted kept, and the seats in publish answering it
// at once with a tagged version, as the builder and a registry seat would.
type recordedTags struct {
heard *PublishedHeard
publish []string
// noModule publishes a Go package naming no Go module path, against go-package-registry's contract.
noModule bool
mu sync.Mutex
wanted []TagWanted
}
func (r *recordedTags) Want(_ context.Context, w TagWanted) error {
r.mu.Lock()
r.wanted = append(r.wanted, w)
r.mu.Unlock()
for _, seat := range r.publish {
version := "0.1.15-feat-x.1"
if w.Package == "go-gopackageregistry" {
version = "0.1.3-feat-x.1"
}
module := "" // go-package-registry's published names the Go module path (its contract); npm's names none
if seat == "go-package-registry" && !r.noModule {
module = map[string]string{repoSDK + " go": SDKModulePath, "novox/mesh-catalog go": "git.novox.be/novox/mesh-catalog/go",
"novox/mesh-catalog go-gopackageregistry": "git.novox.be/novox/mesh-catalog/go/seats/gopackageregistry"}[w.Repository+" "+w.Package]
}
r.heard.Heard(seat, TaggedPublished{Name: w.Package, Kind: "package", Commit: w.Head, Tag: "feat-x", Version: version, Module: module})
}
return nil
}
func (r *recordedTags) Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool) {
return r.heard.Await(ctx, seat, head, names)
}
type runsAt map[string]string
@@ -0,0 +1,127 @@
package check
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A config value renamed (design 32 *A config value renamed*, DLV-C5): the checker asks the seat mesh-config's verb
// renames, never the module, for every change that brings `renamed-from` or drops one. A holder that does not serve the
// verb refuses `renamed-from`, naming that; while a rename's window is open, dropping its `renamed-from` is refused,
// naming the nodes holding it open; a check that cannot ask fails, saying so.
// RenamesSeat is the verb the checker asks, as its manifest's `invokes` names it.
var RenamesSeat = "seat:" + meshconfig.Seat + "." + meshconfig.VerbRenames
// ErrRenamesNotServed is the answer of Renames when the seat's holder serves no verb renames.
var ErrRenamesNotServed = errors.New("the holder of the seat " + meshconfig.Seat + " does not serve its verb " + meshconfig.VerbRenames)
// Renames asks the seat mesh-config's verb renames for a module's open renames.
type Renames interface {
Renames(ctx context.Context, module string) (meshconfig.RenamesAnswer, error)
}
// renameChange is what a pull request's head does to a module's `renamed-from` against the trunk: the declarations it
// brings (key from old name) and the ones it drops (key, old name).
type renameChange struct {
brought, dropped map[string]string
}
// renamesOf compares a module's config declarations at the trunk and at the head.
func renamesOf(was, now catalogue.Manifest) renameChange {
rc := renameChange{brought: map[string]string{}, dropped: map[string]string{}}
for key, d := range now.Settings {
if d.RenamedFrom != "" && was.Settings[key].RenamedFrom != d.RenamedFrom {
rc.brought[key] = d.RenamedFrom
}
}
for key, d := range was.Settings {
if d.RenamedFrom != "" && now.Settings[key].RenamedFrom != d.RenamedFrom {
rc.dropped[key] = d.RenamedFrom
}
}
return rc
}
// judgeRenames fails the check for a rename the seat refuses, a window a change would drop, or an ask that cannot be made.
func (st *state) judgeRenames(ctx context.Context) *delivery.Verdict {
if st.c.Try != nil {
return nil
}
var out *delivery.Verdict
failed := func(words string) {
f := delivery.FailedFirst{Machine: meshconfig.Seat, Words: words}
if out == nil {
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: f}
return
}
addFailure(out, f)
}
for _, p := range st.c.Pulls {
for _, module := range p.Modules {
was, now, ok, err := st.manifestPair(ctx, p, module)
if err != nil {
failed(fmt.Sprintf("%s (%s): its config renames cannot be judged: %v", module, pullKey(p), err))
continue
}
if !ok {
was = catalogue.Manifest{} // a module new on the pull request brings every rename it declares
if now, ok = st.headManifest(ctx, p, module); !ok {
continue
}
}
rc := renamesOf(was, now)
if len(rc.brought) == 0 && len(rc.dropped) == 0 {
continue
}
if st.j.Renames == nil {
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and this checker cannot ask %s", module, pullKey(p), RenamesSeat))
continue
}
answer, err := st.j.Renames.Renames(ctx, module)
switch {
case errors.Is(err, ErrRenamesNotServed):
for _, key := range slices.Sorted(maps.Keys(rc.brought)) {
failed(fmt.Sprintf("%s (%s) renames %s from %s: refused, %v, so the value cannot be moved", module, pullKey(p), key, rc.brought[key], err))
}
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s: refused, %v, so whether its window is open is not known", module, pullKey(p), rc.dropped[key], key, err))
}
continue
case err != nil:
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and the check cannot ask %s: %v", module, pullKey(p), RenamesSeat, err))
continue
}
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
for _, r := range answer.Renames {
if r.Module == module && r.From == rc.dropped[key] && r.To == key && len(r.Open) > 0 {
open := slices.Sorted(slices.Values(r.Open))
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s while its rename window is open on %s", module, pullKey(p), r.From, key, strings.Join(open, ", ")))
}
}
}
st.c.note(fmt.Sprintf("%s.%s answered for %s (%s)", meshconfig.Seat, meshconfig.VerbRenames, module, pullKey(p)))
}
}
return out
}
// headManifest is a module's manifest at the pull request's head, new on it: by its candidate's digest, else the forge's.
func (st *state) headManifest(ctx context.Context, p Pull, module string) (catalogue.Manifest, bool) {
if digest := p.Fps[module].Manifest; digest != "" && st.j.Read != nil {
if st.j.Manifests != nil && st.j.Manifests.ManifestDigests()[module] == digest {
return catalogue.Manifest{}, false // unchanged
}
m, err := st.j.Read.Manifest(ctx, digest)
return m, err == nil
}
return catalogue.Manifest{}, false
}
@@ -110,6 +110,10 @@ type Tested struct {
// By is the build module that answered and the SDK it was built with (buildstep.By); absent from a build module from
// before the failure contract.
By string `json:"by,omitempty"`
// OutOfStep are the generated files the step's regenerate-and-compare found out of step with their seat definitions,
// each a repository-relative path (design 26; the build modules' compare step, mesh-catalog CAT-BG5 and CAT-BN2):
// the checker names them in its verdict event. Absent from a build module that does not compare.
OutOfStep []string `json:"out-of-step,omitempty"`
}
// Digested is the answer of the Go build module's digest step: one digest per package that has tests at the level, by import
@@ -0,0 +1,55 @@
package check
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A pull request whose head's seat definition does not parse fails its check, naming the seat and where it lives (design
// 26; the lead's ruling on A2i's finding 1): the indexer keeps such a definition as the pull request's entry of its state
// seat-definitions with `unparsed` set, and cannot refuse the manifest itself, whose digest the trunk may share.
// UnparsedSeat is one seat definition of a pull request's head that does not parse: the seat (the contracts module's name
// when its index did not parse), its module's directory and the indexer's words of why.
type UnparsedSeat struct {
Seat, Module, Directory, Why string
}
// SeatDefinitions reads the indexer's seat definitions of one pull request (its state seat-definitions, through its
// first view, DLV-M2).
type SeatDefinitions interface {
Unparsed(ctx context.Context, repository string, pull int) ([]UnparsedSeat, error)
}
// refuseUnparsed fails the check of each pull request with a seat definition that does not parse; a state that cannot be
// read fails it too, saying so.
func (st *state) refuseUnparsed(ctx context.Context) *delivery.Verdict {
if st.j.SeatDefinitions == nil || st.c.Try != nil {
return nil
}
var out *delivery.Verdict
failed := func(words string) {
f := delivery.FailedFirst{Machine: SeatDefinitionsMachine, Words: words}
if out == nil {
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: f}
return
}
addFailure(out, f)
}
for _, p := range st.c.Pulls {
bad, err := st.j.SeatDefinitions.Unparsed(ctx, p.Repository, p.Number)
if err != nil {
failed(fmt.Sprintf("%s: the indexer's seat definitions of it cannot be read, so none is known to parse: %v", pullKey(p), err))
continue
}
for _, u := range bad {
failed(fmt.Sprintf("%s: the seat definition of %s (module %s, %s) does not parse: %s", pullKey(p), u.Seat, u.Module, u.Directory, u.Why))
}
}
return out
}
// SeatDefinitionsMachine names what refused a seat definition in a verdict's failure.
const SeatDefinitionsMachine = "seat-definitions"
@@ -0,0 +1,148 @@
package check
import (
"context"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A change of a package series is named in the verdict, so the review of the change sees it (design 32 *A package, its
// series*; DLV-C4): for each module a pull request touches, every artifact both its trunk manifest and its head's
// manifest declare whose series differs, as `<module>/<artifact> series <trunk> → <head>`. An artifact that names none
// is in catalogue.DefaultSeries. A manifest that cannot be read is said in the same words, never left out.
// seriesChanges names, on the case per pull request, each change of an artifact's series the pull request makes.
func (st *state) seriesChanges(ctx context.Context) {
for _, p := range st.c.Pulls {
var words []string
var changes []delivery.SeriesChange
for _, module := range p.Modules {
was, now, ok, err := st.manifestPair(ctx, p, module)
if err != nil {
words = append(words, fmt.Sprintf("%s: its series not compared (%v)", module, err))
continue
}
if ok {
moved := seriesMoved(module, was, now)
changes = append(changes, moved...)
for _, c := range moved {
words = append(words, seriesLine(c))
}
}
}
if len(words) > 0 {
st.c.series(pullKey(p), words, changes)
}
}
}
// manifestPair is a touched module's trunk manifest and its manifest at the pull request's head: the head's by the digest
// its candidate names, else read from the forge at the head. ok is false when the module is new on the pull request,
// unchanged, or not read here.
func (st *state) manifestPair(ctx context.Context, p Pull, module string) (was, now catalogue.Manifest, ok bool, err error) {
if st.j.Read == nil || st.j.Manifests == nil {
return was, now, false, nil
}
trunk := st.j.Manifests.ManifestDigests()[module]
digest := p.Fps[module].Manifest
if trunk == "" || digest == trunk {
return was, now, false, nil
}
if was, err = st.j.Read.Manifest(ctx, trunk); err != nil {
return was, now, false, fmt.Errorf("the trunk's manifest not read: %w", err)
}
switch {
case digest != "":
if now, err = st.j.Read.Manifest(ctx, digest); err != nil {
return was, now, false, fmt.Errorf("the head's manifest not read: %w", err)
}
case st.j.Forge != nil:
var found bool
if now, _, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found {
return was, now, false, err
}
default:
return was, now, false, nil
}
return was, now, true, nil
}
// seriesLine is a series change as the status line names it: `<module>/<artifact> series <trunk> → <head>`.
func seriesLine(c delivery.SeriesChange) string {
return fmt.Sprintf("%s/%s series %s → %s", c.Module, c.Artifact, c.From, c.To)
}
// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's, by artifact. An
// artifact new on the head is no series change (the lead's ruling): it has no series on the trunk.
func seriesMoved(module string, was, now catalogue.Manifest) []delivery.SeriesChange {
if was.Build == nil || now.Build == nil {
return nil
}
before := map[string]string{}
for _, a := range was.Build.Artifacts {
before[a.Name] = a.SeriesOf()
}
var out []delivery.SeriesChange
for _, a := range now.Build.Artifacts {
if old, ok := before[a.Name]; ok && old != a.SeriesOf() {
out = append(out, delivery.SeriesChange{Module: module, Artifact: a.Name, From: old, To: a.SeriesOf()})
}
}
slices.SortFunc(out, func(a, b delivery.SeriesChange) int { return strings.Compare(a.Artifact, b.Artifact) })
return out
}
// series keeps, of a pull request (pullKey), the words of the series changes it makes and the changes themselves.
func (c *Case) series(key string, words []string, changes []delivery.SeriesChange) {
c.notesMu.Lock()
defer c.notesMu.Unlock()
if c.seriesOf == nil {
c.seriesOf, c.seriesChanges = map[string][]string{}, map[string][]delivery.SeriesChange{}
}
c.seriesOf[key], c.seriesChanges[key] = words, changes
}
// SeriesChanges are the series changes a pull request (pullKey) makes, as the verdict and its event name them.
func (c *Case) SeriesChanges(key string) []delivery.SeriesChange {
c.notesMu.Lock()
defer c.notesMu.Unlock()
return slices.Clone(c.seriesChanges[key])
}
// allSeries are the series changes of every pull request of the case, each module's artifact once, by module and
// artifact: what the verdict names (delivery.Verdict.Series).
func (c *Case) allSeries() []delivery.SeriesChange {
var out []delivery.SeriesChange
seen := map[string]bool{}
for _, p := range c.Pulls {
for _, s := range c.SeriesChanges(pullKey(p)) {
if k := s.Module + "/" + s.Artifact; !seen[k] {
seen[k] = true
out = append(out, s)
}
}
}
slices.SortFunc(out, func(a, b delivery.SeriesChange) int {
return strings.Compare(a.Module+"/"+a.Artifact, b.Module+"/"+b.Artifact)
})
return out
}
// Series are the series changes a pull request (pullKey) makes, as the check named them; none when it makes none.
func (c *Case) Series(key string) []string {
c.notesMu.Lock()
defer c.notesMu.Unlock()
return slices.Clone(c.seriesOf[key])
}
// seriesWords is a pull request's series changes for its status line.
func seriesWords(c *Case, p Pull) string {
if c == nil {
return ""
}
return strings.Join(c.Series(pullKey(p)), ", ")
}
@@ -0,0 +1,349 @@
package check
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// EventTagWanted is the checker's event that asks the builder for a group member's tagged versions (design 51 *Tagged
// versions*, ADR 0610 §1), on the checker's own family asks (its stream asks, kept seven days), apart from the stream
// checks, so a waiter on a verdict does not wake on it.
const EventTagWanted = meshdeliverychecker.HolderEventTagWanted
// AsksStream is the checker's stream that carries tag-wanted and verdict, in its old streams section (ADR 0619): the
// bus names it ASKS.
const AsksStream = "asks"
// DefaultPublishBound is how long a group's check waits for the published of each package its members declare before it
// fails naming the package (design 51 *Tagged versions*: the publish's bound, 10 minutes).
const DefaultPublishBound = 10 * time.Minute
// TagWanted is the body of tag-wanted, as the seat definition of mesh-delivery-checker says it. The tag is left to the
// builder, which makes it from the branch, so one part alone names a branch's tag.
type TagWanted = meshdeliverychecker.TagWantedHolderEvent
// PackageSeats are the registry seats a package of each language is published through (design 51 *The seats*).
var PackageSeats = map[string]string{"go": "go-package-registry", "typescript": "npm-package-registry"}
// TaggedPublished is what a registry seat's published says of a tagged version, as the checker reads it.
type TaggedPublished struct {
Name string `json:"name"`
Kind string `json:"kind"`
Commit string `json:"commit,omitempty"`
Tag string `json:"tag,omitempty"`
Version string `json:"version,omitempty"`
// Module is the Go module path a Go package is fetched under, as go-package-registry's published names it (its
// contract requires it of a Go package).
Module string `json:"module,omitempty"`
}
// TagAsks says tag-wanted and hears the registry seats' published: nil asks nothing, and a group's check builds its
// members as it did before tagged versions.
type TagAsks interface {
// Want says one tag-wanted on the checker's family asks.
Want(ctx context.Context, t TagWanted) error
// Await answers the tagged version a seat published from a head under one of names, once heard; false when ctx ends
// first. A head declaring several packages on one seat (mesh-contracts' Go modules) is told apart by the name.
Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool)
}
// PublishedHeard is the registry seats' published of tagged versions as the checker heard them on its durable consumer
// of the shared events stream, by seat and source commit, each name kept: a waiter wakes on the event, never on a timer.
type PublishedHeard struct {
mu sync.Mutex
heard map[string][]TaggedPublished
waiters map[string][]publishedWaiter
}
type publishedWaiter struct {
names []string
c chan TaggedPublished
}
func publishedKey(seat, head string) string { return seat + " " + head }
func (h *PublishedHeard) init() {
if h.heard == nil {
h.heard, h.waiters = map[string][]TaggedPublished{}, map[string][]publishedWaiter{}
}
}
// Heard takes one published of a seat in; one of no tagged version (no tag, or no commit), and a Go package's that names
// no Go module path, are left out and answer false. A later published of the same name and head replaces the earlier.
func (h *PublishedHeard) Heard(seat string, p TaggedPublished) bool {
if p.Tag == "" || p.Commit == "" || (seat == PackageSeats["go"] && p.Module == "") {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
h.init()
k := publishedKey(seat, p.Commit)
h.heard[k] = append(slices.DeleteFunc(h.heard[k], func(o TaggedPublished) bool { return o.Name == p.Name }), p)
h.waiters[k] = slices.DeleteFunc(h.waiters[k], func(w publishedWaiter) bool {
if !slices.Contains(w.names, p.Name) {
return false
}
w.c <- p
return true
})
return true
}
// Await is TagAsks.Await over what was heard.
func (h *PublishedHeard) Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool) {
k := publishedKey(seat, head)
h.mu.Lock()
h.init()
for _, p := range h.heard[k] {
if slices.Contains(names, p.Name) {
h.mu.Unlock()
return p, true
}
}
w := publishedWaiter{names: names, c: make(chan TaggedPublished, 1)}
h.waiters[k] = append(h.waiters[k], w)
h.mu.Unlock()
select {
case p := <-w.c:
return p, true
case <-ctx.Done():
h.mu.Lock()
h.waiters[k] = slices.DeleteFunc(h.waiters[k], func(o publishedWaiter) bool { return o.c == w.c })
h.mu.Unlock()
select {
case p := <-w.c: // heard as the wait ended
return p, true
default:
}
return TaggedPublished{}, false
}
}
// declaredPackage is one package a group member declares: the module, its directory in the repository (RootPath for its
// root, empty when not known), the artifact's name and paths, and the seat it is published through.
type declaredPackage struct {
pull Pull
module, dir string
name string
paths []string
language, seat string
}
// publishedNames are the names a registry seat's published may carry for a declared package: the builder's
// `<module>/<artifact>` (its versions' name) and the artifact's name. A Go package's module path is the published's own
// `module` (go-package-registry's contract), never worked out here.
func (d declaredPackage) publishedNames() []string {
return []string{d.module + "/" + d.name, d.name}
}
// manifestAtHead is a touched module's manifest read from the forge at its member's head (the lead's ruling 4 on A2c's
// finding 4): with no preview of the merge, a package new in the group is declared only there, never at the trunk. The
// module's directory is the indexer's when it knows the module, else modules/<module> or the repository's root, the one
// whose manifest names the module. Found is false when no such file names it.
func (st *state) manifestAtHead(ctx context.Context, p Pull, module string) (m catalogue.Manifest, dir string, found bool, err error) {
var dirs []string
if st.j.Dirs != nil {
if d, ok := st.j.Dirs.ModuleDir(module); ok {
dirs = []string{d}
}
}
if dirs == nil {
dirs = []string{"modules/" + module, delivery.RootPath}
}
for _, d := range dirs {
file := "module.json"
if t := strings.Trim(d, "/"); t != "" && t != delivery.RootPath {
file = t + "/module.json"
}
text, err := st.j.Forge.File(ctx, p.Repository, file, p.Head)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return catalogue.Manifest{}, "", false, fmt.Errorf("the manifest of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err)
}
m, err := catalogue.Decode([]byte(text))
if err != nil {
return catalogue.Manifest{}, "", false, fmt.Errorf("the manifest %s of %s at the head of %s is no manifest: %w", file, module, pullKey(p), err)
}
if m.Module == module {
return m, d, true, nil
}
}
return catalogue.Manifest{}, "", false, nil
}
// declaredPackages are the packages the case's members declare, read from each touched module's manifest at the merge
// preview (the candidate's); with no preview, at the member's head on the forge, so a package new in the group is asked
// for on the first check too (trunk's manifest when no forge is read): a member that declares none is not asked for.
func (st *state) declaredPackages(ctx context.Context) ([]declaredPackage, error) {
if st.j.Read == nil && st.j.Forge == nil {
return nil, nil
}
var trunk map[string]string
if st.j.Manifests != nil {
trunk = st.j.Manifests.ManifestDigests()
}
var out []declaredPackage
for _, p := range st.c.Pulls {
for _, module := range p.Modules {
var m catalogue.Manifest
var dir string
digest := p.Fps[module].Manifest
switch {
case digest != "" && st.j.Read != nil:
var err error
if m, err = st.j.Read.Manifest(ctx, digest); err != nil {
return nil, fmt.Errorf("the manifest of %s (%s) cannot be read, so the packages it declares are not known: %w", module, pullKey(p), err)
}
case st.j.Forge != nil:
var found bool
var err error
if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil {
return nil, fmt.Errorf("%v, so the packages it declares are not known", err)
}
if !found {
continue
}
case trunk[module] != "" && st.j.Read != nil:
var err error
if m, err = st.j.Read.Manifest(ctx, trunk[module]); err != nil {
return nil, fmt.Errorf("the manifest of %s (%s) cannot be read, so the packages it declares are not known: %w", module, pullKey(p), err)
}
default:
continue
}
if dir == "" && st.j.Dirs != nil {
dir, _ = st.j.Dirs.ModuleDir(module)
}
if m.Build == nil {
continue
}
for _, a := range m.Build.Artifacts {
if a.Kind != catalogue.ArtifactPackage {
continue
}
seat, ok := PackageSeats[a.Language]
if !ok {
return nil, fmt.Errorf("the package %s of %s (%s) is in %q, which no registry seat publishes", a.Name, module, pullKey(p), a.Language)
}
out = append(out, declaredPackage{pull: p, module: module, dir: dir, name: a.Name, paths: a.Paths, language: a.Language, seat: seat})
}
}
}
return out, nil
}
// groupVersions are a group's tagged versions as its check builds its members against them (ADR 0607 §3, §5): the Go
// packages by module path in Go's form (StepAsk.Packages), and the TypeScript packages a member's Node fetch takes from the
// registry, by the repository that publishes them.
type groupVersions struct {
goPackages delivery.PackageVersions
node map[string]NodePackage
}
// NodePackage is a TypeScript package a group's check hands the Node build module's fetch (its `package`): the name and the
// tagged version it takes from the registry, its integrity checked against the member's lock (ADR 0607 §3).
type NodePackage struct {
Name string `json:"name"`
Version string `json:"version"`
}
// nodePackageFor is the TypeScript package a member of repository takes as the group's tagged version: another member's
// (the Node build module places mesh-sdk's alone, so mesh-sdk's first); false when the group published none.
func (g *groupVersions) nodePackageFor(repository string) (NodePackage, bool) {
if np, ok := g.node[SDKRepository]; ok && repository != SDKRepository {
return np, true
}
for _, repo := range slices.Sorted(maps.Keys(g.node)) {
if repo != repository {
return g.node[repo], true
}
}
return NodePackage{}, false
}
// goVersion is a published version in Go's form: a leading v.
func goVersion(v string) string {
if strings.HasPrefix(v, "v") {
return v
}
return "v" + v
}
// took keeps a declared package's tagged version as the group's.
func (g *groupVersions) took(d declaredPackage, p TaggedPublished) {
switch d.language {
case "go":
if g.goPackages == nil {
g.goPackages = delivery.PackageVersions{}
}
g.goPackages[p.Module] = goVersion(p.Version) // Heard took no Go published without its module
case "typescript":
if g.node == nil {
g.node = map[string]NodePackage{}
}
g.node[d.pull.Repository] = NodePackage{Name: p.Name, Version: p.Version}
}
}
// tagged asks a group's tagged versions before any member is built (design 51 *Tagged versions*): one tag-wanted per
// package a member declares, then each package's published awaited within the publish bound. A lone pull request and a
// try ask nothing. A package not published within the bound fails the check naming it.
func (st *state) tagged(ctx context.Context) *delivery.Verdict {
if st.j.Tags == nil || st.c.Try != nil || len(st.c.Pulls) < 2 {
return nil
}
decl, err := st.declaredPackages(ctx)
if err != nil {
return fail(delivery.CheckerModule, err.Error())
}
if len(decl) == 0 {
return nil
}
st.c.Progress.Set("tagged versions", "")
var names []string
for _, d := range decl {
t := TagWanted{Repository: d.pull.Repository, Branch: st.c.Group, Head: d.pull.Head, Module: d.module, Package: d.name, Pull: int64(d.pull.Number)}
if err := st.j.Tags.Want(ctx, t); err != nil {
return fail(delivery.CheckerModule, fmt.Sprintf("the tagged version of %s of %s (%s) cannot be asked: %v", d.name, d.module, pullKey(d.pull), err))
}
names = append(names, d.module+"/"+d.name)
}
bound := st.j.PublishBound
if bound <= 0 {
bound = DefaultPublishBound
}
waits := fmt.Sprintf("waits on the tagged versions of %s before its members are built, at most %s (design 51 *Tagged versions*)", strings.Join(names, ", "), bound)
st.c.note(waits)
st.j.logf("%s %s", st.c.Key, waits)
at, cancel := context.WithTimeout(ctx, bound)
defer cancel()
var late []string
for _, d := range decl {
p, ok := st.j.Tags.Await(at, d.seat, d.pull.Head, d.publishedNames())
if !ok {
late = append(late, fmt.Sprintf("%s of %s (%s) on %s", d.name, d.module, pullKey(d.pull), d.seat))
continue
}
st.tags.took(d, p)
st.c.note(fmt.Sprintf("%s of %s is built as %s %s (tag %s)", d.name, d.module, p.Name, p.Version, p.Tag))
}
if len(late) > 0 {
if ctx.Err() != nil {
return fail(delivery.CheckerModule, ctx.Err().Error())
}
return fail(delivery.CheckerModule, fmt.Sprintf("no published of the tagged version of %s within %s, so no member is built (design 51 *Tagged versions*)", strings.Join(late, "; "), bound))
}
return nil
}
@@ -0,0 +1,40 @@
package check
import (
"context"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// EventVerdict is the checker's event that says a pull request's check ended, with the generated files its steps found out
// of step with their seat definitions (design 26 *Out of step*; ADR 0620): on the checker's own family asks (its stream
// asks), so the indexer learns them from the event and runs no generator, and a waiter on the stream checks does not wake.
const EventVerdict = meshdeliverychecker.HolderEventVerdict
// VerdictEvent is the body of verdict, as the seat definition of mesh-delivery-checker says it.
type VerdictEvent = meshdeliverychecker.VerdictHolderEvent
// VerdictSayer publishes a verdict event.
type VerdictSayer interface {
Say(ctx context.Context, v VerdictEvent) error
}
// sayVerdict says, for each pull request of a check that ended in pass or fail, one verdict event naming its head and its
// out-of-step files. A try owes no pull request anything and says none (ADR 0501 §5). A publish that fails is said in the
// log: the verdict and the status are written already, and the next check of the heads says it again.
func (ch *Checker) sayVerdict(ctx context.Context, cs *Case, v delivery.Verdict, out Outcome) {
if ch.Says == nil || cs.Try != nil || (v.State != delivery.VerdictPass && v.State != delivery.VerdictFail) {
return
}
for _, p := range cs.Pulls {
key := pullKey(Pull{Repository: p.Repository, Number: p.Number})
e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State, OutOfStep: out.OutOfStep[key]}
for _, s := range cs.SeriesChanges(key) {
e.Series = append(e.Series, meshdeliverychecker.VerdictHolderEventSeriesItem{Module: s.Module, Artifact: s.Artifact, From: s.From, To: s.To})
}
if err := ch.Says.Say(ctx, e); err != nil {
ch.logf("%s: the verdict of %s#%d was written, and its event could not be said: %v", cs.Key, p.Repository, p.Number, err)
}
}
}

Some files were not shown because too many files have changed in this diff Show More