appconfig: read a route's ack and say delivered, stale or not yet delivered (plan 4, task 10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 files changed,
+154
−1
internal/appconfig/configack.go+92 −1internal/appconfig/configack_test.go+62 −0
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go| index b4f43d488..6914a73da 100644 |
| --- a/internal/appconfig/configack.go |
| +++ b/internal/appconfig/configack.go |
| @@ -23,7 +23,9 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "log" |
| "os" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -88,11 +90,100 @@ type ConfigAck struct { |
| InstanceConfigETag string `json:"instance_config_etag,omitempty"` |
| } |
| +// ConfigAckPrefix is where every route's config-adoption ack lives, one |
| +// object per (box, route host). |
| +const ConfigAckPrefix = "control/config-ack/" |
| + |
| // ConfigAckKey is where a route's config-adoption ack lives: additive, one |
| // object per (box, route host), rewritten (never appended to) on every sync |
| // that changes what was delivered. |
| func ConfigAckKey(box, host string) string { |
| - return "control/config-ack/" + box + "/" + host + ".json" |
| + return ConfigAckPrefix + box + "/" + host + ".json" |
| +} |
| + |
| +// The four answers to "is this instance running the overlay I wrote", named |
| +// because three of them are not "no" and a caller that flattened them would |
| +// tell an operator to roll a box that is merely one pass behind. |
| +const ( |
| + // InstanceConfigDelivered: the agent rendered exactly this version of |
| + // the overlay. a route with no overlay, whose caller expects none, is |
| + // also delivered. there is nothing outstanding. |
| + InstanceConfigDelivered = "delivered" |
| + // InstanceConfigStale: the agent rendered a different version. one more |
| + // pass, or a generation that never got read, is the usual cause. |
| + InstanceConfigStale = "stale" |
| + // InstanceConfigUnknownAgent: this box's agent predates the instance |
| + // config layer, so its ack says nothing about it. not "no". the answer |
| + // cannot be had until the box is rolled. |
| + InstanceConfigUnknownAgent = "unknown-agent" |
| + // InstanceConfigNotYetDelivered: no ack for this host at all. acks are |
| + // change-only, so this is the honest answer for a route whose file has |
| + // not moved since its agent rolled. it never means "old agent": that |
| + // question is the box record's agent version, which step 5 reads |
| + // (design §6.5). |
| + InstanceConfigNotYetDelivered = "not-yet-delivered" |
| +) |
| + |
| +// InstanceConfigDelivery compares an ack with the etag a write returned and |
| +// says which of the four states the route is in. want is the etag of the |
| +// overlay the caller wrote, or "" when the caller wrote none. |
| +// |
| +// the comparison is on etags rather than on delivered var names because only |
| +// etags are decidable: an agent too old to read the layer and a new agent |
| +// rendering an overlay whose vars the reader does not recognise deliver the |
| +// same set of names (design §6.5). |
| +func InstanceConfigDelivery(a *ConfigAck, want string) string { |
| + switch { |
| + case a == nil: |
| + return InstanceConfigNotYetDelivered |
| + case a.Schema < ConfigAckSchema: |
| + return InstanceConfigUnknownAgent |
| + case a.InstanceConfigETag == want: |
| + return InstanceConfigDelivered |
| + default: |
| + return InstanceConfigStale |
| + } |
| +} |
| + |
| +// LatestConfigAck reads the freshest ack naming host, across every box that |
| +// has written one, and nil (with no error) when nothing has. a route can |
| +// legitimately have acks from two boxes at once across a migration, and the |
| +// freshest is the one describing what is running now. |
| +// |
| +// it lists the whole ack prefix, which is the same limit relayConfigAcks |
| +// accepts (internal/console/relayops.go): there is no per-host box list to |
| +// narrow a listing by. the Gets are narrowed, though. only keys whose |
| +// basename is this host are read. |
| +func LatestConfigAck(ctx context.Context, s release.Store, host string) (*ConfigAck, error) { |
| + keys, err := s.List(ctx, ConfigAckPrefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var best *ConfigAck |
| + for _, key := range keys { |
| + if !strings.HasSuffix(key, "/"+host+".json") { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + return nil, err |
| + } |
| + var a ConfigAck |
| + if err := json.Unmarshal(body, &a); err != nil { |
| + // one unreadable object must not hide the box that answered |
| + // properly, and there is nothing in it to report either way. |
| + log.Printf("config: config ack %s: %v", key, err) |
| + continue |
| + } |
| + if best == nil || a.At.After(best.At) { |
| + latest := a |
| + best = &latest |
| + } |
| + } |
| + return best, nil |
| } |
| // WriteConfigAck writes ack, get-then-put with the CAS etag of whatever is |
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go| index 00f64f60c..6725777ec 100644 |
| --- a/internal/appconfig/configack_test.go |
| +++ b/internal/appconfig/configack_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "errors" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/relay" |
| @@ -314,3 +315,64 @@ func TestSyncExecConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) { |
| t.Fatalf("ack = %+v, want schema %d and etag %q", got[0], ConfigAckSchema, want) |
| } |
| } |
| + |
| +// The four answers, and they are four rather than two on purpose. "This |
| +// agent is too old to render the layer" and "this agent rendered an older |
| +// version of it" call for different sentences from whoever asks: one is a |
| +// box to roll, the other is a pass to wait for. |
| +func TestInstanceConfigDeliveryVerdicts(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + ack *ConfigAck |
| + want string |
| + got string |
| + }{ |
| + {"not yet acked", nil, "etag-1", InstanceConfigNotYetDelivered}, |
| + {"agent older than the layer", &ConfigAck{Schema: 0, InstanceConfigETag: ""}, "etag-1", InstanceConfigUnknownAgent}, |
| + {"rendered this overlay", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-1"}, "etag-1", InstanceConfigDelivered}, |
| + {"rendered an older one", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-0"}, "etag-1", InstanceConfigStale}, |
| + {"no overlay, and none expected", &ConfigAck{Schema: ConfigAckSchema}, "", InstanceConfigDelivered}, |
| + {"no overlay, but one was written", &ConfigAck{Schema: ConfigAckSchema}, "etag-1", InstanceConfigStale}, |
| + } |
| + for _, c := range cases { |
| + if got := InstanceConfigDelivery(c.ack, c.want); got != c.got { |
| + t.Errorf("%s: InstanceConfigDelivery = %q, want %q", c.name, got, c.got) |
| + } |
| + } |
| +} |
| + |
| +// One route can have acks from more than one box across a migration, so the |
| +// reader takes the freshest and ignores every other host's. |
| +func TestLatestConfigAckPicksTheFreshestForTheHost(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + older := ConfigAck{Schema: ConfigAckSchema, Box: "box-1", Host: "book.example.com", Shape: "unit", |
| + At: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-0"} |
| + newer := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "book.example.com", Shape: "unit", |
| + At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-1"} |
| + other := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "cart.example.com", Shape: "unit", |
| + At: time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-9"} |
| + for _, a := range []ConfigAck{older, newer, other} { |
| + if err := WriteConfigAck(ctx, s, a); err != nil { |
| + t.Fatalf("WriteConfigAck %s/%s: %v", a.Box, a.Host, err) |
| + } |
| + } |
| + got, err := LatestConfigAck(ctx, s, "book.example.com") |
| + if err != nil || got == nil { |
| + t.Fatalf("LatestConfigAck = %+v %v", got, err) |
| + } |
| + if got.Box != "box-2" || got.InstanceConfigETag != "etag-1" { |
| + t.Fatalf("LatestConfigAck = %+v, want box-2's etag-1", got) |
| + } |
| + if v := InstanceConfigDelivery(got, "etag-1"); v != InstanceConfigDelivered { |
| + t.Fatalf("delivery = %q, want %q", v, InstanceConfigDelivered) |
| + } |
| + |
| + none, err := LatestConfigAck(ctx, s, "desk.example.com") |
| + if err != nil || none != nil { |
| + t.Fatalf("LatestConfigAck for a host with no ack = %+v %v, want (nil, nil)", none, err) |
| + } |
| + if v := InstanceConfigDelivery(none, "etag-1"); v != InstanceConfigNotYetDelivered { |
| + t.Fatalf("delivery = %q, want %q", v, InstanceConfigNotYetDelivered) |
| + } |
| +} |