Split the unit level from the integration level outside cmd, builder, link, catalogue, inventory; merge-check.sh times and bounds the unit level (issue 895)
This commit is contained in:
@@ -110,14 +110,18 @@ proxy-image:
|
||||
# after formatting never ran through it. The script is the one list of steps; this target only gives it
|
||||
# a database (MESH_TEST_POSTGRES, exported above).
|
||||
#
|
||||
# **Two levels** (issue 895): the unit level, `go test ./...` untagged, timed and held to a bound; and the integration
|
||||
# level, `-tags integration`, the tests that start a bus, a store or another process. Each is said with its time.
|
||||
#
|
||||
# **Packages in parallel, under the race detector, each test on a bus of its own** (internal/testbus),
|
||||
# as the script runs them: a bus per test, of the release the mesh runs, makes the suite the same in any
|
||||
# order, and the timeout bounds a hang to a failure with a stack, never a stalled gate.
|
||||
check: postgres
|
||||
@sh merge-check.sh ; status=$$? ; docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true ; exit $$status
|
||||
|
||||
# Without a database the store's tests skip rather than fail, so this is the honest subset and not
|
||||
# the gate.
|
||||
# The unit level alone (untagged: no bus, no store, no other process), which a developer runs continuously. The tests
|
||||
# that need a bus or the store carry `//go:build integration` and run in `check`, not here; without a database the
|
||||
# store's tests would skip rather than fail anyway.
|
||||
test:
|
||||
go test -timeout 15m ./...
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build integration
|
||||
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveADocumentPutUnderATagIsReadBackAndOnlyTheNewestIsKept(t *testing.T) {
|
||||
address := os.Getenv("MESH_TEST_REGISTRY")
|
||||
container := os.Getenv("MESH_TEST_REGISTRY_CONTAINER")
|
||||
if address == "" || container == "" {
|
||||
t.Skip("no MESH_TEST_REGISTRY / MESH_TEST_REGISTRY_CONTAINER; see the collector test's comment for the registry to raise")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store := Store{Address: address}
|
||||
repository := fmt.Sprintf("facts-live-%d", time.Now().UnixNano())
|
||||
|
||||
if _, _, err := store.GetTagged(ctx, repository, "latest"); !errors.Is(err, ErrNoTag) {
|
||||
t.Fatalf("nothing was put and the read said %v, not that nothing is there", err)
|
||||
}
|
||||
first := []byte(`{"facts":1,"taken":"first"}`)
|
||||
firstDigest, err := store.PutTagged(ctx, repository, "latest", "application/vnd.novox.mesh.facts.v1+json", first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := []byte(`{"facts":1,"taken":"second"}`)
|
||||
secondDigest, err := store.PutTagged(ctx, repository, "latest", "application/vnd.novox.mesh.facts.v1+json", second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, digest, err := store.GetTagged(ctx, repository, "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(second) || digest != secondDigest {
|
||||
t.Fatalf("read back %q (%s), not the newest put %q (%s)", got, digest, second, secondDigest)
|
||||
}
|
||||
|
||||
// The collector, as the store's nightly step runs it — with no `--delete-untagged`: the newest kept,
|
||||
// the replaced one taken because its manifest was let go of.
|
||||
out, err := exec.Command("docker", "exec", container, "registry", "garbage-collect",
|
||||
"/etc/docker/registry/config.yml").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("the collector did not run: %v\n%s", err, out)
|
||||
}
|
||||
if got, _, err := store.GetTagged(ctx, repository, "latest"); err != nil || string(got) != string(second) {
|
||||
t.Fatalf("after collection the newest reads %q, %v", got, err)
|
||||
}
|
||||
// On the store's disk, not as the running server answers: it caches blob descriptors in memory.
|
||||
onDisk := func(digest string) bool {
|
||||
hex := strings.TrimPrefix(digest, "sha256:")
|
||||
path := "/var/lib/registry/docker/registry/v2/blobs/sha256/" + hex[:2] + "/" + hex + "/data"
|
||||
return exec.Command("docker", "exec", container, "test", "-f", path).Run() == nil
|
||||
}
|
||||
if onDisk(firstDigest) {
|
||||
t.Errorf("the replaced snapshot %s is still in the store after collection; nothing would ever take it", firstDigest)
|
||||
}
|
||||
if !onDisk(secondDigest) {
|
||||
t.Errorf("the collector took the newest snapshot %s", secondDigest)
|
||||
}
|
||||
if !strings.HasPrefix(secondDigest, "sha256:") {
|
||||
t.Errorf("the digest said %q", secondDigest)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,8 @@ package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The facts snapshot is put under a tag and read back by it (novox/hq to-be 45 §9).
|
||||
@@ -17,63 +12,6 @@ import (
|
||||
// that a manifest put by tag is read by tag, that the layer comes back whole and checked, and that the
|
||||
// snapshot a newer one replaced is the collector's to take while the newest is kept. Raised as the
|
||||
// collector test above says, with MESH_TEST_REGISTRY and MESH_TEST_REGISTRY_CONTAINER.
|
||||
func TestLiveADocumentPutUnderATagIsReadBackAndOnlyTheNewestIsKept(t *testing.T) {
|
||||
address := os.Getenv("MESH_TEST_REGISTRY")
|
||||
container := os.Getenv("MESH_TEST_REGISTRY_CONTAINER")
|
||||
if address == "" || container == "" {
|
||||
t.Skip("no MESH_TEST_REGISTRY / MESH_TEST_REGISTRY_CONTAINER; see the collector test's comment for the registry to raise")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store := Store{Address: address}
|
||||
repository := fmt.Sprintf("facts-live-%d", time.Now().UnixNano())
|
||||
|
||||
if _, _, err := store.GetTagged(ctx, repository, "latest"); !errors.Is(err, ErrNoTag) {
|
||||
t.Fatalf("nothing was put and the read said %v, not that nothing is there", err)
|
||||
}
|
||||
first := []byte(`{"facts":1,"taken":"first"}`)
|
||||
firstDigest, err := store.PutTagged(ctx, repository, "latest", "application/vnd.novox.mesh.facts.v1+json", first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := []byte(`{"facts":1,"taken":"second"}`)
|
||||
secondDigest, err := store.PutTagged(ctx, repository, "latest", "application/vnd.novox.mesh.facts.v1+json", second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, digest, err := store.GetTagged(ctx, repository, "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(second) || digest != secondDigest {
|
||||
t.Fatalf("read back %q (%s), not the newest put %q (%s)", got, digest, second, secondDigest)
|
||||
}
|
||||
|
||||
// The collector, as the store's nightly step runs it — with no `--delete-untagged`: the newest kept,
|
||||
// the replaced one taken because its manifest was let go of.
|
||||
out, err := exec.Command("docker", "exec", container, "registry", "garbage-collect",
|
||||
"/etc/docker/registry/config.yml").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("the collector did not run: %v\n%s", err, out)
|
||||
}
|
||||
if got, _, err := store.GetTagged(ctx, repository, "latest"); err != nil || string(got) != string(second) {
|
||||
t.Fatalf("after collection the newest reads %q, %v", got, err)
|
||||
}
|
||||
// On the store's disk, not as the running server answers: it caches blob descriptors in memory.
|
||||
onDisk := func(digest string) bool {
|
||||
hex := strings.TrimPrefix(digest, "sha256:")
|
||||
path := "/var/lib/registry/docker/registry/v2/blobs/sha256/" + hex[:2] + "/" + hex + "/data"
|
||||
return exec.Command("docker", "exec", container, "test", "-f", path).Run() == nil
|
||||
}
|
||||
if onDisk(firstDigest) {
|
||||
t.Errorf("the replaced snapshot %s is still in the store after collection; nothing would ever take it", firstDigest)
|
||||
}
|
||||
if !onDisk(secondDigest) {
|
||||
t.Errorf("the collector took the newest snapshot %s", secondDigest)
|
||||
}
|
||||
if !strings.HasPrefix(secondDigest, "sha256:") {
|
||||
t.Errorf("the digest said %q", secondDigest)
|
||||
}
|
||||
}
|
||||
|
||||
// A store with no address is said, not dialled.
|
||||
func TestADocumentWithNowhereToGoIsRefusedByName(t *testing.T) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
kv, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket, err := kv.KeyValue(ctx, AskedBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := bucket.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := status.(*jetstream.KeyValueBucketStatus).StreamInfo()
|
||||
if status.History() != 1 || status.TTL() != AskedKeptFor || info.Config.MaxBytes <= 0 || info.Config.MaxBytes > 64<<20 {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMergeWindowsKeysExpireAndAWatcherHearsIt(t *testing.T) {
|
||||
controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(controller.Publish, "$KV."+MergeWindowBucket+".>") {
|
||||
t.Error("the controller may not write the merge window's bucket")
|
||||
}
|
||||
if !IsControllerBucket(MergeWindowBucket) || !slices.Contains(ControllerBuckets(), MergeWindowBucket) {
|
||||
t.Error("the merge window's bucket is not asserted as the controller's own")
|
||||
}
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, MergeWindowBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := kv.WatchAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
if _, err := kv.Create(ctx, "plan-1.quiet", []byte("x"), jetstream.KeyTTL(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil && e.Key() == "plan-1.quiet" && e.Operation() != jetstream.KeyValuePut {
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no watcher heard the key expire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRolloutsSilentKeysExpireAsAPurgeAndAReportsDeleteIsNot(t *testing.T) {
|
||||
controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(controller.Publish, "$KV."+RolloutSilentBucket+".>") {
|
||||
t.Error("the controller may not write the rollout's silent keys' bucket")
|
||||
}
|
||||
if !IsControllerBucket(RolloutSilentBucket) || !slices.Contains(ControllerBuckets(), RolloutSilentBucket) {
|
||||
t.Error("the rollout's silent keys' bucket is not asserted as the controller's own")
|
||||
}
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, RolloutSilentBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := kv.WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
if _, err := kv.Create(ctx, "plan-1.0.ace", []byte("x"), jetstream.KeyTTL(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := kv.Delete(ctx, "plan-1.0.ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Create(ctx, "plan-1.0.g14", []byte("x"), jetstream.KeyTTL(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Operation() == jetstream.KeyValuePut {
|
||||
continue
|
||||
}
|
||||
if e.Key() == "plan-1.0.ace" && e.Operation() != jetstream.KeyValueDelete {
|
||||
t.Fatalf("a report's delete was heard as %v", e.Operation())
|
||||
}
|
||||
if e.Key() == "plan-1.0.g14" {
|
||||
if e.Operation() != jetstream.KeyValuePurge {
|
||||
t.Fatalf("an expiry was heard as %v, not a purge", e.Operation())
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no watcher heard the key expire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSilentKeysBucketAgesAndAnOlderOneIsBroughtToIt(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// As the first build made it: no age.
|
||||
if _, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: RolloutSilentBucket, History: 1,
|
||||
MaxValueSize: 4 << 10, MaxBytes: 8 << 20, Storage: jetstream.FileStorage, LimitMarkerTTL: RolloutSilentMarkerTTL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatalf("a bucket made without the age is not brought to it: %v", err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, RolloutSilentBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := kv.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.TTL() != RolloutSilentKeptFor {
|
||||
t.Errorf("the bucket ages at %v; want %v", status.TTL(), RolloutSilentKeptFor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResetOfTheEventsConsumerLeavesTheMergesAndTheirConsumerAlone(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AssertMeshConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == EventsStream && c.Name == MergesConsumerName {
|
||||
if c.Resettable != "" || c.MaxDeliver != 0 || len(c.Filters) != 1 || c.Filters[0] != ControllerFollows[3] {
|
||||
t.Errorf("the merges consumer is %+v; want one filter, no max-deliver, not resettable", c)
|
||||
}
|
||||
}
|
||||
if c.Stream == EventsStream && c.Name == ControllerName {
|
||||
for _, f := range c.Filters {
|
||||
if f == ControllerFollows[3] {
|
||||
t.Errorf("the events consumer still follows the merges, which the merges consumer takes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[3], []byte(`{"commit":"c1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[0], []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge", merges, err)
|
||||
}
|
||||
if _, _, err := js.ResetConsumer(EventsStream, ControllerName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err = js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 || merges.Delivered.Stream != 0 {
|
||||
t.Fatalf("after the reset the merges consumer holds %+v (%v); want its merge left alone", merges, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeTheOldConsumerHadNotAcknowledgedIsTheMergesConsumers(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := &nats.ConsumerConfig{Durable: ControllerName, AckPolicy: nats.AckExplicitPolicy, AckWait: 30 * time.Second,
|
||||
MaxDeliver: 5, MaxAckPending: 1, DeliverSubject: "_DELIVER.controller.EVENTS", DeliverPolicy: nats.DeliverNewPolicy,
|
||||
FilterSubjects: append(append([]string(nil), ControllerFollows...), AgainFilter(ControllerName))}
|
||||
if _, err := js.Context().AddConsumer(EventsStream, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[3], []byte(`{"commit":"c1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AssertMeshConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge the old consumer never acknowledged", merges, err)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/nats-io/nats.go"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// **The controller may write every bucket it writes** (novox/hq to-be 45 §1, issue 269). Writing a
|
||||
@@ -100,34 +93,6 @@ func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||
|
||||
// The controller's record of what it asked the operator is bounded (correctness review of 2026-10-08): one
|
||||
// value a key, a month's age, and a size it cannot outgrow.
|
||||
func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
kv, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket, err := kv.KeyValue(ctx, AskedBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := bucket.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := status.(*jetstream.KeyValueBucketStatus).StreamInfo()
|
||||
if status.History() != 1 || status.TTL() != AskedKeptFor || info.Config.MaxBytes <= 0 || info.Config.MaxBytes > 64<<20 {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// **A listed mirror is no undeclared bucket** (ADR 0356 §3, ADR 0201 §16): the controller's list of its mirrors
|
||||
// stands for their declaration, so the report of state nothing declares never names one.
|
||||
@@ -273,232 +238,19 @@ func TestTheControllerMayAskMeshConfigInItsOwnName(t *testing.T) {
|
||||
// **The merge window is a bucket whose keys expire** (novox/hq ADR 0407 decision 1, issue 973): a key made with a
|
||||
// time to live goes when it is up, and a watcher on the bucket hears it go. The controller may write it, and it is
|
||||
// no undeclared state.
|
||||
func TestTheMergeWindowsKeysExpireAndAWatcherHearsIt(t *testing.T) {
|
||||
controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(controller.Publish, "$KV."+MergeWindowBucket+".>") {
|
||||
t.Error("the controller may not write the merge window's bucket")
|
||||
}
|
||||
if !IsControllerBucket(MergeWindowBucket) || !slices.Contains(ControllerBuckets(), MergeWindowBucket) {
|
||||
t.Error("the merge window's bucket is not asserted as the controller's own")
|
||||
}
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, MergeWindowBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := kv.WatchAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
if _, err := kv.Create(ctx, "plan-1.quiet", []byte("x"), jetstream.KeyTTL(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil && e.Key() == "plan-1.quiet" && e.Operation() != jetstream.KeyValuePut {
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no watcher heard the key expire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A rollout's wait for a machine is a bucket whose keys expire** (novox/hq ADR 0406 §5, issue 974): a key made with a
|
||||
// time to live goes when it is up, and a watcher hears it go as a purge; a key a report deleted is a delete. The
|
||||
// controller may write it, and it is no undeclared state.
|
||||
func TestTheRolloutsSilentKeysExpireAsAPurgeAndAReportsDeleteIsNot(t *testing.T) {
|
||||
controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(controller.Publish, "$KV."+RolloutSilentBucket+".>") {
|
||||
t.Error("the controller may not write the rollout's silent keys' bucket")
|
||||
}
|
||||
if !IsControllerBucket(RolloutSilentBucket) || !slices.Contains(ControllerBuckets(), RolloutSilentBucket) {
|
||||
t.Error("the rollout's silent keys' bucket is not asserted as the controller's own")
|
||||
}
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, RolloutSilentBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := kv.WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
if _, err := kv.Create(ctx, "plan-1.0.ace", []byte("x"), jetstream.KeyTTL(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := kv.Delete(ctx, "plan-1.0.ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Create(ctx, "plan-1.0.g14", []byte("x"), jetstream.KeyTTL(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Operation() == jetstream.KeyValuePut {
|
||||
continue
|
||||
}
|
||||
if e.Key() == "plan-1.0.ace" && e.Operation() != jetstream.KeyValueDelete {
|
||||
t.Fatalf("a report's delete was heard as %v", e.Operation())
|
||||
}
|
||||
if e.Key() == "plan-1.0.g14" {
|
||||
if e.Operation() != jetstream.KeyValuePurge {
|
||||
t.Fatalf("an expiry was heard as %v, not a purge", e.Operation())
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no watcher heard the key expire")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The silent keys' bucket ages**, or its delete markers would fill it (issue 974, review 1): every report deletes a key,
|
||||
// the delete leaves a marker, and with one value per key and no age the markers stay for ever. A bucket made before the
|
||||
// age was set is brought to it by the next start, which the server accepts as an update of an existing bucket.
|
||||
func TestTheSilentKeysBucketAgesAndAnOlderOneIsBroughtToIt(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// As the first build made it: no age.
|
||||
if _, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: RolloutSilentBucket, History: 1,
|
||||
MaxValueSize: 4 << 10, MaxBytes: 8 << 20, Storage: jetstream.FileStorage, LimitMarkerTTL: RolloutSilentMarkerTTL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatalf("a bucket made without the age is not brought to it: %v", err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, RolloutSilentBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := kv.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.TTL() != RolloutSilentKeptFor {
|
||||
t.Errorf("the bucket ages at %v; want %v", status.TTL(), RolloutSilentKeptFor)
|
||||
}
|
||||
}
|
||||
|
||||
// **The merges have a consumer of their own, which a reset of the events consumer leaves alone** (issue 973, novox/hq
|
||||
// ADR 0407 decision 3): healer H4 re-makes the events consumer from now, dropping what it holds pending; a merge
|
||||
// waiting in the merges consumer is not that consumer's, and nothing would catch it up.
|
||||
func TestAResetOfTheEventsConsumerLeavesTheMergesAndTheirConsumerAlone(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AssertMeshConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == EventsStream && c.Name == MergesConsumerName {
|
||||
if c.Resettable != "" || c.MaxDeliver != 0 || len(c.Filters) != 1 || c.Filters[0] != ControllerFollows[3] {
|
||||
t.Errorf("the merges consumer is %+v; want one filter, no max-deliver, not resettable", c)
|
||||
}
|
||||
}
|
||||
if c.Stream == EventsStream && c.Name == ControllerName {
|
||||
for _, f := range c.Filters {
|
||||
if f == ControllerFollows[3] {
|
||||
t.Errorf("the events consumer still follows the merges, which the merges consumer takes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[3], []byte(`{"commit":"c1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[0], []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge", merges, err)
|
||||
}
|
||||
if _, _, err := js.ResetConsumer(EventsStream, ControllerName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err = js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 || merges.Delivered.Stream != 0 {
|
||||
t.Fatalf("after the reset the merges consumer holds %+v (%v); want its merge left alone", merges, err)
|
||||
}
|
||||
}
|
||||
|
||||
// **Delivering this change loses no merge** (review of issue 973): a merge published under the old filter of the
|
||||
// `controller` consumer and never acknowledged by it is delivered by the merges consumer once the assertion has
|
||||
// made it, because it starts where `controller` has acknowledged up to, not from now.
|
||||
func TestAMergeTheOldConsumerHadNotAcknowledgedIsTheMergesConsumers(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := &nats.ConsumerConfig{Durable: ControllerName, AckPolicy: nats.AckExplicitPolicy, AckWait: 30 * time.Second,
|
||||
MaxDeliver: 5, MaxAckPending: 1, DeliverSubject: "_DELIVER.controller.EVENTS", DeliverPolicy: nats.DeliverNewPolicy,
|
||||
FilterSubjects: append(append([]string(nil), ControllerFollows...), AgainFilter(ControllerName))}
|
||||
if _, err := js.Context().AddConsumer(EventsStream, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Context().Publish(ControllerFollows[3], []byte(`{"commit":"c1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AssertMeshConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merges, err := js.Context().ConsumerInfo(EventsStream, MergesConsumerName)
|
||||
if err != nil || merges.NumPending != 1 {
|
||||
t.Fatalf("the merges consumer holds %v (%v); want the merge the old consumer never acknowledged", merges, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
func TestALogIsAssertedInPlaceAndNeverRemoved(t *testing.T) {
|
||||
bus := aLiveBus(t)
|
||||
l := Log{Module: "logtest", Name: "changes", MaxMiB: 1}
|
||||
if _, err := RaiseLogs(bus, []Log{l}); err != nil {
|
||||
t.Fatalf("a real server refused a module's log: %v", err)
|
||||
}
|
||||
js, err := jetstream.New(bus.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ack, err := js.Publish(ctx, l.Subject()+".7", []byte(`{"op":"opened"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an append to the log was refused: %v", err)
|
||||
}
|
||||
stream, err := js.Stream(ctx, l.Stream())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := stream.CachedInfo().Config
|
||||
want := l.Config()
|
||||
if have.Storage != want.Storage || have.Retention != want.Retention || have.MaxAge != 0 ||
|
||||
have.MaxMsgsPerSubject != -1 || have.MaxBytes != want.MaxBytes || have.MaxMsgSize != want.MaxMsgSize ||
|
||||
have.Discard != jetstream.DiscardNew || !have.AllowDirect || !have.DenyDelete || !have.DenyPurge ||
|
||||
have.Replicas != 1 || !slices.Equal(have.Subjects, want.Subjects) {
|
||||
t.Fatalf("the server holds the log as %+v", have)
|
||||
}
|
||||
if err := stream.DeleteMsg(ctx, ack.Sequence); err == nil {
|
||||
t.Fatal("an entry was deleted from a log")
|
||||
}
|
||||
l.MaxMiB = 2
|
||||
if _, err := RaiseLogs(bus, []Log{l}); err != nil {
|
||||
t.Fatalf("asserting the log again failed, so a restart would: %v", err)
|
||||
}
|
||||
info, err := stream.Info(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Config.MaxBytes != 2*1024*1024 {
|
||||
t.Fatalf("the changed cap was not brought to match: %d", info.Config.MaxBytes)
|
||||
}
|
||||
if info.State.Msgs < 1 {
|
||||
t.Fatal("asserting the log again lost what it held")
|
||||
}
|
||||
undeclared, err := RaiseLogs(bus, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(undeclared, l.Stream()) {
|
||||
t.Fatalf("a log nothing declares was not reported: %v", undeclared)
|
||||
}
|
||||
if _, err := js.Stream(ctx, l.Stream()); err != nil {
|
||||
t.Fatalf("a log nothing declares is gone: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -240,59 +238,3 @@ func stringsIn(f *ast.File) []string {
|
||||
// Against a real server: a log is created as its configuration says, asserting it again keeps what
|
||||
// it holds, a changed cap is brought to match in place, an entry cannot be deleted from it, and one
|
||||
// nothing declares any more is reported and stays.
|
||||
func TestALogIsAssertedInPlaceAndNeverRemoved(t *testing.T) {
|
||||
bus := aLiveBus(t)
|
||||
l := Log{Module: "logtest", Name: "changes", MaxMiB: 1}
|
||||
if _, err := RaiseLogs(bus, []Log{l}); err != nil {
|
||||
t.Fatalf("a real server refused a module's log: %v", err)
|
||||
}
|
||||
js, err := jetstream.New(bus.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ack, err := js.Publish(ctx, l.Subject()+".7", []byte(`{"op":"opened"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an append to the log was refused: %v", err)
|
||||
}
|
||||
stream, err := js.Stream(ctx, l.Stream())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := stream.CachedInfo().Config
|
||||
want := l.Config()
|
||||
if have.Storage != want.Storage || have.Retention != want.Retention || have.MaxAge != 0 ||
|
||||
have.MaxMsgsPerSubject != -1 || have.MaxBytes != want.MaxBytes || have.MaxMsgSize != want.MaxMsgSize ||
|
||||
have.Discard != jetstream.DiscardNew || !have.AllowDirect || !have.DenyDelete || !have.DenyPurge ||
|
||||
have.Replicas != 1 || !slices.Equal(have.Subjects, want.Subjects) {
|
||||
t.Fatalf("the server holds the log as %+v", have)
|
||||
}
|
||||
if err := stream.DeleteMsg(ctx, ack.Sequence); err == nil {
|
||||
t.Fatal("an entry was deleted from a log")
|
||||
}
|
||||
l.MaxMiB = 2
|
||||
if _, err := RaiseLogs(bus, []Log{l}); err != nil {
|
||||
t.Fatalf("asserting the log again failed, so a restart would: %v", err)
|
||||
}
|
||||
info, err := stream.Info(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Config.MaxBytes != 2*1024*1024 {
|
||||
t.Fatalf("the changed cap was not brought to match: %d", info.Config.MaxBytes)
|
||||
}
|
||||
if info.State.Msgs < 1 {
|
||||
t.Fatal("asserting the log again lost what it held")
|
||||
}
|
||||
undeclared, err := RaiseLogs(bus, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(undeclared, l.Stream()) {
|
||||
t.Fatalf("a log nothing declares was not reported: %v", undeclared)
|
||||
}
|
||||
if _, err := js.Stream(ctx, l.Stream()); err != nil {
|
||||
t.Fatalf("a log nothing declares is gone: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func TestABucketIsAssertedInPlace(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
b := Bucket{Module: "statetest", Name: "servers"}
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("a real server refused a module's bucket: %v", err)
|
||||
}
|
||||
kv, err := js.Context().KeyValue(b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.History = 3
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
|
||||
}
|
||||
got, err := kv.Get("all.one")
|
||||
if err != nil || string(got.Value()) != `{"kept":true}` {
|
||||
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
|
||||
}
|
||||
status, err := kv.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.History() != 3 {
|
||||
t.Fatalf("history is %d, the owner declared 3", status.History())
|
||||
}
|
||||
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
|
||||
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
|
||||
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
held := OnConn(js.Conn())
|
||||
if _, err := RaiseBuckets(held, []Bucket{{Module: "statetest", Name: "held"}}); err != nil {
|
||||
t.Fatalf("asserting over a held connection failed: %v", err)
|
||||
}
|
||||
if _, err := js.Context().KeyValue("statetest_held"); err != nil {
|
||||
t.Fatalf("the bucket is not there: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The grants measured against a running server (novox/hq research 024): an owner reads and writes
|
||||
@@ -131,54 +129,10 @@ func TestRaisingStateReportsWhatNothingDeclares(t *testing.T) {
|
||||
// Against a real server: a bucket is created with the owner's options and the mesh's caps,
|
||||
// asserting it again changes nothing and keeps what it holds, and a changed option is brought to
|
||||
// match in place.
|
||||
func TestABucketIsAssertedInPlace(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
b := Bucket{Module: "statetest", Name: "servers"}
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("a real server refused a module's bucket: %v", err)
|
||||
}
|
||||
kv, err := js.Context().KeyValue(b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.History = 3
|
||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
||||
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
|
||||
}
|
||||
got, err := kv.Get("all.one")
|
||||
if err != nil || string(got.Value()) != `{"kept":true}` {
|
||||
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
|
||||
}
|
||||
status, err := kv.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.History() != 3 {
|
||||
t.Fatalf("history is %d, the owner declared 3", status.History())
|
||||
}
|
||||
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
|
||||
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
|
||||
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Against a real server: the handle over a connection the control plane already holds asserts a
|
||||
// bucket as Dial's does — what a push uses, so a module registered since the last start has its
|
||||
// bucket before its membership names it.
|
||||
func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
|
||||
js := aLiveBus(t)
|
||||
held := OnConn(js.Conn())
|
||||
if _, err := RaiseBuckets(held, []Bucket{{Module: "statetest", Name: "held"}}); err != nil {
|
||||
t.Fatalf("asserting over a held connection failed: %v", err)
|
||||
}
|
||||
if _, err := js.Context().KeyValue("statetest_held"); err != nil {
|
||||
t.Fatalf("the bucket is not there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **State keyed by machine reaches its machine's key alone** (novox/hq ADR 0260): the owner's instance on
|
||||
// a machine writes and reads that key, and a bar granted a key reads that key — a direct get of its
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOnARealBusTheVaultsAccountIsAnsweredByTheDeskAndTheProviderAndNothingElse(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
// anchor's runtime serves the desk's prompt and plex's issuing tool, on every subject it is granted, and says
|
||||
// which subject it heard.
|
||||
holder, err := nats.Connect(s.ClientURL(), nats.UserInfo("anchor.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer holder.Close()
|
||||
heard := make(chan string, 8)
|
||||
answer := func(m *nats.Msg) {
|
||||
heard <- m.Subject
|
||||
_ = m.Respond([]byte(`{"result":{"sealed":"c2VhbGVkLXRvLXRoZS1jYWxs"}}`))
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor.*",
|
||||
"mesh.seat.node-launcher.tool.secret.anchor", "mesh.mod.plex.call.plex_issued_credential.anchor.*",
|
||||
"mesh.mod.other.call.tool.anchor.*", "mesh.seat.node-launcher.call.menu.anchor.*"} {
|
||||
sub, err := holder.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = holder.Flush()
|
||||
if !sub.IsValid() {
|
||||
t.Fatalf("the desk's runtime may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
vault, err := nats.Connect(s.ClientURL(), nats.UserInfo("novox.mesh-vault", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.novox.mesh-vault"),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("the vault's own account cannot connect: %v", err)
|
||||
}
|
||||
defer vault.Close()
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if _, err := vault.Request(subject, []byte(`{"seal_to":"a2V5LW9mLXRoZS1jYWxs"}`), 3*time.Second); err != nil {
|
||||
t.Fatalf("the vault's call %s was not answered: %v", subject, err)
|
||||
}
|
||||
if got := <-heard; got != subject {
|
||||
t.Fatalf("the holder heard %s, want %s", got, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.other.call.tool.anchor." + token} {
|
||||
if err := vault.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vault.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse the vault %s", subject)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case got := <-heard:
|
||||
t.Errorf("a call the vault may not make reached the holder: %s", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// The controller is still answered on today's path, for the modules filled the old way (until R1).
|
||||
controller, err := nats.Connect(s.ClientURL(), nats.UserInfo("controller", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.controller"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Close()
|
||||
if _, err := controller.Request("mesh.seat.node-launcher.tool.secret.anchor", []byte(`{}`), 3*time.Second); err != nil {
|
||||
t.Errorf("the controller's call to the desk on today's path: %v", err)
|
||||
}
|
||||
<-heard
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// vaultRecords is a mesh of two machines: anchor, a desk whose runtime carries the desk's prompt (rofi holding
|
||||
@@ -111,117 +103,3 @@ func TestTheVaultAsksTheDeskAndTheIssuingToolsInItsOwnNameAlone(t *testing.T) {
|
||||
// **On a real server, as composed** (issue 723): the vault's account asks the desk's prompt and an issuing tool
|
||||
// and is answered, naming itself; the server refuses it any other call; and the controller is still answered on
|
||||
// today's path. That only sealed values cross is the vault's test (mesh-catalog, mesh-vault intake_test.go).
|
||||
func TestOnARealBusTheVaultsAccountIsAnsweredByTheDeskAndTheProviderAndNothingElse(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
// anchor's runtime serves the desk's prompt and plex's issuing tool, on every subject it is granted, and says
|
||||
// which subject it heard.
|
||||
holder, err := nats.Connect(s.ClientURL(), nats.UserInfo("anchor.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer holder.Close()
|
||||
heard := make(chan string, 8)
|
||||
answer := func(m *nats.Msg) {
|
||||
heard <- m.Subject
|
||||
_ = m.Respond([]byte(`{"result":{"sealed":"c2VhbGVkLXRvLXRoZS1jYWxs"}}`))
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor.*",
|
||||
"mesh.seat.node-launcher.tool.secret.anchor", "mesh.mod.plex.call.plex_issued_credential.anchor.*",
|
||||
"mesh.mod.other.call.tool.anchor.*", "mesh.seat.node-launcher.call.menu.anchor.*"} {
|
||||
sub, err := holder.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = holder.Flush()
|
||||
if !sub.IsValid() {
|
||||
t.Fatalf("the desk's runtime may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
vault, err := nats.Connect(s.ClientURL(), nats.UserInfo("novox.mesh-vault", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.novox.mesh-vault"),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("the vault's own account cannot connect: %v", err)
|
||||
}
|
||||
defer vault.Close()
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if _, err := vault.Request(subject, []byte(`{"seal_to":"a2V5LW9mLXRoZS1jYWxs"}`), 3*time.Second); err != nil {
|
||||
t.Fatalf("the vault's call %s was not answered: %v", subject, err)
|
||||
}
|
||||
if got := <-heard; got != subject {
|
||||
t.Fatalf("the holder heard %s, want %s", got, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.other.call.tool.anchor." + token} {
|
||||
if err := vault.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vault.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse the vault %s", subject)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case got := <-heard:
|
||||
t.Errorf("a call the vault may not make reached the holder: %s", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// The controller is still answered on today's path, for the modules filled the old way (until R1).
|
||||
controller, err := nats.Connect(s.ClientURL(), nats.UserInfo("controller", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.controller"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Close()
|
||||
if _, err := controller.Request("mesh.seat.node-launcher.tool.secret.anchor", []byte(`{}`), 3*time.Second); err != nil {
|
||||
t.Errorf("the controller's call to the desk on today's path: %v", err)
|
||||
}
|
||||
<-heard
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package broker
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package conditions
|
||||
|
||||
import (
|
||||
|
||||
@@ -53,14 +53,15 @@ const Seat = "mesh-controller"
|
||||
|
||||
// Keeper raises, observes, silences and clears conditions, and says each transition.
|
||||
type Keeper struct {
|
||||
store Backend
|
||||
looks LookStore
|
||||
history History
|
||||
teller Teller
|
||||
now func() time.Time
|
||||
say func(format string, args ...any)
|
||||
changed func()
|
||||
epoch func() (uint64, error)
|
||||
store Backend
|
||||
looks LookStore
|
||||
history History
|
||||
teller Teller
|
||||
now func() time.Time
|
||||
say func(format string, args ...any)
|
||||
changed func()
|
||||
epoch func() (uint64, error)
|
||||
tellBackoff time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
// cleared is when each recently cleared condition cleared and how often it had been raised, so
|
||||
@@ -108,6 +109,9 @@ type Options struct {
|
||||
// Epoch is the controller lease this keeper writes under (to-be 45 §6): every write carries its
|
||||
// epoch, and none is made while it is not held. Nil writes with no epoch (a test, the memory store).
|
||||
Epoch func() (uint64, error)
|
||||
// TellBackoff is the first wait before a transition the bus would not take is offered again; it doubles each
|
||||
// time. Zero is 200 ms; a test sets it short so that it need not wait.
|
||||
TellBackoff time.Duration
|
||||
}
|
||||
|
||||
// TellFor is how long one transition is offered to the bus before it is said lost.
|
||||
@@ -117,7 +121,7 @@ var TellFor = 10 * time.Minute
|
||||
// that cleared just before this controller started and is raised again now is a reopening.
|
||||
func NewKeeper(ctx context.Context, o Options) *Keeper {
|
||||
k := &Keeper{store: o.Store, looks: o.Looks, history: o.History, teller: o.Teller, now: o.Now, say: o.Say, changed: o.Changed,
|
||||
epoch: o.Epoch,
|
||||
epoch: o.Epoch, tellBackoff: o.TellBackoff,
|
||||
cleared: map[string]clearing{}, reworded: map[string]bool{}, out: make(chan Event, 1024), drained: make(chan struct{})}
|
||||
if k.now == nil {
|
||||
k.now = time.Now
|
||||
@@ -713,7 +717,10 @@ func (k *Keeper) telling() {
|
||||
continue
|
||||
}
|
||||
deadline := time.Now().Add(TellFor)
|
||||
wait := 200 * time.Millisecond
|
||||
wait := k.tellBackoff
|
||||
if wait <= 0 {
|
||||
wait = 200 * time.Millisecond
|
||||
}
|
||||
kept, said := k.history == nil, k.teller == nil
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -370,12 +370,12 @@ func TestTheEventShapeIsTheContract(t *testing.T) {
|
||||
func TestATransitionIsOfferedAgainWhileTheBusIsAway(t *testing.T) {
|
||||
store, told, c := NewInMemory(), &Told{}, newClock()
|
||||
told.SetFail(errors.New("no responders"))
|
||||
k := NewKeeper(t.Context(), Options{Store: store, History: store, Teller: told, Now: c.now})
|
||||
k := NewKeeper(t.Context(), Options{Store: store, History: store, Teller: told, Now: c.now, TellBackoff: 5 * time.Millisecond})
|
||||
defer k.Close(context.Background())
|
||||
if _, err := k.Observe(t.Context(), silent("ace")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
told.SetFail(nil)
|
||||
said := settled(t, told, 1)
|
||||
if said[0].Key != "machine.ace.silent" || k.Unsaid() != 0 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package identity
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package identity
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package lease
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package licences
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package licences
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package licences
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build integration
|
||||
|
||||
package lint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// judgeUnitRun runs merge-check.sh's judging of the unit level on a recorded `go test -v` output (issue 895), as it
|
||||
// would judge the output of the untagged run: it starts the shell, so it is an integration test.
|
||||
func judgeUnitRun(t *testing.T, recorded string, bound string) (string, error) {
|
||||
t.Helper()
|
||||
out := filepath.Join(t.TempDir(), "unit.out")
|
||||
if err := os.WriteFile(out, []byte(recorded), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", "merge-check.sh", "judge-unit-run", out, bound)
|
||||
cmd.Dir = filepath.Join("..", "..")
|
||||
said, err := cmd.CombinedOutput()
|
||||
return string(said), err
|
||||
}
|
||||
|
||||
const recordedUnitRun = `=== RUN TestQuick
|
||||
--- PASS: TestQuick (0.01s)
|
||||
=== RUN TestSlowOne
|
||||
--- PASS: TestSlowOne (2.50s)
|
||||
=== RUN TestSlowTwo
|
||||
--- PASS: TestSlowTwo/sub (1.00s)
|
||||
--- PASS: TestSlowTwo (1.20s)
|
||||
--- SKIP: TestNeedsAStore (0.00s)
|
||||
PASS
|
||||
ok example.test/internal/one 2.80s
|
||||
=== RUN TestElsewhere
|
||||
--- PASS: TestElsewhere (0.30s)
|
||||
PASS
|
||||
ok example.test/internal/two 0.35s
|
||||
? example.test/internal/three [no test files]
|
||||
`
|
||||
|
||||
// **The unit level over its bound fails the check and names the slowest tests** (issue 895).
|
||||
func TestAUnitRunOverItsBoundFailsAndNamesTheSlowestTests(t *testing.T) {
|
||||
said, err := judgeUnitRun(t, recordedUnitRun, "1")
|
||||
if err == nil {
|
||||
t.Fatalf("a unit run of 2.80 s passed a bound of 1 s: %s", said)
|
||||
}
|
||||
for _, want := range []string{"5 tests (1 skipped)", "2.80 s (bound 1 s)", "TestSlowOne", "TestSlowTwo", "example.test/internal/one"} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("the verdict does not say %q: %s", want, said)
|
||||
}
|
||||
}
|
||||
if strings.Index(said, "TestSlowOne") > strings.Index(said, "TestSlowTwo") {
|
||||
t.Errorf("the slowest test is not named first: %s", said)
|
||||
}
|
||||
if strings.Contains(said, "TestSlowTwo/sub") {
|
||||
t.Errorf("a subtest is named as if it were a test of its own: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// A unit run within its bound passes and says its count and time.
|
||||
func TestAUnitRunWithinItsBoundPasses(t *testing.T) {
|
||||
said, err := judgeUnitRun(t, recordedUnitRun, "3")
|
||||
if err != nil {
|
||||
t.Fatalf("a unit run of 2.80 s failed a bound of 3 s: %v: %s", err, said)
|
||||
}
|
||||
if !strings.Contains(said, "unit level: 5 tests (1 skipped), 2.80 s (bound 3 s)") {
|
||||
t.Errorf("the level's count and time are not said: %s", said)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//go:build integration
|
||||
|
||||
package recordmirror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func aMirrorBus(t *testing.T) (*broker.JetStream, jetstream.JetStream) {
|
||||
t.Helper()
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js, api
|
||||
}
|
||||
|
||||
func TestNatsAMirrorChangesOneKeyAndRaisesOneEventPerChange(t *testing.T) {
|
||||
for _, m := range []Mirror{Assignments, DeclaredConfig} {
|
||||
t.Run(m.Name, func(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
events := listen(t, js.Conn(), "mesh.seat."+broker.ControllerSeat+".event."+m.Event)
|
||||
w := aWriter(t, js, api, m)
|
||||
ctx := t.Context()
|
||||
|
||||
first := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}
|
||||
if _, err := w.Sync(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, m.Bucket)
|
||||
heardBefore := len(settle(events, 1))
|
||||
|
||||
// One record changes, one is added: two keys, two events, nothing else.
|
||||
second := map[string][]byte{"anchor.gitea": []byte(`{"a":2}`), "anchor.forgejo": []byte(`{"a":1}`)}
|
||||
r, err := w.Sync(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Written != 2 || r.Removed != 0 {
|
||||
t.Errorf("a pass over one changed and one new record wrote %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 2 {
|
||||
t.Errorf("the bucket took %d writes for two changed records", got)
|
||||
}
|
||||
said := settle(events, heardBefore+2)[heardBefore:]
|
||||
if len(said) != 2 {
|
||||
t.Fatalf("two changes raised %d events: %+v", len(said), said)
|
||||
}
|
||||
changes := map[string]Event{}
|
||||
for _, e := range said {
|
||||
changes[e.Key] = e
|
||||
}
|
||||
if e := changes["anchor.gitea"]; e.Change != Changed || !slices.Equal(e.Fields, []string{"a"}) ||
|
||||
e.Mirror != m.Name || e.Epoch != 7 || e.Revision == 0 {
|
||||
t.Errorf("the change of anchor.gitea was said as %+v", e)
|
||||
}
|
||||
if e := changes["anchor.forgejo"]; e.Change != Added {
|
||||
t.Errorf("the new anchor.forgejo was said as %+v", e)
|
||||
}
|
||||
|
||||
// **An unchanged pass writes nothing**, and says nothing (ADR 0289 §1).
|
||||
heardBefore = len(events.all())
|
||||
before = writes(t, api, m.Bucket)
|
||||
r, err = w.Sync(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{}) {
|
||||
t.Errorf("a pass over unchanged records did %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 0 {
|
||||
t.Errorf("a pass over unchanged records wrote the bucket %d times", got)
|
||||
}
|
||||
if said := settle(events, heardBefore+1)[heardBefore:]; len(said) != 0 {
|
||||
t.Errorf("a pass over unchanged records raised %+v", said)
|
||||
}
|
||||
|
||||
// **A record gone is a purge**, one write and one `removed`.
|
||||
before = writes(t, api, m.Bucket)
|
||||
r, err = w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Removed != 1 || r.Written != 0 {
|
||||
t.Errorf("a record gone did %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 1 {
|
||||
t.Errorf("a record gone wrote the bucket %d times", got)
|
||||
}
|
||||
if _, err := w.KV.Get(ctx, "anchor.forgejo"); !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
t.Errorf("the key of a record gone is still read: %v", err)
|
||||
}
|
||||
said = settle(events, heardBefore+1)[heardBefore:]
|
||||
if len(said) != 1 || said[0].Change != Removed || said[0].Key != "anchor.forgejo" {
|
||||
t.Errorf("a record gone was said as %+v", said)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatsAWriterStartedAgainWritesOnlyWhatDiffers(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
first := aWriter(t, js, api, Assignments)
|
||||
held := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`), "anchor.forgejo": []byte(`{"a":1}`)}
|
||||
if _, err := first.Sync(ctx, held); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
again := aWriter(t, js, api, Assignments)
|
||||
r, err := again.Sync(ctx, held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{}) || writes(t, api, Assignments.Bucket) != before {
|
||||
t.Errorf("a writer started again over unchanged records did %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatsAWriteRefusedByCompareAndSetReadsAgain(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
if _, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Behind its back, as a former lease holder would.
|
||||
if _, err := w.KV.Put(ctx, "anchor.gitea", []byte(`{"a":9}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Written != 1 {
|
||||
t.Errorf("a refused write was not made again: %+v", r)
|
||||
}
|
||||
e, err := w.KV.Get(ctx, "anchor.gitea")
|
||||
if err != nil || string(e.Value()) != `{"a":2}` {
|
||||
t.Fatalf("the bucket holds %v, %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatsAForeignWriteIsCorrectedOnTheNextPass(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
events := listen(t, js.Conn(), "mesh.seat."+broker.ControllerSeat+".event."+Assignments.Event)
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
store := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}
|
||||
if _, err := w.Sync(ctx, store); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heardBefore := len(settle(events, 1))
|
||||
if _, err := w.KV.Put(ctx, "anchor.gitea", []byte(`{"a":9}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := w.Sync(ctx, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{Written: 1}) {
|
||||
t.Errorf("a pass after a foreign write did %+v", r)
|
||||
}
|
||||
e, err := w.KV.Get(ctx, "anchor.gitea")
|
||||
if err != nil || string(e.Value()) != `{"a":1}` {
|
||||
t.Fatalf("the bucket still holds %v, %v", e, err)
|
||||
}
|
||||
if said := settle(events, heardBefore+1)[heardBefore:]; len(said) != 1 || said[0].Change != Changed {
|
||||
t.Errorf("the correction was said as %+v", said)
|
||||
}
|
||||
// And put right, it stays: the next pass writes nothing.
|
||||
if r, err := w.Sync(ctx, store); err != nil || r != (Result{}) {
|
||||
t.Errorf("a pass after the correction did %+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatsAWriterWithoutTheLeaseWritesNothing(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
held := aWriter(t, js, api, Assignments)
|
||||
if _, err := held.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
for _, holds := range []func() bool{nil, func() bool { return false }} {
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = holds
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`), "anchor.forgejo": []byte(`{"a":1}`)})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) {
|
||||
t.Errorf("a writer without the lease did %+v, %v", r, err)
|
||||
}
|
||||
r, err = w.Sync(ctx, map[string][]byte{})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) {
|
||||
t.Errorf("a writer without the lease removed: %+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
// And one that loses it mid-pass stops before its next write.
|
||||
asked := 0
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = func() bool { asked++; return asked <= 2 }
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.a": []byte(`{}`), "anchor.b": []byte(`{}`), "anchor.gitea": []byte(`{"a":1}`)})
|
||||
if !errors.Is(err, ErrNotHeld) || r.Written != 1 {
|
||||
t.Errorf("a writer that lost the lease mid-pass did %+v, %v", r, err)
|
||||
}
|
||||
if got := writes(t, api, Assignments.Bucket) - before; got != 1 {
|
||||
t.Errorf("writers without the lease wrote the bucket %d times", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatsARemovalWithoutTheLeaseRemovesNothing(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
held := aWriter(t, js, api, Assignments)
|
||||
if _, err := held.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
asked := 0
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = func() bool { asked++; return asked == 1 }
|
||||
r, err := w.Sync(ctx, map[string][]byte{})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) || asked != 2 {
|
||||
t.Errorf("a removal without the lease did %+v, %v (the lease asked %d times)", r, err, asked)
|
||||
}
|
||||
if got := writes(t, api, Assignments.Bucket) - before; got != 0 {
|
||||
t.Errorf("a removal without the lease wrote the bucket %d times", got)
|
||||
}
|
||||
if _, err := w.KV.Get(ctx, "anchor.gitea"); err != nil {
|
||||
t.Errorf("the key was removed without the lease: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package recordmirror
|
||||
|
||||
import (
|
||||
|
||||
@@ -2,7 +2,6 @@ package recordmirror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// A record mirror against a real bus (novox/hq design 54 §2, §3; ADR 0289): a change of a record changes
|
||||
@@ -22,25 +20,6 @@ import (
|
||||
|
||||
// aMirrorBus is a bus of this test alone with the mesh's streams and the controller's buckets, as the
|
||||
// controller asserts them at its start, and a counter of the writes made to one bucket.
|
||||
func aMirrorBus(t *testing.T) (*broker.JetStream, jetstream.JetStream) {
|
||||
t.Helper()
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js, api
|
||||
}
|
||||
|
||||
// heard counts what is said on one subject, each body kept.
|
||||
type heard struct {
|
||||
@@ -112,230 +91,20 @@ func aWriter(t *testing.T, js *broker.JetStream, api jetstream.JetStream, m Mirr
|
||||
Epoch: func() uint64 { return 7 }, Holds: func() bool { return true }}
|
||||
}
|
||||
|
||||
func TestNatsAMirrorChangesOneKeyAndRaisesOneEventPerChange(t *testing.T) {
|
||||
for _, m := range []Mirror{Assignments, DeclaredConfig} {
|
||||
t.Run(m.Name, func(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
events := listen(t, js.Conn(), "mesh.seat."+broker.ControllerSeat+".event."+m.Event)
|
||||
w := aWriter(t, js, api, m)
|
||||
ctx := t.Context()
|
||||
|
||||
first := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}
|
||||
if _, err := w.Sync(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, m.Bucket)
|
||||
heardBefore := len(settle(events, 1))
|
||||
|
||||
// One record changes, one is added: two keys, two events, nothing else.
|
||||
second := map[string][]byte{"anchor.gitea": []byte(`{"a":2}`), "anchor.forgejo": []byte(`{"a":1}`)}
|
||||
r, err := w.Sync(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Written != 2 || r.Removed != 0 {
|
||||
t.Errorf("a pass over one changed and one new record wrote %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 2 {
|
||||
t.Errorf("the bucket took %d writes for two changed records", got)
|
||||
}
|
||||
said := settle(events, heardBefore+2)[heardBefore:]
|
||||
if len(said) != 2 {
|
||||
t.Fatalf("two changes raised %d events: %+v", len(said), said)
|
||||
}
|
||||
changes := map[string]Event{}
|
||||
for _, e := range said {
|
||||
changes[e.Key] = e
|
||||
}
|
||||
if e := changes["anchor.gitea"]; e.Change != Changed || !slices.Equal(e.Fields, []string{"a"}) ||
|
||||
e.Mirror != m.Name || e.Epoch != 7 || e.Revision == 0 {
|
||||
t.Errorf("the change of anchor.gitea was said as %+v", e)
|
||||
}
|
||||
if e := changes["anchor.forgejo"]; e.Change != Added {
|
||||
t.Errorf("the new anchor.forgejo was said as %+v", e)
|
||||
}
|
||||
|
||||
// **An unchanged pass writes nothing**, and says nothing (ADR 0289 §1).
|
||||
heardBefore = len(events.all())
|
||||
before = writes(t, api, m.Bucket)
|
||||
r, err = w.Sync(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{}) {
|
||||
t.Errorf("a pass over unchanged records did %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 0 {
|
||||
t.Errorf("a pass over unchanged records wrote the bucket %d times", got)
|
||||
}
|
||||
if said := settle(events, heardBefore+1)[heardBefore:]; len(said) != 0 {
|
||||
t.Errorf("a pass over unchanged records raised %+v", said)
|
||||
}
|
||||
|
||||
// **A record gone is a purge**, one write and one `removed`.
|
||||
before = writes(t, api, m.Bucket)
|
||||
r, err = w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Removed != 1 || r.Written != 0 {
|
||||
t.Errorf("a record gone did %+v", r)
|
||||
}
|
||||
if got := writes(t, api, m.Bucket) - before; got != 1 {
|
||||
t.Errorf("a record gone wrote the bucket %d times", got)
|
||||
}
|
||||
if _, err := w.KV.Get(ctx, "anchor.forgejo"); !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
t.Errorf("the key of a record gone is still read: %v", err)
|
||||
}
|
||||
said = settle(events, heardBefore+1)[heardBefore:]
|
||||
if len(said) != 1 || said[0].Change != Removed || said[0].Key != "anchor.forgejo" {
|
||||
t.Errorf("a record gone was said as %+v", said)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A controller started again writes nothing for what the bucket already holds** (design 54 §2: the writer's
|
||||
// memory is filled from the bucket), and a key nobody holds a record for is removed.
|
||||
func TestNatsAWriterStartedAgainWritesOnlyWhatDiffers(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
first := aWriter(t, js, api, Assignments)
|
||||
held := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`), "anchor.forgejo": []byte(`{"a":1}`)}
|
||||
if _, err := first.Sync(ctx, held); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
again := aWriter(t, js, api, Assignments)
|
||||
r, err := again.Sync(ctx, held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{}) || writes(t, api, Assignments.Bucket) != before {
|
||||
t.Errorf("a writer started again over unchanged records did %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// **Another writer in between is no lost value** (design 54 §2, compare-and-set): a write refused because the
|
||||
// key moved reads it again, takes the revision when it already holds the value, and otherwise writes again.
|
||||
func TestNatsAWriteRefusedByCompareAndSetReadsAgain(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
if _, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Behind its back, as a former lease holder would.
|
||||
if _, err := w.KV.Put(ctx, "anchor.gitea", []byte(`{"a":9}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Written != 1 {
|
||||
t.Errorf("a refused write was not made again: %+v", r)
|
||||
}
|
||||
e, err := w.KV.Get(ctx, "anchor.gitea")
|
||||
if err != nil || string(e.Value()) != `{"a":2}` {
|
||||
t.Fatalf("the bucket holds %v, %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A value written behind the writer's back is put right on the next pass** (ADR 0289 §10): a former lease
|
||||
// holder's write differs from the store, and the store's value is written again, said as one change.
|
||||
func TestNatsAForeignWriteIsCorrectedOnTheNextPass(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
events := listen(t, js.Conn(), "mesh.seat."+broker.ControllerSeat+".event."+Assignments.Event)
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
store := map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}
|
||||
if _, err := w.Sync(ctx, store); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heardBefore := len(settle(events, 1))
|
||||
if _, err := w.KV.Put(ctx, "anchor.gitea", []byte(`{"a":9}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := w.Sync(ctx, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != (Result{Written: 1}) {
|
||||
t.Errorf("a pass after a foreign write did %+v", r)
|
||||
}
|
||||
e, err := w.KV.Get(ctx, "anchor.gitea")
|
||||
if err != nil || string(e.Value()) != `{"a":1}` {
|
||||
t.Fatalf("the bucket still holds %v, %v", e, err)
|
||||
}
|
||||
if said := settle(events, heardBefore+1)[heardBefore:]; len(said) != 1 || said[0].Change != Changed {
|
||||
t.Errorf("the correction was said as %+v", said)
|
||||
}
|
||||
// And put right, it stays: the next pass writes nothing.
|
||||
if r, err := w.Sync(ctx, store); err != nil || r != (Result{}) {
|
||||
t.Errorf("a pass after the correction did %+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A writer without the lease writes nothing** (ADR 0289 §9, design 54 §2): not a new key, not a change, not
|
||||
// a removal, and it says so.
|
||||
func TestNatsAWriterWithoutTheLeaseWritesNothing(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
held := aWriter(t, js, api, Assignments)
|
||||
if _, err := held.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
for _, holds := range []func() bool{nil, func() bool { return false }} {
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = holds
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":2}`), "anchor.forgejo": []byte(`{"a":1}`)})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) {
|
||||
t.Errorf("a writer without the lease did %+v, %v", r, err)
|
||||
}
|
||||
r, err = w.Sync(ctx, map[string][]byte{})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) {
|
||||
t.Errorf("a writer without the lease removed: %+v, %v", r, err)
|
||||
}
|
||||
}
|
||||
// And one that loses it mid-pass stops before its next write.
|
||||
asked := 0
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = func() bool { asked++; return asked <= 2 }
|
||||
r, err := w.Sync(ctx, map[string][]byte{"anchor.a": []byte(`{}`), "anchor.b": []byte(`{}`), "anchor.gitea": []byte(`{"a":1}`)})
|
||||
if !errors.Is(err, ErrNotHeld) || r.Written != 1 {
|
||||
t.Errorf("a writer that lost the lease mid-pass did %+v, %v", r, err)
|
||||
}
|
||||
if got := writes(t, api, Assignments.Bucket) - before; got != 1 {
|
||||
t.Errorf("writers without the lease wrote the bucket %d times", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A removal asks for the lease too** (ADR 0289 §9): a writer that held the lease when its pass began and lost
|
||||
// it before its first removal purges nothing, and says it does not hold the lease.
|
||||
func TestNatsARemovalWithoutTheLeaseRemovesNothing(t *testing.T) {
|
||||
js, api := aMirrorBus(t)
|
||||
ctx := t.Context()
|
||||
held := aWriter(t, js, api, Assignments)
|
||||
if _, err := held.Sync(ctx, map[string][]byte{"anchor.gitea": []byte(`{"a":1}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := writes(t, api, Assignments.Bucket)
|
||||
asked := 0
|
||||
w := aWriter(t, js, api, Assignments)
|
||||
w.Holds = func() bool { asked++; return asked == 1 }
|
||||
r, err := w.Sync(ctx, map[string][]byte{})
|
||||
if !errors.Is(err, ErrNotHeld) || r != (Result{}) || asked != 2 {
|
||||
t.Errorf("a removal without the lease did %+v, %v (the lease asked %d times)", r, err, asked)
|
||||
}
|
||||
if got := writes(t, api, Assignments.Bucket) - before; got != 0 {
|
||||
t.Errorf("a removal without the lease wrote the bucket %d times", got)
|
||||
}
|
||||
if _, err := w.KV.Get(ctx, "anchor.gitea"); err != nil {
|
||||
t.Errorf("the key was removed without the lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A key the bus cannot hold is refused alone**, and the others are written.
|
||||
func TestAKeyTheBusCannotHoldIsRefusedAlone(t *testing.T) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package recordmirror
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build integration
|
||||
|
||||
package testbus
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func TestEveryTestHasABusOfItsOwn(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2; i++ {
|
||||
t.Run("one", func(t *testing.T) {
|
||||
url := URL(t)
|
||||
if seen[url] {
|
||||
t.Fatalf("two tests were handed %s", url)
|
||||
}
|
||||
seen[url] = true
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.StreamInfo("EVENTS"); err == nil {
|
||||
t.Fatal("a stream an earlier test made is here")
|
||||
}
|
||||
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Asked twice, one test reaches one bus.
|
||||
if a, b := URL(t), URL(t); a != b {
|
||||
t.Errorf("one test was handed two buses: %s and %s", a, b)
|
||||
}
|
||||
if v := strings.Count(Version, "."); v != 2 {
|
||||
t.Errorf("the server's release reads %q", Version)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
@@ -59,37 +57,3 @@ func TestTheBusTheTestsRunIsTheBusTheMeshRuns(t *testing.T) {
|
||||
}
|
||||
|
||||
// A test's bus is its own: what one test makes, the next never sees.
|
||||
func TestEveryTestHasABusOfItsOwn(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2; i++ {
|
||||
t.Run("one", func(t *testing.T) {
|
||||
url := URL(t)
|
||||
if seen[url] {
|
||||
t.Fatalf("two tests were handed %s", url)
|
||||
}
|
||||
seen[url] = true
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.StreamInfo("EVENTS"); err == nil {
|
||||
t.Fatal("a stream an earlier test made is here")
|
||||
}
|
||||
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Asked twice, one test reaches one bus.
|
||||
if a, b := URL(t), URL(t); a != b {
|
||||
t.Errorf("one test was handed two buses: %s and %s", a, b)
|
||||
}
|
||||
if v := strings.Count(Version, "."); v != 2 {
|
||||
t.Errorf("the server's release reads %q", Version)
|
||||
}
|
||||
}
|
||||
|
||||
+83
-9
@@ -9,15 +9,28 @@
|
||||
# seat's first layer (`mesh/merge-gate`), run because the mesh's module graph builds `mesh-controller`
|
||||
# from this repository; it is not repeated here. This is the code's own quality and its suite:
|
||||
#
|
||||
# 1. formatted and vetted;
|
||||
# 2. the whole suite, packages in parallel, against the throwaway store the build seat raised at the
|
||||
# release the mesh runs; every live test on a bus of its own (internal/testbus), so nothing is
|
||||
# serialised. Under the race detector when the toolchain has a C compiler, and said when it has not.
|
||||
# 1. formatted and vetted, with and without the build tag `integration`;
|
||||
# 2. the unit level: `go test ./...` untagged and without the race detector, timed and held to a bound
|
||||
# (unit_bound below): a test with no database, bus, running service or other process (novox/hq ADR 0391,
|
||||
# how-we-build section 5). Over the bound the check fails and names the slowest tests (issue 895);
|
||||
# 3. the integration level: the whole suite with `-tags integration`, packages in parallel, against the
|
||||
# throwaway store the build seat raised at the release the mesh runs; every live test on a bus of its own
|
||||
# (internal/testbus), so nothing is serialised. Under the race detector when the toolchain has a C
|
||||
# compiler, and said when it has not.
|
||||
#
|
||||
# **A test that starts a bus, a store or another process, or composes the whole mesh, carries
|
||||
# `//go:build integration`** as the first line of its file; a file that held both kinds is two files
|
||||
# (`<name>_integration_test.go` beside `<name>_test.go`). Each level says its test count and its time.
|
||||
#
|
||||
# Exit 0 is a pass; anything else fails `mesh/repo-check` with the last line printed.
|
||||
set -eu
|
||||
export GOFLAGS=-mod=vendor GOPROXY=off
|
||||
|
||||
# **The unit level's bound, in seconds** (issue 895): the slowest package's own test time of the untagged run, the
|
||||
# compile not counted. It is a few seconds while the tests of the last packages are still being split from the
|
||||
# integration level; it becomes 1 s (how-we-build section 5: the whole unit suite under a second) when that is done.
|
||||
unit_bound=5
|
||||
|
||||
# judge_store_tests says each listed test of one package as `go test -v` reported it, and counts in $missing each
|
||||
# that did not pass: $1 the package, $2 go test's exit status, $3 the file holding its output, then the names.
|
||||
# A result is found anywhere in a line, not only at its start: when a test's last output leaves a line open,
|
||||
@@ -36,6 +49,40 @@ judge_store_tests() {
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
}
|
||||
# judge_unit_run says the unit level as `go test -v` reported it and fails when it was over the bound: $1 the file
|
||||
# holding the output of the untagged `go test -v` of every package (packages one after another, each ending in its
|
||||
# `ok <package> <time>` line), $2 the bound in seconds. Packages run side by side, so the level's time is that of its
|
||||
# slowest package; the five slowest tests of the run are named when it is over, so that the bound is kept by
|
||||
# making them cheap or by tagging them `integration`, and never by raising it (issue 895).
|
||||
judge_unit_run() {
|
||||
out=$1 bound=$2
|
||||
awk '
|
||||
/^--- (PASS|FAIL|SKIP): / {
|
||||
name = $3; took = $4; gsub(/[()s]/, "", took)
|
||||
pending[++n] = took "\t" name
|
||||
if ($2 == "SKIP:") skipped++
|
||||
next
|
||||
}
|
||||
/^(ok|FAIL)[ \t]/ && NF >= 3 && $3 ~ /^[0-9.]+s$/ {
|
||||
t = $3; sub(/s$/, "", t)
|
||||
for (i = 1; i <= n; i++) print "T\t" pending[i] "\t" $2
|
||||
n = 0
|
||||
print "P\t" t "\t" $2
|
||||
}
|
||||
END { print "S\t" skipped + 0 }
|
||||
' "$out" >"$out.judged"
|
||||
tests=$(grep -c '^T' "$out.judged" || true)
|
||||
skipped=$(awk -F'\t' '$1 == "S" {print $2}' "$out.judged")
|
||||
level=$(awk -F'\t' '$1 == "P" && $2 + 0 > m {m = $2 + 0} END {printf "%.2f", m}' "$out.judged")
|
||||
echo "unit level: $tests tests ($skipped skipped), $level s (bound $bound s)"
|
||||
if awk -v t="$level" -v b="$bound" 'BEGIN {exit !(t + 0 > b + 0)}'; then
|
||||
echo "the unit level took $level s, over its bound of $bound s; the slowest tests:"
|
||||
awk -F'\t' '$1 == "T" {print $2 "\t" $4 "\t" $3}' "$out.judged" | sort -t"$(printf '\t')" -k1,1 -nr | head -n 5 |
|
||||
awk -F'\t' '{printf " %s s %s %s\n", $1, $3, $2}'
|
||||
echo "make them cheap (no wait, no process), or give their files //go:build integration if they start a bus, a store or another process"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# Every Go module of its own below the root, vetted and tested (novox/hq ADR 0420 §5, issue 1025): `./...` stops at a
|
||||
# nested go.mod. Its own file, check-nested-modules.sh, which takes $cgo and $race from here.
|
||||
nested_modules() {
|
||||
@@ -43,6 +90,12 @@ nested_modules() {
|
||||
}
|
||||
# `sh merge-check.sh judge-store-tests <package> <status> <output> <name>…` runs only the judging, to try it on a
|
||||
# recorded `go test -v` output.
|
||||
# `sh merge-check.sh judge-unit-run <output> <bound>` runs only the judging of the unit level, on a recorded output.
|
||||
if [ "${1:-}" = judge-unit-run ]; then
|
||||
shift
|
||||
judge_unit_run "$@"
|
||||
exit
|
||||
fi
|
||||
if [ "${1:-}" = judge-store-tests ]; then
|
||||
shift
|
||||
missing=0
|
||||
@@ -61,6 +114,8 @@ if [ -n "$unformatted" ]; then
|
||||
exit 1
|
||||
fi
|
||||
CGO_ENABLED=0 go vet ./...
|
||||
# The files tagged `integration` are vetted too, or nothing would read them.
|
||||
CGO_ENABLED=0 go vet -tags integration ./...
|
||||
|
||||
# The race detector when the toolchain has a C compiler, and said when it has not.
|
||||
if command -v gcc >/dev/null 2>&1; then
|
||||
@@ -78,7 +133,7 @@ suite() {
|
||||
# handed neither and always runs whole; a part that passes is said as a part, never as the check.
|
||||
if [ -n "${MESH_CHECK_TESTS:-}" ]; then
|
||||
echo "TRIED IN PART: only the tests matching $MESH_CHECK_TESTS, in every package"
|
||||
suite -run "$MESH_CHECK_TESTS" ./...
|
||||
suite -tags integration -run "$MESH_CHECK_TESTS" ./...
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -107,7 +162,7 @@ shard_package() {
|
||||
bin="$work/$key.test"
|
||||
status=0
|
||||
# shellcheck disable=SC2086 # $race is one flag or none
|
||||
CGO_ENABLED=$cgo go test $race -c -o "$bin" "$dir" >"$work/$key.build" 2>&1 || status=$?
|
||||
CGO_ENABLED=$cgo go test $race -tags integration -c -o "$bin" "$dir" >"$work/$key.build" 2>&1 || status=$?
|
||||
# Every test, example and fuzz target `go test` would run, and nothing a TestMain might print beside them. A
|
||||
# binary that cannot list them, or lists none, fails here rather than pass by running nothing.
|
||||
if [ "$status" -eq 0 ]; then
|
||||
@@ -213,7 +268,7 @@ if [ -n "$listed" ] && [ -n "${MESH_TEST_POSTGRES:-}" ]; then
|
||||
pattern="^($(printf '%s\n' "$names" | paste -sd '|' -))\$"
|
||||
(
|
||||
status=0
|
||||
CGO_ENABLED=0 go test -count=1 -v -run "$pattern" "$pkg" >"$work/store.$key.out" 2>&1 || status=$?
|
||||
CGO_ENABLED=0 go test -tags integration -count=1 -v -run "$pattern" "$pkg" >"$work/store.$key.out" 2>&1 || status=$?
|
||||
echo "$status" >"$work/store.$key.status"
|
||||
) &
|
||||
done
|
||||
@@ -224,23 +279,42 @@ if [ "${MESH_CHECK_STORE_ONLY:-}" = 1 ]; then
|
||||
echo "TRIED IN PART: only the store tests listed in testdata/store-tests"
|
||||
wait
|
||||
else
|
||||
# The unit level first and alone, so that nothing else loads the machine while it is timed: every package
|
||||
# untagged, without the race detector (the bound is the plain `go test ./...` a developer runs).
|
||||
status=0
|
||||
CGO_ENABLED=0 go test -count=1 -v ./... >"$work/unit.out" 2>&1 || status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
grep -vE '^(=== |[[:space:]]*--- PASS)' "$work/unit.out"
|
||||
echo "the unit level failed (go test exited $status)"
|
||||
suite_failed=1
|
||||
fi
|
||||
judge_unit_run "$work/unit.out" "$unit_bound" || suite_failed=1
|
||||
# The integration level: the whole suite with the tag, the slowest packages in shards, the race detector when
|
||||
# there is a C compiler. It runs the unit tests again, raced. Said with its test count and its time.
|
||||
integration_start=$(date +%s)
|
||||
module=$(go list -m)
|
||||
skip=$(printf '%s\n' "$sharded" | awk -v m="$module" '{sub(/^\.\//, "", $1); print m "/" $1}')
|
||||
rest=$(go list ./... | grep -vxF "$skip")
|
||||
(
|
||||
status=0
|
||||
# shellcheck disable=SC2086 # the packages are one word each, split on purpose
|
||||
suite $rest >"$work/rest.out" 2>&1 || status=$?
|
||||
suite -tags integration -v $rest >"$work/rest.out" 2>&1 || status=$?
|
||||
echo "$status" >"$work/rest.status"
|
||||
) &
|
||||
for entry in $(printf '%s\n' "$sharded" | awk '{print $1 "|" $2}'); do
|
||||
shard_package "${entry%%|*}" "${entry#*|}" &
|
||||
done
|
||||
wait
|
||||
cat "$work/rest.out"
|
||||
grep -vE '^(=== |[[:space:]]*--- PASS)' "$work/rest.out" || true
|
||||
[ "$(cat "$work/rest.status")" -eq 0 ] || suite_failed=1
|
||||
say_shards || suite_failed=1
|
||||
shard_tests=$(cat "$work"/*.status 2>/dev/null | awk 'NF == 3 {n += $3} END {print n + 0}')
|
||||
rest_tests=$(grep -cE '^--- (PASS|FAIL|SKIP): ' "$work/rest.out" || true)
|
||||
rest_skipped=$(grep -cE '^--- SKIP: ' "$work/rest.out" || true)
|
||||
echo "integration level (the whole suite, tag integration): $((rest_tests + shard_tests)) tests ($rest_skipped skipped outside the shards), $(($(date +%s) - integration_start)) s"
|
||||
nested_start=$(date +%s)
|
||||
nested_modules || suite_failed=1
|
||||
echo "nested modules: $(($(date +%s) - nested_start)) s"
|
||||
fi
|
||||
|
||||
if [ -z "$listed" ]; then
|
||||
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
# The tests that need the throwaway store and must have run against it (novox/hq issue 459): merge-check.sh runs
|
||||
# each one named here, by package, and fails unless every one passed — a test that needs the store skips without
|
||||
# one, and a skip passes unseen. One per line: <package> <TestName>. Lines starting with # are said nothing of.
|
||||
# They run with `-tags integration`: a test file of the integration level (issue 895) is not built without it.
|
||||
./cmd/mesh-controller TestAStopWithoutAMergeWalksTheOthersAgain
|
||||
./cmd/mesh-controller TestAStopWithoutAMergeEndsTheMergesThatContainIt
|
||||
./cmd/mesh-controller TestAStopWalksTheOthersAgainInTheOpenBatch
|
||||
|
||||
Reference in New Issue
Block a user