carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 9e0ed51ca5141c5ad9effd5ef008347a1349be91 parent 871c473
2 files changed, +154 −1
  • internal/appconfig/configack.go +92 −1
  • internal/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)
+ }
+}