-
Notifications
You must be signed in to change notification settings - Fork 2
feat(jepsen): M5a — setup-hook verification + elastickv-list-routes CLI #925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+399
−6
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a229f5b
feat(jepsen): M5a — setup-hook verification + elastickv-list-routes CLI
bootjp 4b71c5e
feat(jepsen): M5a — wire --list-routes-bin / --grpc-host-port CLI flags
bootjp e67d0df
feat: M5a — 2 gemini medium fixes on PR #925 (io.Writer + dynamic gRP…
bootjp 68adde1
feat(cmd): elastickv-list-routes — fix State doc + assert in test (cl…
bootjp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| // elastickv-list-routes prints the cluster's current route catalog as | ||
| // JSON. Used by the Composed-1 M5a Jepsen setup-hook verification to | ||
| // assert the launch script's --shardRanges actually placed the M5 | ||
| // table-route keys on the expected Raft groups before any workload | ||
| // op runs. | ||
| // | ||
| // Per the design doc (docs/design/2026_06_02_proposed_composed1_m5_jepsen_route_shuffle.md | ||
| // §3.3), the Jepsen client's setup! shells out to this tool rather | ||
| // than re-implementing the gRPC client in Clojure: a JSON contract is | ||
| // stable across versions and a future ListRoutes schema change shows | ||
| // up as an unmarshal failure rather than as a silent mis-routing | ||
| // during a Jepsen run. | ||
| // | ||
| // Usage: | ||
| // | ||
| // elastickv-list-routes --address 127.0.0.1:50051 | ||
| // | ||
| // Output (stdout, one JSON object): | ||
| // | ||
| // { | ||
| // "catalog_version": 7, | ||
| // "routes": [ | ||
| // {"route_id": 100, "raft_group_id": 1, "start": "...", "end": "...", "state": "ROUTE_STATE_ACTIVE"}, | ||
| // ... | ||
| // ] | ||
| // } | ||
| // | ||
| // `start` and `end` are base64-encoded raw bytes so any byte | ||
| // sequence (including unprintables in the routing keyspace) survives | ||
| // the JSON round-trip without quoting issues. Non-zero exit on any | ||
| // error so the Jepsen setup-hook sees the failure verbatim. | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "flag" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "time" | ||
|
|
||
| pb "github.com/bootjp/elastickv/proto" | ||
| "github.com/cockroachdb/errors" | ||
| "google.golang.org/grpc" | ||
| "google.golang.org/grpc/credentials/insecure" | ||
| ) | ||
|
|
||
| const rpcTimeout = 10 * time.Second | ||
|
|
||
| var address = flag.String("address", "127.0.0.1:50051", "gRPC address of an elastickv server (any node works — the Distribution service is replicated)") | ||
|
|
||
| // routeJSON is the on-the-wire shape this tool emits. Field names | ||
| // are snake_case to match the proto's wire format; Start/End are | ||
| // base64-encoded so arbitrary byte ranges survive JSON. | ||
| type routeJSON struct { | ||
| RouteID uint64 `json:"route_id"` | ||
| RaftGroupID uint64 `json:"raft_group_id"` | ||
| Start string `json:"start"` // base64(raw bytes) | ||
| End string `json:"end"` // base64(raw bytes); empty == +infinity | ||
| State string `json:"state"` | ||
| } | ||
|
|
||
| type responseJSON struct { | ||
| CatalogVersion uint64 `json:"catalog_version"` | ||
| Routes []routeJSON `json:"routes"` | ||
| } | ||
|
|
||
| func main() { | ||
| flag.Parse() | ||
| if err := run(); err != nil { | ||
| fmt.Fprintf(os.Stderr, "elastickv-list-routes: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| func run() error { | ||
| conn, err := grpc.NewClient(*address, grpc.WithTransportCredentials(insecure.NewCredentials())) | ||
| if err != nil { | ||
| return errors.Wrapf(err, "dial %s", *address) | ||
| } | ||
| defer func() { | ||
| // Mirror cmd/elastickv-split's close pattern: surface close | ||
| // errors on stderr without failing the process — by this | ||
| // point the response has been printed. | ||
| if cerr := conn.Close(); cerr != nil { | ||
| fmt.Fprintf(os.Stderr, "elastickv-list-routes: close: %v\n", cerr) | ||
| } | ||
| }() | ||
|
|
||
| client := pb.NewDistributionClient(conn) | ||
| ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout) | ||
| defer cancel() | ||
|
|
||
| resp, err := client.ListRoutes(ctx, &pb.ListRoutesRequest{}) | ||
| if err != nil { | ||
| return errors.Wrap(err, "ListRoutes") | ||
| } | ||
| return emit(resp, os.Stdout) | ||
| } | ||
|
|
||
| // emit serialises resp as JSON to w. Extracted from run() so the | ||
| // encoding is testable with bytes.Buffer rather than a real | ||
| // temp file (gemini medium on PR #925). io.Writer is the | ||
| // idiomatic Go return shape for "I write structured output to | ||
| // something." | ||
| func emit(resp *pb.ListRoutesResponse, w io.Writer) error { | ||
| out := responseJSON{ | ||
| CatalogVersion: resp.GetCatalogVersion(), | ||
| Routes: make([]routeJSON, 0, len(resp.GetRoutes())), | ||
| } | ||
| for _, r := range resp.GetRoutes() { | ||
| out.Routes = append(out.Routes, routeJSON{ | ||
| RouteID: r.GetRouteId(), | ||
| RaftGroupID: r.GetRaftGroupId(), | ||
| Start: base64.StdEncoding.EncodeToString(r.GetStart()), | ||
| End: base64.StdEncoding.EncodeToString(r.GetEnd()), | ||
| State: r.GetState().String(), | ||
| }) | ||
| } | ||
| enc := json.NewEncoder(w) | ||
| enc.SetIndent("", " ") | ||
| if err := enc.Encode(out); err != nil { | ||
| return errors.Wrap(err, "encode") | ||
| } | ||
| return nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| pb "github.com/bootjp/elastickv/proto" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // TestEmit_Empty checks the JSON shape when no routes exist. | ||
| // catalog_version is still emitted, routes is an empty array | ||
| // (not nil) so Clojure callers don't have to special-case | ||
| // nil-vs-empty. | ||
| func TestEmit_Empty(t *testing.T) { | ||
| var buf bytes.Buffer | ||
| require.NoError(t, emit(&pb.ListRoutesResponse{CatalogVersion: 7}, &buf)) | ||
|
|
||
| var out struct { | ||
| CatalogVersion uint64 `json:"catalog_version"` | ||
| Routes []any `json:"routes"` | ||
| } | ||
| require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) | ||
| require.Equal(t, uint64(7), out.CatalogVersion) | ||
| require.NotNil(t, out.Routes) | ||
| require.Empty(t, out.Routes) | ||
| } | ||
|
|
||
| // TestEmit_RoundTripsRouteBytes pins the on-the-wire shape against | ||
| // the JSON Clojure will parse. Start/End are base64-encoded so any | ||
| // byte sequence survives — verified by round-tripping a routing-key | ||
| // shape that contains '|' (ASCII 124, outside the base64 alphabet). | ||
| func TestEmit_RoundTripsRouteBytes(t *testing.T) { | ||
| startBytes := []byte("!ddb|route|table|amVwc2VuX2FwcGVuZF90MQ") | ||
| endBytes := []byte("!ddb|route|table|amVwc2VuX2FwcGVuZF90Mw") | ||
|
|
||
| var buf bytes.Buffer | ||
| require.NoError(t, emit(&pb.ListRoutesResponse{ | ||
| CatalogVersion: 3, | ||
| Routes: []*pb.RouteDescriptor{ | ||
| { | ||
| RouteId: 100, | ||
| RaftGroupId: 1, | ||
| Start: startBytes, | ||
| End: endBytes, | ||
| State: pb.RouteState_ROUTE_STATE_ACTIVE, | ||
| }, | ||
| }, | ||
| }, &buf)) | ||
|
|
||
| var out responseJSON | ||
| require.NoError(t, json.Unmarshal(buf.Bytes(), &out)) | ||
|
|
||
| require.Equal(t, uint64(3), out.CatalogVersion) | ||
| require.Len(t, out.Routes, 1) | ||
| require.Equal(t, uint64(100), out.Routes[0].RouteID) | ||
| require.Equal(t, uint64(1), out.Routes[0].RaftGroupID) | ||
|
|
||
| // Round-trip the base64-encoded bytes — the load-bearing claim is | ||
| // that the Clojure caller decodes back to the exact bytes the | ||
| // server holds. | ||
| decodedStart, err := base64.StdEncoding.DecodeString(out.Routes[0].Start) | ||
| require.NoError(t, err) | ||
| require.Equal(t, startBytes, decodedStart) | ||
| decodedEnd, err := base64.StdEncoding.DecodeString(out.Routes[0].End) | ||
| require.NoError(t, err) | ||
| require.Equal(t, endBytes, decodedEnd) | ||
|
|
||
| // State serialises via proto's String() — verify the on-the-wire | ||
| // shape so a future enum-name change (e.g. stripped prefix to | ||
| // "ACTIVE") is caught here rather than silently parsed by the | ||
| // Clojure regex (claude[bot] low on PR #925). | ||
| require.Equal(t, "ROUTE_STATE_ACTIVE", out.Routes[0].State) | ||
| } | ||
|
|
||
| // TestEmit_EmptyEndDistinguishable verifies that an unset End | ||
| // (the +infinity sentinel) round-trips as an empty string rather | ||
| // than as a missing field — the Clojure setup-hook relies on this | ||
| // to detect the rightmost route in the catalog. | ||
| func TestEmit_EmptyEndDistinguishable(t *testing.T) { | ||
| var buf bytes.Buffer | ||
| require.NoError(t, emit(&pb.ListRoutesResponse{ | ||
| Routes: []*pb.RouteDescriptor{ | ||
| {RouteId: 1, RaftGroupId: 2, Start: []byte("x"), End: nil}, | ||
| }, | ||
| }, &buf)) | ||
|
|
||
| require.Contains(t, buf.String(), `"end": ""`, | ||
| "empty End must serialise as an explicit empty string so Clojure can detect the +infinity boundary") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoding
127.0.0.1:50051as the default gRPC address works fine for local testing, but will fail in a distributed Jepsen environment where the database nodes run on separate hosts. Consider dynamically resolving the default address using the first node in the test configuration.