The bus on NATS: both transports behind seams, and the rollout switch #87
@@ -0,0 +1,106 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The store window, as a decision rather than a mechanism.
|
||||||
|
//
|
||||||
|
// The guarantee (novox/hq ADR 0083): a push the controller cannot record because its store is
|
||||||
|
// restarting is **held and retried**, not dropped and not falsely acknowledged. On the bus the
|
||||||
|
// mesh runs on today that is done by keeping the delivery unacknowledged in memory and settling
|
||||||
|
// it later. On the bus being built it is a `nak` with a delay: the server holds it and redelivers,
|
||||||
|
// so the controller keeps no list of parked messages and a controller that restarts mid-window
|
||||||
|
// loses nothing it was holding.
|
||||||
|
//
|
||||||
|
// **The decision is the same either way, and the mechanism is not the interesting part.** What is
|
||||||
|
// interesting is that moving the holding into the server introduces a problem the in-memory
|
||||||
|
// version did not have, and the answer was already in the message.
|
||||||
|
|
||||||
|
// Verdict is what to do with one control message.
|
||||||
|
type Verdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Take it: apply, then acknowledge.
|
||||||
|
Take Verdict = iota
|
||||||
|
// Hold it: the store cannot record this yet. Nak with a delay and let the server redeliver.
|
||||||
|
Hold
|
||||||
|
// Stale: this is about a declaration the node has already moved past, and applying it would
|
||||||
|
// undo what came after. Acknowledge without acting — redelivering forever is worse.
|
||||||
|
Stale
|
||||||
|
// GiveUp: the store has not come back in time. Settle it and say so, loudly.
|
||||||
|
GiveUp
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoreWindow decides. Pure, so the guarantee is testable without a bus, a store or a clock.
|
||||||
|
type StoreWindow struct {
|
||||||
|
// GiveUpAfter is how long one message may be held before it is let go with a line saying so.
|
||||||
|
GiveUpAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide answers for one delivery.
|
||||||
|
//
|
||||||
|
// - err is what the store said, or nil.
|
||||||
|
// - declaredIn is the digest of the declaration this message is about, empty when it is not
|
||||||
|
// about one (an enrolment, a build result).
|
||||||
|
// - outstanding is the digest the mesh last sent that node, empty when it has sent none.
|
||||||
|
// - heldFor is how long this message has already been held; zero on first delivery.
|
||||||
|
func (w StoreWindow) Decide(err error, declaredIn, outstanding string, heldFor time.Duration) Verdict {
|
||||||
|
// **Staleness is checked before the store, not after.** A redelivery that lost its race is
|
||||||
|
// not worth waiting on a store for, and asking the store first would mean a message about a
|
||||||
|
// superseded declaration holding a slot in the window that a current one needs.
|
||||||
|
if declaredIn != "" && outstanding != "" && declaredIn != outstanding {
|
||||||
|
return Stale
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return Take
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrTryAgain) && !inventory.Unreachable(err) {
|
||||||
|
// Not the store being away: a refusal is an answer, and holding it would turn a message
|
||||||
|
// the mesh understood into one it retries forever.
|
||||||
|
return Take
|
||||||
|
}
|
||||||
|
if heldFor >= w.GiveUpAfter {
|
||||||
|
return GiveUp
|
||||||
|
}
|
||||||
|
return Hold
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeliverAfter is how long the server should hold a naked message before trying again.
|
||||||
|
//
|
||||||
|
// Backed off, and bounded. A store restarting is back in seconds; a store that is gone is not
|
||||||
|
// helped by being asked every second, and the delay is what keeps a window of held messages from
|
||||||
|
// becoming a spin.
|
||||||
|
func RedeliverAfter(heldFor time.Duration) time.Duration {
|
||||||
|
switch {
|
||||||
|
case heldFor < 5*time.Second:
|
||||||
|
return time.Second
|
||||||
|
case heldFor < 30*time.Second:
|
||||||
|
return 5 * time.Second
|
||||||
|
default:
|
||||||
|
return 15 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The problem holding-in-the-server introduces, and why the answer was already in the message.
|
||||||
|
//
|
||||||
|
// Holding a delivery in memory let the controller do something a server cannot: when a newer
|
||||||
|
// report for the same node arrived, it dropped the older one, "because acting on it after the
|
||||||
|
// newer would undo the newer". A `nak`ed message is the server's, and the server will redeliver
|
||||||
|
// it whatever else has happened in the meantime — so the older report comes back *after* the
|
||||||
|
// newer was applied, and applying it would undo exactly what that comment describes.
|
||||||
|
//
|
||||||
|
// **A report already says which declaration it is about.** `Declared` is the digest of the exact
|
||||||
|
// bytes the mesh sent, and it exists because an earlier attempt to order reports by time lost the
|
||||||
|
// race it invited — an apply that started under the previous declaration finishes after the next
|
||||||
|
// is sent, and the report reads as newer than the send. Clocks cannot answer *which*; the digest
|
||||||
|
// is the answer itself.
|
||||||
|
//
|
||||||
|
// So supersession stops being a thing the controller remembers and becomes a thing it checks: a
|
||||||
|
// report whose digest is not the one outstanding for that node is stale, and is acknowledged
|
||||||
|
// without being acted on. Which is the same shape as a node refusing a superseded declaration by
|
||||||
|
// sequence (novox/hq issue 107) — ordering settled by what the message says, not by when it
|
||||||
|
// happened to arrive.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func window() StoreWindow { return StoreWindow{GiveUpAfter: 2 * time.Minute} }
|
||||||
|
|
||||||
|
// The guarantee itself (novox/hq ADR 0083): a push the store cannot record is held, not dropped
|
||||||
|
// and not falsely acknowledged.
|
||||||
|
func TestAMessageTheStoreCannotTakeYetIsHeld(t *testing.T) {
|
||||||
|
if got := window().Decide(ErrTryAgain, "", "", 0); got != Hold {
|
||||||
|
t.Fatalf("got %v; a push the store could not record was not held", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal is an answer. Holding it would turn a message the mesh understood into one it
|
||||||
|
// retries forever.
|
||||||
|
func TestARefusalIsNotHeld(t *testing.T) {
|
||||||
|
if got := window().Decide(errors.New("that node does not exist"), "", "", 0); got != Take {
|
||||||
|
t.Fatalf("got %v; a refusal was mistaken for the store being away", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held has a limit, and past it the message is settled rather than held for ever.
|
||||||
|
func TestAStoreThatNeverComesBackEndsTheHold(t *testing.T) {
|
||||||
|
if got := window().Decide(ErrTryAgain, "", "", 3*time.Minute); got != GiveUp {
|
||||||
|
t.Fatalf("got %v; the window has no end", got)
|
||||||
|
}
|
||||||
|
if got := window().Decide(ErrTryAgain, "", "", time.Minute); got != Hold {
|
||||||
|
t.Fatalf("got %v; the window ended early", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The problem that holding in the server introduces.** A naked message is redelivered whatever
|
||||||
|
// else happened meanwhile, so a report about a superseded declaration comes back after the newer
|
||||||
|
// one was applied — and applying it would undo the newer.
|
||||||
|
func TestAReportAboutASupersededDeclarationIsNotApplied(t *testing.T) {
|
||||||
|
got := window().Decide(nil, "digest-of-the-old-one", "digest-of-the-current-one", 0)
|
||||||
|
if got != Stale {
|
||||||
|
t.Fatalf("got %v; a redelivery that lost its race would have undone what came after", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReportAboutTheOutstandingDeclarationIsApplied(t *testing.T) {
|
||||||
|
if got := window().Decide(nil, "same", "same", 0); got != Take {
|
||||||
|
t.Fatalf("got %v; a current report was discarded", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message that is about no declaration — an enrolment, a build result — is never stale: there
|
||||||
|
// is nothing for it to be out of date with.
|
||||||
|
func TestAMessageAboutNoDeclarationIsNeverStale(t *testing.T) {
|
||||||
|
if got := window().Decide(nil, "", "whatever-is-outstanding", 0); got != Take {
|
||||||
|
t.Fatalf("got %v; an enrolment was treated as a stale report", got)
|
||||||
|
}
|
||||||
|
if got := window().Decide(nil, "a-digest", "", 0); got != Take {
|
||||||
|
t.Fatalf("got %v; a report was called stale against a node that was sent nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staleness is decided before the store is waited on: a redelivery that lost its race must not
|
||||||
|
// hold a slot in the window that a current message needs.
|
||||||
|
func TestAStaleMessageIsNotHeldForTheStore(t *testing.T) {
|
||||||
|
if got := window().Decide(ErrTryAgain, "old", "current", 0); got != Stale {
|
||||||
|
t.Fatalf("got %v; a superseded message was held for a store it would never be applied to", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delay backs off: a store that is gone is not helped by being asked every second, and the
|
||||||
|
// delay is what keeps a window of held messages from becoming a spin.
|
||||||
|
func TestRedeliveryBacksOff(t *testing.T) {
|
||||||
|
first := RedeliverAfter(0)
|
||||||
|
later := RedeliverAfter(10 * time.Second)
|
||||||
|
last := RedeliverAfter(time.Minute)
|
||||||
|
if !(first < later && later < last) {
|
||||||
|
t.Fatalf("delays do not back off: %v %v %v", first, later, last)
|
||||||
|
}
|
||||||
|
if last > 30*time.Second {
|
||||||
|
t.Fatalf("a held message waits %v between attempts, which is longer than a store restart", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user