console: hostname locks are refcounted and the lifetime log throttle prunes old keys (final review F7)
hostLocks kept a mutex for every hostname ever locked, including authenticated requests for hostnames that never existed, and lifetimeLogged kept every logged operation key. withHostname now uses keyedLocks (the app locks' refcounted map), so an entry goes when its last holder or waiter is done and a queued request still waits, and lifetimeLogHourly prunes keys older than two hours when it logs a line. Census: internal/console envelope refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+105
−15
docs/superpowers/notes/census-proposal-self-serve-moves-1.md+1 −1internal/console/lifetimes.go+20 −9internal/console/lifetimes_create_test.go+78 −0internal/console/server.go+4 −4internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv+2 −1
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (16 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (166 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.397s
ok github.com/carlosframework/platform/cmd/moveprobe 11.807s
ok github.com/carlosframework/platform/cmd/zddprobe 0.756s
ok github.com/carlosframework/platform/internal/activator 59.384s
ok github.com/carlosframework/platform/internal/agentsock 0.029s
ok github.com/carlosframework/platform/internal/alerting 0.010s
ok github.com/carlosframework/platform/internal/analytics 0.040s
ok github.com/carlosframework/platform/internal/appconfig 2.820s
ok github.com/carlosframework/platform/internal/apperr 0.205s
ok github.com/carlosframework/platform/internal/applog 0.297s
ok github.com/carlosframework/platform/internal/awsinv 0.012s
ok github.com/carlosframework/platform/internal/boxchoice 0.015s
ok github.com/carlosframework/platform/internal/boxid 0.621s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.027s
ok github.com/carlosframework/platform/internal/caps 0.020s
ok github.com/carlosframework/platform/internal/certwarm 0.010s
ok github.com/carlosframework/platform/internal/cgroup 0.783s
ok github.com/carlosframework/platform/internal/changelog 0.515s
ok github.com/carlosframework/platform/internal/claimactivation 1.213s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.396s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.014s
ok github.com/carlosframework/platform/internal/dbverify 0.011s
ok github.com/carlosframework/platform/internal/dnsrec 0.008s
ok github.com/carlosframework/platform/internal/docsite 0.026s
ok github.com/carlosframework/platform/internal/domains 2.860s
ok github.com/carlosframework/platform/internal/doorkey 0.017s
ok github.com/carlosframework/platform/internal/economics 0.009s
ok github.com/carlosframework/platform/internal/edge 45.624s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.018s
ok github.com/carlosframework/platform/internal/fleetapi 0.244s
ok github.com/carlosframework/platform/internal/gate 1.745s
ok github.com/carlosframework/platform/internal/genselect 0.019s
ok github.com/carlosframework/platform/internal/geo 0.018s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/historyreadback 0.263s
ok github.com/carlosframework/platform/internal/host 139.252s
ok github.com/carlosframework/platform/internal/hostactors 6.894s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.008s
ok github.com/carlosframework/platform/internal/hostbox 2.931s
ok github.com/carlosframework/platform/internal/hostclaim 0.822s
ok github.com/carlosframework/platform/internal/hostenv 0.010s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 5.219s
ok github.com/carlosframework/platform/internal/hostfence 0.052s
ok github.com/carlosframework/platform/internal/hostfiles 10.289s
ok github.com/carlosframework/platform/internal/hostfloor 0.019s
ok github.com/carlosframework/platform/internal/hostguard 1.013s
ok github.com/carlosframework/platform/internal/hostops 1.960s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.008s
ok github.com/carlosframework/platform/internal/hostquarantine 0.003s
ok github.com/carlosframework/platform/internal/hostrelocate 0.599s
ok github.com/carlosframework/platform/internal/hostresolve 0.190s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.006s
ok github.com/carlosframework/platform/internal/instancectl 5.219s
ok github.com/carlosframework/platform/internal/instanceexec 2.774s
ok github.com/carlosframework/platform/internal/instancefs 0.010s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.013s
ok github.com/carlosframework/platform/internal/layout 1.849s
ok github.com/carlosframework/platform/internal/lbhealth 0.004s
ok github.com/carlosframework/platform/internal/lease 10.112s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.559s
ok github.com/carlosframework/platform/internal/ledger 0.024s
ok github.com/carlosframework/platform/internal/mail 0.297s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.580s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.087s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.778s
ok github.com/carlosframework/platform/internal/moves/archive 0.979s
ok github.com/carlosframework/platform/internal/objstore 0.247s
ok github.com/carlosframework/platform/internal/ops 0.208s
ok github.com/carlosframework/platform/internal/park 0.290s
ok github.com/carlosframework/platform/internal/plans 0.011s
ok github.com/carlosframework/platform/internal/privatefile 0.009s
ok github.com/carlosframework/platform/internal/provision 22.556s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.847s
ok github.com/carlosframework/platform/internal/relay 7.197s
ok github.com/carlosframework/platform/internal/release 25.003s
ok github.com/carlosframework/platform/internal/rollout 3.771s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.033s
ok github.com/carlosframework/platform/internal/runs 0.567s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.710s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.799s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.822s
ok github.com/carlosframework/platform/internal/status 0.082s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 1.018s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.636s
=== internal_console@0 (87 s)
ok github.com/carlosframework/platform/internal/console 85.121s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 35.689s
=== internal_console@2 (37 s)
ok github.com/carlosframework/platform/internal/console 34.818s
=== internal_console@3 (44 s)
ok github.com/carlosframework/platform/internal/console 42.653s
=== internal_console@4 (42 s)
ok github.com/carlosframework/platform/internal/console 39.794s
=== internal_console@5 (45 s)
ok github.com/carlosframework/platform/internal/console 42.983s
=== internal_console@6 (44 s)
ok github.com/carlosframework/platform/internal/console 41.886s
=== internal_console@7 (44 s)
ok github.com/carlosframework/platform/internal/console 42.358s
=== root@0 (30 s)
ok github.com/carlosframework/platform 28.009s
=== root@1 (44 s)
ok github.com/carlosframework/platform 42.293s
=== root@2 (45 s)
ok github.com/carlosframework/platform 43.273s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50135202 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.021s
=== RUN TestT4StorageProbeMinio
2026/10/23 05:42:03 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E1122FD4A04EF9, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md| index f8b10a9b2..f066343a9 100644 |
| --- a/docs/superpowers/notes/census-proposal-self-serve-moves-1.md |
| +++ b/docs/superpowers/notes/census-proposal-self-serve-moves-1.md |
| @@ -42,6 +42,6 @@ Task 9 adds the plan's end-to-end witnesses, in `internal/host/movesoffline_life |
| Task 9 fix round 1 corrects Task 3's create door. When a delayed create's SQLite commit is refused because recovery already committed its allocation, `Server.allocRecordLocked` now asks `Server.committedElsewhere`. That function reads the allocation row and the record through `readRecord` (judged) and writes nothing. When the row is committed and the record is valid at the allocation's incarnation, the door answers success; anything else stays pending. `Server.allocRecordLocked` stays a record-writer through `WriteRecord` and is re-pinned in place in the record-write inventory. The `internal/console` envelope is refreshed in place. No root read, class, legacy row or seal changes, and `TestNoUnjudgedRecordReads` stays green. |
| -Final review fix round. F1 changes the cutover door: it skips the box floor only once a validated marker body has been read or written by the door, never on the gate's state alone. A failed marker read still closes admission. `Server.handleAPILifetimesCutover` stays nonrecord and is re-pinned in place in the record-write inventory. F2 narrows the create's orphan rule: a live slot of another owner is retired only when no record exists under that owner's key, and a present body of any verdict refuses the create and is left untouched. `Server.planCreateLocked` is not a census unit, so only the envelope moves. F3 judges the catch-all label counts. `internal/domains` cannot import `internal/provision`, so it declares an `InstanceObserver` interface and its own context key, and `provision.WithJudge` records the same judge there (nil records none). `domains.InstanceLabelCountsJudged` counts only what the judge vouches for and reports a label with an unjudgeable record as unknown; with no observer it is `InstanceLabelCounts`. `ReconcileAliases` and `deriveInstanceAliases` (accepted legacy, rev3=J1) are re-frozen with reviewed bases: a stale body no longer makes a label shared, and an unknown label keeps its existing alias row and derives none, which adds four read sites on that row (`registry:Account`, `registry:Alias`, `registry:App` and the whole `RouteAlias`). That moves the seal. In the record-write inventory, `InstanceLabelCounts` leaves and `instanceHosts`, the key listing it now shares with the judged counter, takes its place as a key-site. `Server.catchallInstanceName` counts through the judged counter with the request's judge. The `internal/domains` and `internal/provision` envelopes are refreshed in place. F5 makes the migration pass report the migrations it attempted rather than the candidates it listed, so a candidate a pending operation blocks leaves the pass on its ten-minute idle cadence. `Server.migratePreLifetimeRecords` stays a key-site and is re-pinned in place. The `internal/console` envelope is refreshed in place after each finding. No root read, class or task is added. |
| +Final review fix round. F1 changes the cutover door: it skips the box floor only once a validated marker body has been read or written by the door, never on the gate's state alone. A failed marker read still closes admission. `Server.handleAPILifetimesCutover` stays nonrecord and is re-pinned in place in the record-write inventory. F2 narrows the create's orphan rule: a live slot of another owner is retired only when no record exists under that owner's key, and a present body of any verdict refuses the create and is left untouched. `Server.planCreateLocked` is not a census unit, so only the envelope moves. F3 judges the catch-all label counts. `internal/domains` cannot import `internal/provision`, so it declares an `InstanceObserver` interface and its own context key, and `provision.WithJudge` records the same judge there (nil records none). `domains.InstanceLabelCountsJudged` counts only what the judge vouches for and reports a label with an unjudgeable record as unknown; with no observer it is `InstanceLabelCounts`. `ReconcileAliases` and `deriveInstanceAliases` (accepted legacy, rev3=J1) are re-frozen with reviewed bases: a stale body no longer makes a label shared, and an unknown label keeps its existing alias row and derives none, which adds four read sites on that row (`registry:Account`, `registry:Alias`, `registry:App` and the whole `RouteAlias`). That moves the seal. In the record-write inventory, `InstanceLabelCounts` leaves and `instanceHosts`, the key listing it now shares with the judged counter, takes its place as a key-site. `Server.catchallInstanceName` counts through the judged counter with the request's judge. The `internal/domains` and `internal/provision` envelopes are refreshed in place. F5 makes the migration pass report the migrations it attempted rather than the candidates it listed, so a candidate a pending operation blocks leaves the pass on its ten-minute idle cadence. `Server.migratePreLifetimeRecords` stays a key-site and is re-pinned in place. F6 changes a host test only. F7 bounds two in-process maps: the hostname exclusion is now refcounted, so an entry goes when its last holder or waiter is done, and the hourly lifetime log throttle prunes keys older than two hours when it logs a line. Neither reads or writes the bucket. The `internal/console` envelope is refreshed in place after each finding. No root read, class or task is added. |
| Taken together, the rows marked `PROPOSED-self-serve-moves-1` are the census of one change. Claims-off instance records gain a lifetime: the host incarnation slot, written only through `WriteHostIncarnation` by the console's create, retirement and migration protocols. Every claims-off consumer judges a record against its slot before it acts on it. Every record write stays on `WriteRecord` with the sequence discipline. The per-host `move_seq` and each row's `(incarnation, serving_seq)` pair order the publications an edge folds. Each row is one of five kinds: a write site added or re-pinned in the record-write inventory (each through `WriteRecord`, a `DeleteIfMatch` on an observed ETag, or a Put of a control key that is not a record); a read made judged at its existing class; a console SQLite unit or a legacy fingerprint with a reviewed basis; the `@fields` schema row, which pins the registry fields Task 7 adds (`Route.Incarnation`, `Route.ServingSeq`, and the remote request's `Pair` and `ExpectPair`); or a source envelope refreshed over the new code. No row adds a raw record write, a root-read class or a task. The two registry read sites added in Task 8 are named in its paragraph. These rows are one proposal, for Paul to rule on once, together, after the plan's final review. Until then every row stays PROPOSED, and a green census means only that the rows match the code, not that they are accepted. |
diff --git a/internal/console/lifetimes.go b/internal/console/lifetimes.go| index 211335e04..51d209097 100644 |
| --- a/internal/console/lifetimes.go |
| +++ b/internal/console/lifetimes.go |
| @@ -8,7 +8,6 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| - "sync" |
| "sync/atomic" |
| "time" |
| @@ -143,9 +142,11 @@ func lockEvent(kind, key, event string) { |
| } |
| } |
| -// withHostname runs fn holding host's exclusion: one mutex per hostname for |
| -// the life of the process. Not reentrant. Every create, retirement, |
| -// migration and recovery of a hostname in this console runs inside it. |
| +// withHostname runs fn holding host's exclusion: one mutex per hostname, |
| +// refcounted (keyedLocks), so a hostname's entry goes when its last holder |
| +// or waiter is done and requests for hostnames that never exist leave no |
| +// state. Not reentrant. Every create, retirement, migration and recovery of |
| +// a hostname in this console runs inside it. |
| func (s *Server) withHostname(host string, fn func() error) error { |
| defer s.lockHostname(host)() |
| return fn() |
| @@ -153,14 +154,12 @@ func (s *Server) withHostname(host string, fn func() error) error { |
| // lockHostname blocks until host's exclusion is held and returns the release. |
| func (s *Server) lockHostname(host string) func() { |
| - v, _ := s.hostLocks.LoadOrStore(host, &sync.Mutex{}) |
| - mu := v.(*sync.Mutex) |
| lockEvent("host", host, lockWaiting) |
| - mu.Lock() |
| + unlock := s.hostLocks.lock(host) |
| lockEvent("host", host, lockHeld) |
| return func() { |
| lockEvent("host", host, lockReleased) |
| - mu.Unlock() |
| + unlock() |
| } |
| } |
| @@ -235,13 +234,25 @@ func newLifetimeOpID() (string, error) { |
| return hex.EncodeToString(b[:]), nil |
| } |
| +// lifetimeLogKeep is how long lifetimeLogHourly remembers a key: past it a |
| +// key throttles nothing, so it is pruned when the next line is logged. |
| +const lifetimeLogKeep = 2 * time.Hour |
| + |
| // lifetimeLogHourly logs once per hour per key: a row recovery leaves |
| -// pending on every pass would otherwise log every minute. |
| +// pending on every pass would otherwise log every minute. Logging a line |
| +// prunes the keys older than lifetimeLogKeep, so a completed operation's |
| +// key does not stay for the life of the process. |
| func (s *Server) lifetimeLogHourly(key, format string, args ...any) { |
| now := time.Now() |
| if v, ok := s.lifetimeLogged.Load(key); ok && now.Sub(v.(time.Time)) < time.Hour { |
| return |
| } |
| + s.lifetimeLogged.Range(func(k, v any) bool { |
| + if now.Sub(v.(time.Time)) > lifetimeLogKeep { |
| + s.lifetimeLogged.Delete(k) |
| + } |
| + return true |
| + }) |
| s.lifetimeLogged.Store(key, now) |
| log.Printf(format, args...) |
| } |
diff --git a/internal/console/lifetimes_create_test.go b/internal/console/lifetimes_create_test.go| index ebfcbbf8b..eaff06788 100644 |
| --- a/internal/console/lifetimes_create_test.go |
| +++ b/internal/console/lifetimes_create_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "net/url" |
| "os" |
| "runtime" |
| + "strconv" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -2476,3 +2477,80 @@ func TestRecoveryWaitsForAnotherPendingOperation(t *testing.T) { |
| recoverOn(t, l.fx.srv) |
| oneAlloc(t, l.fx.st, ltOne, lifetimeCommitted) |
| } |
| + |
| +// hostLockEntries is how many hostnames withHostname holds state for. |
| +func hostLockEntries(s *Server) int { |
| + s.hostLocks.mu.Lock() |
| + defer s.hostLocks.mu.Unlock() |
| + return len(s.hostLocks.m) |
| +} |
| + |
| +func lifetimeLoggedEntries(s *Server) map[string]time.Time { |
| + out := map[string]time.Time{} |
| + s.lifetimeLogged.Range(func(k, v any) bool { out[k.(string)] = v.(time.Time); return true }) |
| + return out |
| +} |
| + |
| +// The hostname exclusion and the hourly log throttle keep bounded state: |
| +// a hostname's entry goes when its last holder or waiter is done, and log |
| +// keys older than two hours are pruned when a line is logged (final review |
| +// F7). Two requests on one hostname still exclude each other. |
| +func TestLifetimeLockAndLogStateIsBounded(t *testing.T) { |
| + l := newLtOn(t) |
| + for i := 0; i < 40; i++ { |
| + host := "gone" + strconv.Itoa(i) + ".shop.example.com" |
| + if code, body := l.del(t, host); code != http.StatusOK { |
| + t.Fatalf("delete of an absent %s = %d %s, want its idempotent 200", host, code, body) |
| + } |
| + } |
| + for i := 0; i < 200; i++ { |
| + _ = l.fx.srv.withHostname("h"+strconv.Itoa(i)+".shop.example.com", func() error { return nil }) |
| + } |
| + if n := hostLockEntries(l.fx.srv); n != 0 { |
| + t.Fatalf("%d hostname lock entries after every request completed, want 0", n) |
| + } |
| + |
| + // Exclusion: a second request on the hostname waits for the first, and |
| + // the entry stays while either holds or waits for it. |
| + held, release, second := make(chan struct{}), make(chan struct{}), make(chan struct{}) |
| + go func() { |
| + _ = l.fx.srv.withHostname(ltOne, func() error { close(held); <-release; return nil }) |
| + }() |
| + waitFor(t, held, "the first holder") |
| + done := make(chan struct{}) |
| + go func() { |
| + _ = l.fx.srv.withHostname(ltOne, func() error { close(second); return nil }) |
| + close(done) |
| + }() |
| + select { |
| + case <-second: |
| + t.Fatal("the second request ran while the first held the hostname") |
| + case <-time.After(100 * time.Millisecond): |
| + } |
| + if n := hostLockEntries(l.fx.srv); n != 1 { |
| + t.Fatalf("%d entries while one holds and one waits, want 1", n) |
| + } |
| + close(release) |
| + waitFor(t, done, "the second request") |
| + if n := hostLockEntries(l.fx.srv); n != 0 { |
| + t.Fatalf("%d entries after both, want 0", n) |
| + } |
| + |
| + // The log throttle: completed operations' keys older than two hours go |
| + // when the next line is logged; a recent key and its throttle stay. |
| + now := time.Now() |
| + for i := 0; i < 300; i++ { |
| + l.fx.srv.lifetimeLogged.Store("retirement done"+strconv.Itoa(i), now.Add(-3*time.Hour)) |
| + } |
| + l.fx.srv.lifetimeLogged.Store("retirement recent", now.Add(-time.Hour+time.Minute)) |
| + l.fx.srv.lifetimeLogHourly("retirement new", "console: test line %d", 1) |
| + got := lifetimeLoggedEntries(l.fx.srv) |
| + if _, ok := got["retirement new"]; !ok || len(got) != 2 { |
| + t.Fatalf("after a logged line the throttle holds %d keys, want the recent one and the new one", len(got)) |
| + } |
| + at := got["retirement recent"] |
| + l.fx.srv.lifetimeLogHourly("retirement recent", "console: test line %d", 2) |
| + if again := lifetimeLoggedEntries(l.fx.srv)["retirement recent"]; !again.Equal(at) { |
| + t.Fatal("a key logged within the hour was logged again") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 8cb9fb06a..c1077a11f 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1150,10 +1150,10 @@ type Server struct { |
| // (applocks.go). Zero values are ready. |
| appLocks keyedLocks |
| acctLocks keyedLocks |
| - // hostLocks is withHostname's mutex per hostname (lifetimes.go), and |
| - // lifetimeLogged the last time each hourly lifetime log line was |
| - // written. Zero values are ready. |
| - hostLocks sync.Map |
| + // hostLocks is withHostname's refcounted mutex per hostname |
| + // (lifetimes.go), and lifetimeLogged the last time each hourly lifetime |
| + // log line was written, pruned past two hours. Zero values are ready. |
| + hostLocks keyedLocks |
| lifetimeLogged sync.Map |
| // The trash sweep's clock (lifetimes_purge.go) and the box floor |
| // watch's memory (lifetimes_floor.go). |
diff --git a/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv b/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv| index 22c940803..2509d38db 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv |
| @@ -45,7 +45,7 @@ internal/console/store_gate.go claimSQLCensus legacy accepted-legacy accepted-le |
| @remove internal/console/tempstore.go Store.CreateTemporaryAppInRegion |
| @removed internal/console/tempstore.go Store.CreateTemporaryAppInRegion H3 census-ruling-PROPOSED-self-serve-moves-1;rev3=PROPOSED-self-serve-moves-1;basis=constructor remains declared but has no direct read; app insertion and lifetime minting moved to insertAppRow, classified above; proposed removal only |
| # Task 2 envelopes, rev3=PROPOSED-self-serve-moves-1: internal/console covers the lifetime store, app constructors, lifetime projection and table-scoped gate labels; internal/provision (above) adds only ErrLifetimeExhausted beyond Task 1. Registry fields are unchanged. |
| -@coverage internal/console bfcd3b07e8252233f13df2a923e0af4012b710cf61e77b0a8f018bc3ba4b002a |
| +@coverage internal/console d982f1babc8f4c803bea0fb0b1b5a53159a9ba0ec8e2005aae12e7b779327f7a |
| # Task 6a: rev3=PROPOSED-self-serve-moves-1; reviewed provision envelope now includes bounded record-then-slot observations, judged read/list/update gates and signed-counter exhaustion at slot read. No removal-loop, host or console change; registry fields and the legacy seal stand. |
| internal/provision/rootrecord.go IndexRecords authority B3,C rev3=PROPOSED-self-serve-moves-1;basis=retain every discovered candidate owner key per host for fresh lifetime judgement at Find; partial discovery still retries and no verdict or body is cached e44ac431865bf38baa4105f66c9cb23014cc3c7ffcd41d5385244cc76fc4c0b9 |
| internal/provision/rootrecord.go RecordIndex.Find authority B3,C rev3=PROPOSED-self-serve-moves-1;basis=observe each candidate through the context judge, refuse corruption and read failures, require one valid owner and validate its schema and shape; nil-judge duplicates retain permission refusal 760eed59cbd6a8ddab1098ce3a5f7a4272ecda81c17cc079dc135aba3a85a8f8 |
| @@ -200,3 +200,4 @@ internal/domains/aliases.go ReconcileAliases legacy accepted-legacy accepted-leg |
| @legacy a37ea03438815852c187e4b5e8d83641253f23e8cbd48898abd4e2c72820c65e internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): rev3=J1's body; a stale body under a retired lifetime no longer makes a label shared, and a label with a record that cannot be judged keeps its existing alias row of the catch-all's own account and app (reading that row's Account, App and Alias) and derives no new one; a report-only conservative keep, never a new route registry:Account@2163,registry:Account@2950,registry:Account@3913,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@3006,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@2976,registry:App@3939,registry:Host@1843,registry:Host@3892,registry:Host@3902,registry:Host@4098,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3502,whole:RouteAlias@2143,whole:RouteAlias@2920,whole:RouteAlias@3874,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 888a5a32dbbcd9619b0bf513c0dc746686ad7c4e5681bf82248022e8f13b2512 |
| internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-1;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): rev3=J1's body; a stale body under a retired lifetime no longer makes a label shared, and a label with a record that cannot be judged keeps its existing alias row of the catch-all's own account and app (reading that row's Account, App and Alias) and derives no new one; a report-only conservative keep, never a new route registry:Account@2163,registry:Account@2950,registry:Account@3913,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@3006,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@2976,registry:App@3939,registry:Host@1843,registry:Host@3892,registry:Host@3902,registry:Host@4098,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3502,whole:RouteAlias@2143,whole:RouteAlias@2920,whole:RouteAlias@3874,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 888a5a32dbbcd9619b0bf513c0dc746686ad7c4e5681bf82248022e8f13b2512 |
| # Final review fix round F5, rev3=PROPOSED-self-serve-moves-1: the migration pass reports the migrations it attempted, not the candidates it listed, so a blocked candidate leaves it on the idle cadence; no read site added. The internal/console envelope is refreshed in place above; no root read, class, legacy row or seal changes. |
| +# Final review fix round F7, rev3=PROPOSED-self-serve-moves-1: the hostname exclusion becomes a refcounted keyedLocks (an entry goes when its last holder or waiter is done) and the hourly lifetime log throttle prunes keys older than two hours when it logs. In-process state only: no read site, class, legacy row or seal changes; the internal/console envelope is refreshed in place above. |