Apply a binding that differs, and never repeat a generation a node passed

A licence store rebuilt after issue 241 counted generations from one again,
and nodes that apply only a higher number discarded the login move and the
rotations unseen. Nodes now apply any binding other than the one applied;
the manager moves its sequence past every generation a node reports. hq
issue 243.
This commit is contained in:
jochen
2026-10-05 09:59:19 +02:00
parent 316576f1da
commit b91b4c427d
6 changed files with 83 additions and 3 deletions
@@ -433,3 +433,31 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
t.Fatalf("the key crossed in the clear: %v", sent)
}
}
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
+8 -3
View File
@@ -255,14 +255,19 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
// which lives hours, and says so.
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
// lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence {
return "", nil
}
out, err := Pull(p, ask, write)
@@ -49,6 +49,8 @@ type Holdings struct {
Fingerprint string `json:"fingerprint"`
} `json:"refresh"`
ChangedAt string `json:"changedAt"`
// Generation is the binding generation the node last applied (novox/hq issue 243).
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
@@ -189,6 +191,17 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -423,3 +423,18 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
}
}
}
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
// reports moves the count past every generation a node says it applied, so no binding repeats one.
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
laptop := mm.login("laptop", "rt-a", true, t0)
laptop.Generation = 16
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -225,6 +225,16 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
@@ -70,6 +70,8 @@ type Store interface {
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
@@ -200,6 +202,13 @@ func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()