diff --git a/CHANGELOG.md b/CHANGELOG.md index 621ec21..c15bc0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to this project will be documented in this file. +## v0.3.0 + +This release reorganizes the cluster and shard APIs under a common topology namespace and simplifies several sharding +contracts. + +### Changed + +* **Topology Package Layout:** Moved cluster and shard packages to `topology/cluster` and `topology/shard`, with shard + resolvers under `topology/shard/resolver`. +* **Cluster Identity:** Cluster IDs are now required when creating a `cluster.Cluster`. +* **Shard Topology Construction:** Simplified topology creation from `shard.NewTopology([]shard.Config{...})` to + `shard.NewTopology(clusters...)`. +* **Rendezvous Resolver:** Renamed `HashResolver` and `NewHash` to `RendezvousResolver` and `NewRendezvous`, making the + routing algorithm explicit while preserving the existing rendezvous placement contract. +* **Custom Resolvers:** Simplified custom resolver callbacks to map a key directly to `shard.ID` without receiving the + topology on every call. +* **Cross-Shard Operations:** `ForEachShard` now returns callback and cancellation failures through its function error + while preserving detailed per-shard results. + +### Fixed + +* **Rendezvous Portability:** Fixed length validation in rendezvous routing so the resolver also compiles correctly on + 32-bit architectures. + +### Removed + +* **Shard Configuration Layer:** Removed `shard.Config`; shard topologies are now created directly from clusters. +* **Generic Hash API:** Removed the `HashResolver` and `NewHash` names in favor of the explicit rendezvous API. + +--- + ## v0.2.0 Initial production release of `xpg`, built around `pgx` with PostgreSQL transaction helpers, primary/replica clustering, diff --git a/README.md b/README.md index 3c83c29..46d013b 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ connection management, routing, and common production workflows. * **Error Classification:** Classification of PostgreSQL constraint, transaction, cancellation, connection, and other common database errors. * **Advisory Locking:** Transaction-level advisory locks for coordinating concurrent database operations. -* **Primary/Replica Routing:** Explicit read policies, replica selection, primary fallback, and read-only transactions - across PostgreSQL nodes. -* **Application-Level Sharding:** Hash, range, time-based, and custom routing with colocation checks, key grouping, and - bounded parallel operations across shards. +* **Primary/Replica Routing:** Logical cluster topologies with explicit read policies, replica selection, primary + fallback, and read-only transactions across PostgreSQL nodes. +* **Application-Level Sharding:** Rendezvous, range, time-based, and custom routing with colocation checks, key + grouping, and bounded parallel operations across shards. * **Observability:** Structured logging, tracing, pool statistics, and optional OpenTelemetry metrics. ## Installation @@ -132,7 +132,7 @@ serialization failures, deadlocks, lock errors, query cancellation, and connecti ## Clustering -`xpg` groups primary and replica pools into a logical cluster with explicit read routing. +The `topology/cluster` package groups primary and replica pools into a logical cluster with explicit read routing. ```go @@ -173,61 +173,60 @@ replica is available. Replica selection is round-robin by default and can be cus ## Sharding -`xpg` provides application-level sharding with explicit key routing across an immutable shard topology. +The `topology/shard` package provides application-level sharding with explicit key routing across an immutable shard +topology. Routing strategies live under `topology/shard/resolver`. ```go -topology, err := shard.NewTopology([]shard.Config{ - {Cluster: shardA}, - {Cluster: shardB}, -}) +topology, err := shard.NewTopology(shardA, shardB) if err != nil { - panic(err) + panic(err) } defer topology.Close() // Partition user IDs into shard ranges. users, err := resolver.NewRange( - topology, - []resolver.Range[uint64]{ - {Start: 0, End: 100, ShardID: "shard-a"}, - {Start: 100, End: 200, ShardID: "shard-b"}, - }, + topology, + []resolver.Range[uint64]{ + {Start: 0, End: 100, ShardID: "shard-a"}, + {Start: 100, End: 200, ShardID: "shard-b"}, + }, ) if err != nil { - panic(err) + panic(err) } // Resolve the target shard. -shard, err := users.Resolve(userID) +targetShard, err := users.Resolve(userID) if err != nil { - panic(err) + panic(err) } // Write to the shard primary. -primaryPool := shard.Primary() +primaryPool := targetShard.Primary() _, err = primaryPool.Exec(ctx, "UPDATE users SET active = true WHERE id = $1", userID) if err != nil { - panic(err) + panic(err) } // Read from the same shard using the selected read policy. -readPool, err := shard.ReadPool(ctx, cluster.ReadReplicaPreferred) +readPool, err := targetShard.ReadPool(ctx, cluster.ReadReplicaPreferred) if err != nil { - panic(err) + panic(err) } var active bool err = readPool.QueryRow(ctx, "SELECT active FROM users WHERE id = $1", userID).Scan(&active) if err != nil { - panic(err) + panic(err) } + ``` Built-in routing strategies include rendezvous hashing, ordered ranges, time ranges, and custom resolvers. Sharding -utilities cover key colocation, grouping by shard, and parallel operations across shards. +utilities cover key colocation, grouping by shard, and bounded parallel operations across shards. ## Examples diff --git a/examples/basic/main.go b/examples/basic/main.go index b3c1244..a965199 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -104,11 +104,7 @@ func upsertUsers(ctx context.Context, pool *xpg.Pool) (int64, error) { return tag.RowsAffected(), nil } -func loadUser( - ctx context.Context, - pool *xpg.Pool, - userID int64, -) (user, error) { +func loadUser(ctx context.Context, pool *xpg.Pool, userID int64) (user, error) { var selected user err := pool.QueryRow( @@ -134,10 +130,7 @@ func loadUser( return selected, nil } -func listActiveUsers( - ctx context.Context, - pool *xpg.Pool, -) ([]user, error) { +func listActiveUsers(ctx context.Context, pool *xpg.Pool) ([]user, error) { rows, err := pool.Query( ctx, `SELECT diff --git a/examples/cluster/main.go b/examples/cluster/main.go index 0d614cc..7782275 100644 --- a/examples/cluster/main.go +++ b/examples/cluster/main.go @@ -6,7 +6,7 @@ import ( "log" "github.com/jackc/pgx/v5" - "github.com/mkbeh/xpg/cluster" + "github.com/mkbeh/xpg/topology/cluster" ) type nodeInfo struct { diff --git a/examples/cluster/setup.go b/examples/cluster/setup.go index fe62855..ac711c0 100644 --- a/examples/cluster/setup.go +++ b/examples/cluster/setup.go @@ -4,9 +4,10 @@ import ( "context" "fmt" "os" + "slices" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" + "github.com/mkbeh/xpg/topology/cluster" ) const ( @@ -16,50 +17,64 @@ const ( ) func openCluster(ctx context.Context) (*cluster.Cluster, error) { - primary, err := openPool( - ctx, - environment("XPG_PRIMARY_DATABASE_URL", defaultPrimaryDatabaseURL), - "cluster.primary", - "primary", - ) - if err != nil { - return nil, fmt.Errorf("open primary pool: %w", err) + type poolConfig struct { + databaseURL string + name string + role string } - replicaOne, err := openPool( - ctx, - environment("XPG_REPLICA_ONE_DATABASE_URL", defaultReplicaOneURL), - "cluster.replica-one", - "replica", - ) - if err != nil { - primary.Close() - - return nil, fmt.Errorf("open replica-one pool: %w", err) + configs := []poolConfig{ + { + databaseURL: environment( + "XPG_PRIMARY_DATABASE_URL", + defaultPrimaryDatabaseURL, + ), + name: "cluster.primary", + role: "primary", + }, + { + databaseURL: environment( + "XPG_REPLICA_ONE_DATABASE_URL", + defaultReplicaOneURL, + ), + name: "cluster.replica-one", + role: "replica", + }, + { + databaseURL: environment( + "XPG_REPLICA_TWO_DATABASE_URL", + defaultReplicaTwoURL, + ), + name: "cluster.replica-two", + role: "replica", + }, } - replicaTwo, err := openPool( - ctx, - environment("XPG_REPLICA_TWO_DATABASE_URL", defaultReplicaTwoURL), - "cluster.replica-two", - "replica", - ) - if err != nil { - replicaOne.Close() - primary.Close() + pools := make([]*xpg.Pool, 0, len(configs)) + + for _, config := range configs { + pool, err := openPool( + ctx, + config.databaseURL, + config.name, + config.role, + ) + if err != nil { + closePools(pools) + + return nil, fmt.Errorf("open %s pool: %w", config.name, err) + } - return nil, fmt.Errorf("open replica-two pool: %w", err) + pools = append(pools, pool) } dbCluster, err := cluster.New(cluster.Config{ ID: "cluster-example", - Primary: primary, - Replicas: []*xpg.Pool{replicaOne, replicaTwo}, + Primary: pools[0], + Replicas: pools[1:], }) if err != nil { - replicaTwo.Close() - replicaOne.Close() - primary.Close() + closePools(pools) return nil, fmt.Errorf("create cluster: %w", err) } @@ -87,6 +102,12 @@ func openPool(ctx context.Context, databaseURL, name, role string) (*xpg.Pool, e return pool, nil } +func closePools(pools []*xpg.Pool) { + for _, pool := range slices.Backward(pools) { + pool.Close() + } +} + func environment(key, fallback string) string { if value := os.Getenv(key); value != "" { return value diff --git a/examples/shard/main.go b/examples/shard/main.go index 9f45891..8706674 100644 --- a/examples/shard/main.go +++ b/examples/shard/main.go @@ -5,8 +5,8 @@ import ( "fmt" "log" - "github.com/mkbeh/xpg/shard" - "github.com/mkbeh/xpg/shard/resolver" + "github.com/mkbeh/xpg/topology/shard" + "github.com/mkbeh/xpg/topology/shard/resolver" ) const ( @@ -33,6 +33,9 @@ func run(ctx context.Context) error { } defer topology.Close() + // A resolver describes how one dataset is distributed across the topology. + // NewRange resolves shard IDs once and stores the resulting Shard handles in + // an immutable routing table used by subsequent lookups. userResolver, err := resolver.NewRange( topology, []resolver.Range[uint64]{ @@ -60,14 +63,14 @@ func run(ctx context.Context) error { fmt.Println("range routing:") for _, current := range users { - resolved, err := userResolver.Resolve(current.ID) + targetShard, err := userResolver.Resolve(current.ID) if err != nil { return fmt.Errorf("resolve user %d: %w", current.ID, err) } - primary := resolved.Primary() + primary := targetShard.Primary() if primary == nil { - return fmt.Errorf("shard %q has no primary", resolved.ID()) + return fmt.Errorf("shard %q has no primary", targetShard.ID()) } if _, err := primary.Exec( @@ -85,7 +88,7 @@ func run(ctx context.Context) error { fmt.Printf( "- user_id=%d shard=%s pool=%s\n", current.ID, - resolved.ID(), + targetShard.ID(), primary.Name(), ) } diff --git a/examples/shard/setup.go b/examples/shard/setup.go index 2a43d9d..ebf6907 100644 --- a/examples/shard/setup.go +++ b/examples/shard/setup.go @@ -4,10 +4,11 @@ import ( "context" "fmt" "os" + "slices" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/cluster" + "github.com/mkbeh/xpg/topology/shard" ) const ( @@ -19,41 +20,54 @@ const ( ) func openTopology(ctx context.Context) (*shard.Topology, error) { - shardA, err := openShard( - ctx, - shardAID, - "shard.shard-a.primary", - environment( - "XPG_SHARD_A_DATABASE_URL", - defaultShardADatabaseURL, - ), - ) - if err != nil { - return nil, fmt.Errorf("open shard-a: %w", err) + type clusterConfig struct { + id cluster.ID + name string + databaseURL string } - shardB, err := openShard( - ctx, - shardBID, - "shard.shard-b.primary", - environment( - "XPG_SHARD_B_DATABASE_URL", - defaultShardBDatabaseURL, - ), - ) - if err != nil { - shardA.Close() + configs := []clusterConfig{ + { + id: shardAID, + name: "shard.shard-a.primary", + databaseURL: environment( + "XPG_SHARD_A_DATABASE_URL", + defaultShardADatabaseURL, + ), + }, + { + id: shardBID, + name: "shard.shard-b.primary", + databaseURL: environment( + "XPG_SHARD_B_DATABASE_URL", + defaultShardBDatabaseURL, + ), + }, + } - return nil, fmt.Errorf("open shard-b: %w", err) + clusters := make([]*cluster.Cluster, 0, len(configs)) + + for _, config := range configs { + dbCluster, err := openCluster( + ctx, + config.id, + config.name, + config.databaseURL, + ) + if err != nil { + closeClusters(clusters) + + return nil, fmt.Errorf("open %s cluster: %w", config.id, err) + } + + clusters = append(clusters, dbCluster) } - topology, err := shard.NewTopology([]shard.Config{ - {Cluster: shardA}, - {Cluster: shardB}, - }) + // After NewTopology succeeds, the topology owns all clusters and closes + // them through Topology.Close. On constructor failure ownership remains here. + topology, err := shard.NewTopology(clusters...) if err != nil { - shardB.Close() - shardA.Close() + closeClusters(clusters) return nil, fmt.Errorf("create topology: %w", err) } @@ -61,7 +75,12 @@ func openTopology(ctx context.Context) (*shard.Topology, error) { return topology, nil } -func openShard(ctx context.Context, id shard.ID, name, databaseURL string) (*cluster.Cluster, error) { +func openCluster( + ctx context.Context, + id shard.ID, + name string, + databaseURL string, +) (*cluster.Cluster, error) { pool, err := xpg.Open( ctx, databaseURL, @@ -79,7 +98,7 @@ func openShard(ctx context.Context, id shard.ID, name, databaseURL string) (*clu return nil, fmt.Errorf("ping pool: %w", err) } - shardCluster, err := cluster.New(cluster.Config{ + dbCluster, err := cluster.New(cluster.Config{ ID: id, Primary: pool, }) @@ -89,7 +108,13 @@ func openShard(ctx context.Context, id shard.ID, name, databaseURL string) (*clu return nil, fmt.Errorf("create cluster: %w", err) } - return shardCluster, nil + return dbCluster, nil +} + +func closeClusters(clusters []*cluster.Cluster) { + for _, cluster := range slices.Backward(clusters) { + cluster.Close() + } } func environment(name, fallback string) string { diff --git a/examples/shard_geo/main.go b/examples/shard_geo/main.go index f2d75fd..2df5352 100644 --- a/examples/shard_geo/main.go +++ b/examples/shard_geo/main.go @@ -7,7 +7,7 @@ import ( "log" "github.com/jackc/pgx/v5" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) type tenantKey struct { diff --git a/examples/shard_geo/resolver.go b/examples/shard_geo/resolver.go index dca5e14..b314a14 100644 --- a/examples/shard_geo/resolver.go +++ b/examples/shard_geo/resolver.go @@ -3,8 +3,8 @@ package main import ( "fmt" - "github.com/mkbeh/xpg/shard" - "github.com/mkbeh/xpg/shard/resolver" + "github.com/mkbeh/xpg/topology/shard" + "github.com/mkbeh/xpg/topology/shard/resolver" ) func newTenantResolver(topology *shard.Topology) (shard.Resolver[tenantKey], error) { @@ -31,7 +31,7 @@ func newTenantResolver(topology *shard.Topology) (shard.Resolver[tenantKey], err } resolve := resolver.ResolveFunc[tenantKey]( - func(key tenantKey, _ *shard.Topology) (shard.ID, error) { + func(key tenantKey) (shard.ID, error) { id, ok := shardByRegion[key.Region] if !ok { return "", fmt.Errorf( diff --git a/examples/shard_geo/setup.go b/examples/shard_geo/setup.go index e189457..f850b38 100644 --- a/examples/shard_geo/setup.go +++ b/examples/shard_geo/setup.go @@ -4,10 +4,11 @@ import ( "context" "fmt" "os" + "slices" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/cluster" + "github.com/mkbeh/xpg/topology/shard" ) const ( @@ -19,43 +20,56 @@ const ( ) func openTopology(ctx context.Context) (*shard.Topology, error) { - shardEU, err := openShard( - ctx, - shardEUID, - "eu", - "geo.shard-eu.primary", - environment( - "XPG_SHARD_EU_DATABASE_URL", - defaultShardEUDatabaseURL, - ), - ) - if err != nil { - return nil, fmt.Errorf("open shard-eu: %w", err) + type shardConfig struct { + id cluster.ID + region string + name string + databaseURL string } - shardUS, err := openShard( - ctx, - shardUSID, - "us", - "geo.shard-us.primary", - environment( - "XPG_SHARD_US_DATABASE_URL", - defaultShardUSDatabaseURL, - ), - ) - if err != nil { - shardEU.Close() + configs := []shardConfig{ + { + id: shardEUID, + region: "eu", + name: "geo.shard-eu.primary", + databaseURL: environment( + "XPG_SHARD_EU_DATABASE_URL", + defaultShardEUDatabaseURL, + ), + }, + { + id: shardUSID, + region: "us", + name: "geo.shard-us.primary", + databaseURL: environment( + "XPG_SHARD_US_DATABASE_URL", + defaultShardUSDatabaseURL, + ), + }, + } + + clusters := make([]*cluster.Cluster, 0, len(configs)) + + for _, config := range configs { + dbCluster, err := openShard( + ctx, + config.id, + config.region, + config.name, + config.databaseURL, + ) + if err != nil { + closeClusters(clusters) + + return nil, fmt.Errorf("open %s shard: %w", config.id, err) + } - return nil, fmt.Errorf("open shard-us: %w", err) + clusters = append(clusters, dbCluster) } - topology, err := shard.NewTopology([]shard.Config{ - {Cluster: shardEU}, - {Cluster: shardUS}, - }) + topology, err := shard.NewTopology(clusters...) if err != nil { - shardUS.Close() - shardEU.Close() + closeClusters(clusters) return nil, fmt.Errorf("create topology: %w", err) } @@ -101,6 +115,12 @@ func openShard( return shardCluster, nil } +func closeClusters(clusters []*cluster.Cluster) { + for _, cluster := range slices.Backward(clusters) { + cluster.Close() + } +} + func environment(name, fallback string) string { if value := os.Getenv(name); value != "" { return value diff --git a/shard/errors.go b/shard/errors.go deleted file mode 100644 index 908af96..0000000 --- a/shard/errors.go +++ /dev/null @@ -1,54 +0,0 @@ -package shard - -import ( - "errors" - "fmt" -) - -var ( - // ErrNoShard is returned when a resolver cannot map a key to any shard. - ErrNoShard = errors.New("xpg/shard: no shard resolved") - - // ErrUnknownShard is returned when routing references a shard that does not - // exist in the topology. - ErrUnknownShard = errors.New("xpg/shard: unknown shard") - - // ErrShardMismatch is returned when keys expected to be colocated resolve to - // different shards. - ErrShardMismatch = errors.New("xpg/shard: keys resolve to different shards") -) - -// UnknownShardError identifies a shard referenced by routing that does not -// exist in the topology. -type UnknownShardError struct { - ShardID ID -} - -func (e *UnknownShardError) Error() string { - return fmt.Sprintf("xpg/shard: unknown shard %q", e.ShardID) -} - -func (e *UnknownShardError) Unwrap() error { - return ErrUnknownShard -} - -// MismatchError describes the first key whose resolved shard differs from the -// shard of the first key. -type MismatchError struct { - Expected ID - Actual ID - Index int -} - -func (e *MismatchError) Error() string { - return fmt.Sprintf( - "xpg/shard: key %d resolved to shard %q instead of %q", - e.Index, - e.Actual, - e.Expected, - ) -} - -func (e *MismatchError) Unwrap() error { - return ErrShardMismatch -} diff --git a/shard/resolver.go b/shard/resolver.go deleted file mode 100644 index 500fcb8..0000000 --- a/shard/resolver.go +++ /dev/null @@ -1,9 +0,0 @@ -package shard - -// Resolver maps a typed application key to one shard. -// -// Resolve should return ErrNoShard when the key cannot be mapped to a shard. -// Implementations must be safe for concurrent use. -type Resolver[K any] interface { - Resolve(key K) (Shard, error) -} diff --git a/shard/resolver/custom.go b/shard/resolver/custom.go deleted file mode 100644 index cae2096..0000000 --- a/shard/resolver/custom.go +++ /dev/null @@ -1,59 +0,0 @@ -package resolver - -import ( - "errors" - - "github.com/mkbeh/xpg/shard" -) - -// ResolveFunc maps an application key to a shard ID within topology. -// -// Resolve functions must be deterministic and safe for concurrent use. They -// should return shard.ErrNoShard when a key cannot be mapped and should not -// perform hidden I/O or modify topology. -type ResolveFunc[K any] func(key K, topology *shard.Topology) (shard.ID, error) - -// CustomResolver adapts ResolveFunc to shard.Resolver. -// -// CustomResolver borrows its topology and must not outlive it. -type CustomResolver[K any] struct { - topology *shard.Topology - resolve ResolveFunc[K] -} - -// NewCustom binds custom routing logic to an immutable topology. -func NewCustom[K any](topology *shard.Topology, resolve ResolveFunc[K]) (*CustomResolver[K], error) { - if err := requireTopology(topology); err != nil { - return nil, err - } - - if resolve == nil { - return nil, errors.New("xpg/shard/resolver: custom resolve function is nil") - } - - return &CustomResolver[K]{ - topology: topology, - resolve: resolve, - }, nil -} - -// Resolve maps key to a shard in the bound topology. -func (resolver *CustomResolver[K]) Resolve(key K) (shard.Shard, error) { - if resolver == nil || resolver.topology == nil || resolver.resolve == nil { - return shard.Shard{}, errors.New("xpg/shard/resolver: custom resolver is not initialized") - } - - id, err := resolver.resolve(key, resolver.topology) - if err != nil { - return shard.Shard{}, err - } - - resolved, ok := resolver.topology.Shard(id) - if !ok { - return shard.Shard{}, &shard.UnknownShardError{ - ShardID: id, - } - } - - return resolved, nil -} diff --git a/shard/topology.go b/shard/topology.go deleted file mode 100644 index db7468a..0000000 --- a/shard/topology.go +++ /dev/null @@ -1,133 +0,0 @@ -package shard - -import ( - "errors" - "fmt" - "slices" - "sync" - - "github.com/mkbeh/xpg/cluster" -) - -// Config registers one Cluster as a logical shard. -// -// The shard ID and labels are provided by Cluster. -type Config struct { - Cluster *cluster.Cluster -} - -// Topology is an immutable ordered set of logical shards and their PostgreSQL -// clusters. -// -// NewTopology takes ownership of all configured clusters only after it returns -// successfully. Close closes every owned cluster exactly once. -type Topology struct { - shards []Shard - indexByID map[ID]int - - closeOnce sync.Once -} - -// NewTopology validates and creates an immutable topology. -// -// Shards retain their registration order. Every cluster must have a non-empty -// and unique ID. -func NewTopology(configs []Config) (*Topology, error) { - if len(configs) == 0 { - return nil, errors.New( - "xpg/shard: topology must contain at least one shard", - ) - } - - shards := make([]Shard, len(configs)) - indexByID := make(map[ID]int, len(configs)) - - for index, config := range configs { - if config.Cluster == nil { - return nil, fmt.Errorf( - "xpg/shard: shard %d: cluster is nil", - index, - ) - } - - id := config.Cluster.ID() - if id == "" { - return nil, fmt.Errorf( - "xpg/shard: shard %d: cluster ID must not be empty", - index, - ) - } - - if previousIndex, exists := indexByID[id]; exists { - return nil, fmt.Errorf( - "xpg/shard: duplicate shard ID %q at indexes %d and %d", - id, - previousIndex, - index, - ) - } - - shards[index] = Shard{ - cluster: config.Cluster, - } - indexByID[id] = index - } - - return &Topology{ - shards: shards, - indexByID: indexByID, - }, nil -} - -// Len returns the number of registered shards. -func (t *Topology) Len() int { - if t == nil { - return 0 - } - - return len(t.shards) -} - -// At returns the shard at index in registration order. -// -// At panics when t is nil or index is out of range. -func (t *Topology) At(index int) Shard { - return t.shards[index] -} - -// Shard returns the shard with id. -func (t *Topology) Shard(id ID) (Shard, bool) { - if t == nil { - return Shard{}, false - } - - index, ok := t.indexByID[id] - if !ok { - return Shard{}, false - } - - return t.shards[index], true -} - -// Shards returns a defensive copy of shards in registration order. -func (t *Topology) Shards() []Shard { - if t == nil { - return nil - } - - return slices.Clone(t.shards) -} - -// Close closes owned clusters in reverse registration order. Close is safe to -// call multiple times. -func (t *Topology) Close() { - if t == nil { - return - } - - t.closeOnce.Do(func() { - for _, shard := range slices.Backward(t.shards) { - shard.cluster.Close() - } - }) -} diff --git a/cluster/cluster.go b/topology/cluster/cluster.go similarity index 74% rename from cluster/cluster.go rename to topology/cluster/cluster.go index dbe925d..254127f 100644 --- a/cluster/cluster.go +++ b/topology/cluster/cluster.go @@ -15,9 +15,9 @@ type ID string // Config configures a Cluster from independently created pools. // -// ID and Labels are optional metadata. New takes ownership of Primary and -// Replicas only after it returns successfully. Cluster.Close closes all owned -// pools. +// ID is required. Labels are optional metadata. New takes ownership of Primary +// and Replicas only after it returns successfully. Cluster.Close closes the +// owned pools. type Config struct { ID ID Labels map[string]string @@ -27,8 +27,8 @@ type Config struct { Selector ReplicaSelector } -// Cluster represents a logical PostgreSQL cluster composed of an optional -// primary pool and zero or more replica pools. +// Cluster routes operations across an optional primary pool and zero or more +// replica pools. // // A deployment with one PostgreSQL endpoint is represented by a Cluster with // one Primary and no Replicas. A read-only deployment may omit Primary and @@ -52,19 +52,23 @@ type Cluster struct { // New creates a Cluster from independently configured pools. // -// At least one pool is required. When Selector is nil, replicas are selected -// using round-robin. +// ID and at least one pool are required. When Selector is nil, replicas are +// selected using round-robin. func New(config Config) (*Cluster, error) { + if config.ID == "" { + return nil, errors.New("xpg/topology/cluster: cluster ID must not be empty") + } + if config.Primary != nil && config.Primary.Raw() == nil { - return nil, errors.New("xpg/cluster: primary pool is invalid") + return nil, errors.New("xpg/topology/cluster: primary pool is invalid") } if config.Primary == nil && len(config.Replicas) == 0 { - return nil, errors.New("xpg/cluster: at least one pool is required") + return nil, errors.New("xpg/topology/cluster: at least one pool is required") } if err := validateLabels(config.Labels); err != nil { - return nil, fmt.Errorf("xpg/cluster: %w", err) + return nil, fmt.Errorf("xpg/topology/cluster: %w", err) } replicas := slices.Clone(config.Replicas) @@ -72,7 +76,7 @@ func New(config Config) (*Cluster, error) { for index, replica := range replicas { if replica == nil || replica.Raw() == nil { - return nil, fmt.Errorf("xpg/cluster: replica %d is invalid", index) + return nil, fmt.Errorf("xpg/topology/cluster: replica %d is invalid", index) } metadata[index] = ReplicaInfo{ @@ -96,7 +100,7 @@ func New(config Config) (*Cluster, error) { }, nil } -// ID returns the logical cluster ID. +// ID returns the stable logical cluster ID. func (c *Cluster) ID() ID { if c == nil { return "" @@ -149,21 +153,24 @@ func (c *Cluster) ReplicaCount() int { // ReplicaAt returns the replica at index in registration order. // // The returned pool is borrowed and must not be closed separately. ReplicaAt -// panics when c is nil or index is out of range. +// panics when c is nil or index is outside the replica set, matching ordinary +// slice indexing semantics. func (c *Cluster) ReplicaAt(index int) *xpg.Pool { return c.replicas[index] } -// Close closes replicas in reverse registration order, then closes the primary -// when one is configured. Close is safe to call multiple times. +// Close closes all replica pools in reverse registration order and then closes +// the primary pool when one is configured. +// +// Close is safe to call multiple times. func (c *Cluster) Close() { if c == nil { return } c.closeOnce.Do(func() { - for _, replica := range slices.Backward(c.replicas) { - replica.Close() + for _, v := range slices.Backward(c.replicas) { + v.Close() } if c.primary != nil { diff --git a/cluster/cluster_test.go b/topology/cluster/cluster_test.go similarity index 86% rename from cluster/cluster_test.go rename to topology/cluster/cluster_test.go index 403d64b..17978ed 100644 --- a/cluster/cluster_test.go +++ b/topology/cluster/cluster_test.go @@ -7,16 +7,34 @@ import ( "github.com/mkbeh/xpg" ) +func TestNewRequiresID(t *testing.T) { + t.Parallel() + + primary := newTestPool(t, "primary", nil) + + cluster, err := New(Config{ + Primary: primary, + }) + if err == nil { + cluster.Close() + t.Fatal("expected error") + } + + if got, want := err.Error(), "xpg/topology/cluster: cluster ID must not be empty"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + func TestNewRequiresPool(t *testing.T) { t.Parallel() - cluster, err := New(Config{}) + cluster, err := New(Config{ID: testClusterID}) if err == nil { cluster.Close() t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: at least one pool is required"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: at least one pool is required"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -25,6 +43,7 @@ func TestNewRejectsInvalidPrimary(t *testing.T) { t.Parallel() cluster, err := New(Config{ + ID: testClusterID, Primary: &xpg.Pool{}, }) if err == nil { @@ -32,7 +51,7 @@ func TestNewRejectsInvalidPrimary(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: primary pool is invalid"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: primary pool is invalid"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -59,6 +78,7 @@ func TestNewRejectsInvalidReplica(t *testing.T) { t.Parallel() cluster, err := New(Config{ + ID: testClusterID, Replicas: []*xpg.Pool{test.replica}, }) if err == nil { @@ -66,7 +86,7 @@ func TestNewRejectsInvalidReplica(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: replica 0 is invalid"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: replica 0 is invalid"; got != want { t.Fatalf("error = %q, want %q", got, want) } }) @@ -130,6 +150,7 @@ func TestNewRejectsEmptyLabelKey(t *testing.T) { primary := newTestPool(t, "primary", nil) cluster, err := New(Config{ + ID: testClusterID, Labels: map[string]string{ "": "value", }, @@ -140,7 +161,7 @@ func TestNewRejectsEmptyLabelKey(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: label key must not be empty"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: label key must not be empty"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -153,6 +174,7 @@ func TestNewClonesReplicaSlice(t *testing.T) { replicas := []*xpg.Pool{replicaA} cluster, err := New(Config{ + ID: testClusterID, Replicas: replicas, }) if err != nil { @@ -173,6 +195,7 @@ func TestNewAllowsDuplicatePools(t *testing.T) { pool := newTestPool(t, "shared", nil) cluster, err := New(Config{ + ID: testClusterID, Primary: pool, Replicas: []*xpg.Pool{ pool, @@ -234,6 +257,7 @@ func TestNewCapturesReplicaMetadata(t *testing.T) { }) cluster, err := New(Config{ + ID: testClusterID, Replicas: []*xpg.Pool{replica}, Selector: selector, }) @@ -308,6 +332,7 @@ func TestCloseIsIdempotent(t *testing.T) { replica := newTestPool(t, "replica", nil) cluster, err := New(Config{ + ID: testClusterID, Primary: primary, Replicas: []*xpg.Pool{replica}, }) diff --git a/cluster/doc.go b/topology/cluster/doc.go similarity index 100% rename from cluster/doc.go rename to topology/cluster/doc.go diff --git a/cluster/errors.go b/topology/cluster/errors.go similarity index 61% rename from cluster/errors.go rename to topology/cluster/errors.go index 03adc17..d99bcbf 100644 --- a/cluster/errors.go +++ b/topology/cluster/errors.go @@ -5,9 +5,9 @@ import "errors" var ( // ErrNoPrimary is returned when an operation requires a primary but none // is configured. - ErrNoPrimary = errors.New("xpg/cluster: no primary available") + ErrNoPrimary = errors.New("xpg/topology/cluster: no primary available") // ErrNoReplica is returned when an operation requires a replica but none // can be selected. - ErrNoReplica = errors.New("xpg/cluster: no replica available") + ErrNoReplica = errors.New("xpg/topology/cluster: no replica available") ) diff --git a/cluster/helpers_test.go b/topology/cluster/helpers_test.go similarity index 77% rename from cluster/helpers_test.go rename to topology/cluster/helpers_test.go index 092a235..469163e 100644 --- a/cluster/helpers_test.go +++ b/topology/cluster/helpers_test.go @@ -7,7 +7,10 @@ import ( "github.com/mkbeh/xpg" ) -const testDatabaseURL = "postgres://postgres:postgres@127.0.0.1:1/postgres?sslmode=disable" //nolint:gosec // Test-only DSN with non-production credentials. +const ( + testDatabaseURL = "postgres://postgres:postgres@127.0.0.1:1/postgres?sslmode=disable" //nolint:gosec // Test-only DSN with non-production credentials. + testClusterID = ID("test-cluster") +) func newTestPool(t *testing.T, name string, labels map[string]string) *xpg.Pool { t.Helper() @@ -40,6 +43,10 @@ func newTestPool(t *testing.T, name string, labels map[string]string) *xpg.Pool func newTestCluster(t *testing.T, config Config) *Cluster { t.Helper() + if config.ID == "" { + config.ID = testClusterID + } + cluster, err := New(config) if err != nil { t.Fatalf("New() error = %v", err) diff --git a/cluster/resolver.go b/topology/cluster/resolver.go similarity index 87% rename from cluster/resolver.go rename to topology/cluster/resolver.go index 0d0a902..19ac9a8 100644 --- a/cluster/resolver.go +++ b/topology/cluster/resolver.go @@ -41,7 +41,7 @@ func ParseReadPolicy(value string) (ReadPolicy, error) { return ReadReplicaRequired, nil default: return 0, fmt.Errorf( - "xpg/cluster: unknown read policy %q", + "xpg/topology/cluster: unknown read policy %q", value, ) } @@ -67,7 +67,7 @@ func (policy ReadPolicy) String() string { // selected. Other selector errors are returned to the caller. func (c *Cluster) ReadPool(ctx context.Context, policy ReadPolicy) (*xpg.Pool, error) { if c == nil { - return nil, errors.New("xpg/cluster: cluster is nil") + return nil, errors.New("xpg/topology/cluster: cluster is nil") } switch policy { @@ -90,7 +90,7 @@ func (c *Cluster) ReadPool(ctx context.Context, policy ReadPolicy) (*xpg.Pool, e return c.resolveReplica(ctx) default: - return nil, fmt.Errorf("xpg/cluster: unsupported read policy %d", policy) + return nil, fmt.Errorf("xpg/topology/cluster: unsupported read policy %d", policy) } } @@ -109,12 +109,12 @@ func (c *Cluster) resolveReplica(ctx context.Context) (*xpg.Pool, error) { index, err := c.selector.Select(ctx, c.metadata) if err != nil { - return nil, fmt.Errorf("xpg/cluster: select replica: %w", err) + return nil, fmt.Errorf("xpg/topology/cluster: select replica: %w", err) } if index < 0 || index >= len(c.replicas) { return nil, fmt.Errorf( - "xpg/cluster: replica selector returned invalid index %d for %d replicas", + "xpg/topology/cluster: replica selector returned invalid index %d for %d replicas", index, len(c.replicas), ) diff --git a/cluster/routing_test.go b/topology/cluster/routing_test.go similarity index 95% rename from cluster/routing_test.go rename to topology/cluster/routing_test.go index bd08329..93bba97 100644 --- a/cluster/routing_test.go +++ b/topology/cluster/routing_test.go @@ -45,7 +45,7 @@ func TestParseReadPolicyRejectsUnknown(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), `xpg/cluster: unknown read policy "nearest"`; got != want { + if got, want := err.Error(), `xpg/topology/cluster: unknown read policy "nearest"`; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -84,7 +84,7 @@ func TestReadPoolNilCluster(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: cluster is nil"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: cluster is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -330,7 +330,7 @@ func TestReadPoolRejectsSelectorIndex(t *testing.T) { } want := fmt.Sprintf( - "xpg/cluster: replica selector returned invalid index %d for 1 replicas", + "xpg/topology/cluster: replica selector returned invalid index %d for 1 replicas", test.index, ) @@ -364,7 +364,7 @@ func TestReadPoolPreservesSelectorError(t *testing.T) { t.Fatalf("ReadPool() error = %v, want wrapped selector error", err) } - if got, want := err.Error(), "xpg/cluster: select replica: boom"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: select replica: boom"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -384,7 +384,7 @@ func TestReadPoolRejectsUnsupportedPolicy(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: unsupported read policy 255"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: unsupported read policy 255"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/cluster/selector.go b/topology/cluster/selector.go similarity index 96% rename from cluster/selector.go rename to topology/cluster/selector.go index ab43dd6..e76f7b3 100644 --- a/cluster/selector.go +++ b/topology/cluster/selector.go @@ -62,7 +62,7 @@ type ReplicaSelectorFunc func(context.Context, ReplicaSet) (int, error) // Select calls the wrapped selector function. func (selector ReplicaSelectorFunc) Select(ctx context.Context, replicas ReplicaSet) (int, error) { if selector == nil { - return -1, errors.New("xpg/cluster: replica selector function is nil") + return -1, errors.New("xpg/topology/cluster: replica selector function is nil") } return selector(ctx, replicas) diff --git a/cluster/selector_test.go b/topology/cluster/selector_test.go similarity index 97% rename from cluster/selector_test.go rename to topology/cluster/selector_test.go index 348eefa..662f394 100644 --- a/cluster/selector_test.go +++ b/topology/cluster/selector_test.go @@ -91,7 +91,7 @@ func TestReplicaSelectorFuncNil(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: replica selector function is nil"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: replica selector function is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/cluster/tx.go b/topology/cluster/tx.go similarity index 95% rename from cluster/tx.go rename to topology/cluster/tx.go index f2d5818..147842c 100644 --- a/cluster/tx.go +++ b/topology/cluster/tx.go @@ -23,7 +23,7 @@ func (c *Cluster) InPrimaryTx( fn func(context.Context, pgx.Tx) error, ) error { if c == nil { - return errors.New("xpg/cluster: cluster is nil") + return errors.New("xpg/topology/cluster: cluster is nil") } pool, err := c.resolvePrimary() diff --git a/cluster/tx_test.go b/topology/cluster/tx_test.go similarity index 97% rename from cluster/tx_test.go rename to topology/cluster/tx_test.go index 8478e22..c23f125 100644 --- a/cluster/tx_test.go +++ b/topology/cluster/tx_test.go @@ -27,7 +27,7 @@ func TestInPrimaryTxNilCluster(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/cluster: cluster is nil"; got != want { + if got, want := err.Error(), "xpg/topology/cluster: cluster is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } diff --git a/shard/doc.go b/topology/shard/doc.go similarity index 100% rename from shard/doc.go rename to topology/shard/doc.go diff --git a/topology/shard/errors.go b/topology/shard/errors.go new file mode 100644 index 0000000..711fe53 --- /dev/null +++ b/topology/shard/errors.go @@ -0,0 +1,53 @@ +package shard + +import ( + "errors" + "fmt" +) + +var ( + // ErrNoShard indicates that a resolver could not map a key to any shard. + ErrNoShard = errors.New("xpg/topology/shard: no shard resolved") + + // ErrUnknownShard indicates that routing configuration or custom routing + // logic referenced a shard that does not exist in the topology. + ErrUnknownShard = errors.New("xpg/topology/shard: unknown shard") + + // ErrShardMismatch indicates that keys expected to be colocated resolved to + // different shards. + ErrShardMismatch = errors.New("xpg/topology/shard: keys resolve to different shards") +) + +// UnknownShardError identifies a shard that does not exist in a topology. +type UnknownShardError struct { + ShardID ID +} + +func (e *UnknownShardError) Error() string { + return fmt.Sprintf("xpg/topology/shard: unknown shard %q", e.ShardID) +} + +func (e *UnknownShardError) Unwrap() error { + return ErrUnknownShard +} + +// MismatchError describes the first key that resolved to a different shard +// than the first key. +type MismatchError struct { + Expected ID + Actual ID + Index int +} + +func (e *MismatchError) Error() string { + return fmt.Sprintf( + "xpg/topology/shard: key %d resolved to shard %q instead of %q", + e.Index, + e.Actual, + e.Expected, + ) +} + +func (e *MismatchError) Unwrap() error { + return ErrShardMismatch +} diff --git a/shard/errors_test.go b/topology/shard/errors_test.go similarity index 73% rename from shard/errors_test.go rename to topology/shard/errors_test.go index 5523408..0e26a18 100644 --- a/shard/errors_test.go +++ b/topology/shard/errors_test.go @@ -10,7 +10,7 @@ func TestUnknownShardError(t *testing.T) { err := &UnknownShardError{ShardID: "missing"} - if got, want := err.Error(), `xpg/shard: unknown shard "missing"`; got != want { + if got, want := err.Error(), `xpg/topology/shard: unknown shard "missing"`; got != want { t.Fatalf("Error() = %q, want %q", got, want) } @@ -28,7 +28,7 @@ func TestMismatchError(t *testing.T) { Index: 2, } - if got, want := err.Error(), `xpg/shard: key 2 resolved to shard "shard-b" instead of "shard-a"`; got != want { + if got, want := err.Error(), `xpg/topology/shard: key 2 resolved to shard "shard-b" instead of "shard-a"`; got != want { t.Fatalf("Error() = %q, want %q", got, want) } diff --git a/shard/foreach.go b/topology/shard/foreach.go similarity index 53% rename from shard/foreach.go rename to topology/shard/foreach.go index 9effadc..03b00d1 100644 --- a/shard/foreach.go +++ b/topology/shard/foreach.go @@ -7,7 +7,7 @@ import ( "sync" ) -// ForEachShardResult contains the result associated with one shard. +// ForEachShardResult contains the result of one shard callback invocation. type ForEachShardResult struct { ShardID ID Err error @@ -28,7 +28,7 @@ func (results ForEachShardResults) Err() error { errs = append( errs, fmt.Errorf( - "xpg/shard: shard %q: %w", + "xpg/topology/shard: shard %q callback: %w", result.ShardID, result.Err, ), @@ -38,42 +38,41 @@ func (results ForEachShardResults) Err() error { return errors.Join(errs...) } -// ForEachShard invokes fn across the topology with at most concurrency -// callbacks running at once. Results are returned in topology registration -// order; callback execution order is not guaranteed. -// -// Callback failures and context cancellation are stored in the corresponding -// results and can be joined with ForEachShardResults.Err. The returned error is -// reserved for invalid invocation arguments. +// ForEachShard invokes fn for each shard with at most concurrency callbacks +// running at once. Results are returned in topology registration order. The +// returned error joins all per-shard failures and is equivalent to results.Err(). // // Once context cancellation is observed, callbacks that have not started are // skipped and their results contain ctx.Err(). Callbacks already running are -// responsible for observing ctx. ForEachShard waits for all started callbacks -// to finish before returning. +// responsible for observing ctx. func (t *Topology) ForEachShard( ctx context.Context, concurrency int, fn func(context.Context, Shard) error, ) (ForEachShardResults, error) { - if t == nil { - return nil, errors.New("xpg/shard: topology is nil") - } - - if len(t.shards) == 0 { - return nil, errors.New("xpg/shard: topology is empty") + if t == nil || len(t.shards) == 0 { + return nil, errors.New("xpg/topology/shard: topology is nil or empty") } if concurrency <= 0 { - return nil, errors.New("xpg/shard: concurrency must be positive") + return nil, errors.New("xpg/topology/shard: concurrency must be positive") } if fn == nil { - return nil, errors.New("xpg/shard: callback is nil") + return nil, errors.New("xpg/topology/shard: callback is nil") } results := make(ForEachShardResults, len(t.shards)) - for index, shard := range t.shards { - results[index].ShardID = shard.ID() + for index, current := range t.shards { + results[index].ShardID = current.ID() + } + + if err := ctx.Err(); err != nil { + for index := range results { + results[index].Err = err + } + + return results, results.Err() } workerCount := min(concurrency, len(t.shards)) @@ -87,8 +86,6 @@ func (t *Topology) ForEachShard( defer workers.Done() for index := range jobs { - // An index may have been scheduled immediately before context - // cancellation. Skip callbacks that have not started yet. if err := ctx.Err(); err != nil { results[index].Err = err continue @@ -102,9 +99,6 @@ func (t *Topology) ForEachShard( nextIndex := 0 for nextIndex < len(t.shards) && ctx.Err() == nil { - // The explicit context check above prevents scheduling new work after - // cancellation has already been observed. The select still handles - // cancellation that happens while waiting for a worker. select { case jobs <- nextIndex: nextIndex++ @@ -115,13 +109,11 @@ func (t *Topology) ForEachShard( close(jobs) workers.Wait() - // Workers own results for scheduled indexes [0, nextIndex). After all - // workers finish, remaining indexes can be marked canceled without races. if err := ctx.Err(); err != nil { for index := nextIndex; index < len(results); index++ { results[index].Err = err } } - return results, nil + return results, results.Err() } diff --git a/shard/foreach_test.go b/topology/shard/foreach_test.go similarity index 87% rename from shard/foreach_test.go rename to topology/shard/foreach_test.go index ceb8060..a60c93f 100644 --- a/shard/foreach_test.go +++ b/topology/shard/foreach_test.go @@ -25,33 +25,35 @@ func TestForEachShardValidatesArguments(t *testing.T) { topology: nil, concurrency: 1, fn: func(context.Context, Shard) error { return nil }, - wantError: "xpg/shard: topology is nil", + wantError: "xpg/topology/shard: topology is nil or empty", }, { name: "empty topology", topology: &Topology{}, concurrency: 1, fn: func(context.Context, Shard) error { return nil }, - wantError: "xpg/shard: topology is empty", + wantError: "xpg/topology/shard: topology is nil or empty", }, { name: "zero concurrency", topology: topology, concurrency: 0, fn: func(context.Context, Shard) error { return nil }, - wantError: "xpg/shard: concurrency must be positive", + wantError: "xpg/topology/shard: concurrency must be positive", }, { name: "nil callback", topology: topology, concurrency: 1, fn: nil, - wantError: "xpg/shard: callback is nil", + wantError: "xpg/topology/shard: callback is nil", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := test.topology.ForEachShard( t.Context(), test.concurrency, @@ -217,8 +219,8 @@ func TestForEachShardCallbackErrorsDoNotStopOtherShards(t *testing.T) { return nil }, ) - if err != nil { - t.Fatalf("ForEachShard() error = %v", err) + if !errors.Is(err, sentinel) { + t.Fatalf("ForEachShard() error = %v, want wrapped sentinel", err) } if got, want := calls.Load(), int32(3); got != want { @@ -234,10 +236,13 @@ func TestForEachShardCallbackErrorsDoNotStopOtherShards(t *testing.T) { t.Fatalf("results.Err() = %v, want wrapped sentinel", joined) } - if got, want := joined.Error(), - `xpg/shard: shard "shard-b": callback failed`; got != want { + if got, want := joined.Error(), `xpg/topology/shard: shard "shard-b" callback: callback failed`; got != want { t.Fatalf("results.Err() = %q, want %q", got, want) } + + if got, want := err.Error(), joined.Error(); got != want { + t.Fatalf("ForEachShard() error = %q, want %q", got, want) + } } func TestForEachShardCanceledBeforeScheduling(t *testing.T) { @@ -257,8 +262,8 @@ func TestForEachShardCanceledBeforeScheduling(t *testing.T) { return nil }, ) - if err != nil { - t.Fatalf("ForEachShard() error = %v", err) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ForEachShard() error = %v, want context.Canceled", err) } if got := calls.Load(); got != 0 { @@ -311,8 +316,8 @@ func TestForEachShardCancellationSkipsCallbacksNotStarted(t *testing.T) { cancel() outcome := <-done - if outcome.err != nil { - t.Fatalf("ForEachShard() error = %v", outcome.err) + if !errors.Is(outcome.err, context.Canceled) { + t.Fatalf("ForEachShard() error = %v, want context.Canceled", outcome.err) } if got := calls.Load(); got != 1 { @@ -343,8 +348,8 @@ func TestForEachShardResultsErr(t *testing.T) { t.Fatalf("Err() = %v, want both failures", err) } - want := "xpg/shard: shard \"shard-a\": first\n" + - "xpg/shard: shard \"shard-c\": second" + want := "xpg/topology/shard: shard \"shard-a\" callback: first\n" + + "xpg/topology/shard: shard \"shard-c\" callback: second" if got := err.Error(); got != want { t.Fatalf("Err() = %q, want %q", got, want) diff --git a/shard/group.go b/topology/shard/group.go similarity index 59% rename from shard/group.go rename to topology/shard/group.go index 5d04c0d..78b4cd8 100644 --- a/shard/group.go +++ b/topology/shard/group.go @@ -5,13 +5,11 @@ import ( "fmt" ) -// SameShard resolves keys and verifies that they all belong to the same shard. -// -// It returns ErrNoShard when no keys are provided and MismatchError when a key -// resolves to a different shard. +// SameShard resolves the keys and verifies that they all belong to the same +// shard. It returns that shard when all keys are colocated. func SameShard[K any](resolver Resolver[K], keys ...K) (Shard, error) { if resolver == nil { - return Shard{}, errors.New("xpg/shard: resolver is nil") + return Shard{}, errors.New("xpg/topology/shard: resolver is nil") } if len(keys) == 0 { @@ -20,7 +18,7 @@ func SameShard[K any](resolver Resolver[K], keys ...K) (Shard, error) { expected, err := resolver.Resolve(keys[0]) if err != nil { - return Shard{}, fmt.Errorf("xpg/shard: resolve key 0: %w", err) + return Shard{}, fmt.Errorf("xpg/topology/shard: resolve key 0: %w", err) } expectedID := expected.ID() @@ -28,7 +26,7 @@ func SameShard[K any](resolver Resolver[K], keys ...K) (Shard, error) { for index := 1; index < len(keys); index++ { actual, err := resolver.Resolve(keys[index]) if err != nil { - return Shard{}, fmt.Errorf("xpg/shard: resolve key %d: %w", index, err) + return Shard{}, fmt.Errorf("xpg/topology/shard: resolve key %d: %w", index, err) } actualID := actual.ID() @@ -46,20 +44,18 @@ func SameShard[K any](resolver Resolver[K], keys ...K) (Shard, error) { return expected, nil } -// Group contains keys that resolve to the same shard. -// Keys preserve their original relative order. +// Group contains input keys that resolve to one shard. Keys preserve their +// original relative order. type Group[K any] struct { Shard Shard Keys []K } -// GroupByShard resolves each key once and groups keys by shard. -// -// Groups are returned in order of each shard's first appearance in keys. -// Keys within each group preserve their original relative order. +// GroupByShard resolves every key once and returns groups in order of each +// shard's first appearance in the input. func GroupByShard[K any](resolver Resolver[K], keys []K) ([]Group[K], error) { if resolver == nil { - return nil, errors.New("xpg/shard: resolver is nil") + return nil, errors.New("xpg/topology/shard: resolver is nil") } groups := make([]Group[K], 0) @@ -68,7 +64,7 @@ func GroupByShard[K any](resolver Resolver[K], keys []K) ([]Group[K], error) { for keyIndex, key := range keys { resolved, err := resolver.Resolve(key) if err != nil { - return nil, fmt.Errorf("xpg/shard: resolve key %d: %w", keyIndex, err) + return nil, fmt.Errorf("xpg/topology/shard: resolve key %d: %w", keyIndex, err) } id := resolved.ID() diff --git a/shard/group_test.go b/topology/shard/group_test.go similarity index 86% rename from shard/group_test.go rename to topology/shard/group_test.go index 5d548f5..74d405f 100644 --- a/shard/group_test.go +++ b/topology/shard/group_test.go @@ -39,7 +39,7 @@ func TestSameShardRejectsNilResolver(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard: resolver is nil"; got != want { + if got, want := err.Error(), "xpg/topology/shard: resolver is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -71,7 +71,7 @@ func TestSameShardWrapsFirstResolveError(t *testing.T) { t.Fatalf("error = %v, want wrapped sentinel", err) } - if got, want := err.Error(), "xpg/shard: resolve key 0: resolve failed"; got != want { + if got, want := err.Error(), "xpg/topology/shard: resolve key 0: resolve failed"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -79,13 +79,16 @@ func TestSameShardWrapsFirstResolveError(t *testing.T) { func TestSameShardWrapsResolveErrorWithIndex(t *testing.T) { t.Parallel() + topology := newTestTopology(t, "shard-a") + resolved := topology.At(0) sentinel := errors.New("resolve failed") + resolver := testResolverFunc[int](func(key int) (Shard, error) { if key == 2 { return Shard{}, sentinel } - return Shard{}, nil + return resolved, nil }) _, err := SameShard(resolver, 1, 2) @@ -93,7 +96,7 @@ func TestSameShardWrapsResolveErrorWithIndex(t *testing.T) { t.Fatalf("error = %v, want wrapped sentinel", err) } - if got, want := err.Error(), "xpg/shard: resolve key 1: resolve failed"; got != want { + if got, want := err.Error(), "xpg/topology/shard: resolve key 1: resolve failed"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -189,8 +192,8 @@ func TestGroupByShardResolvesEachKeyOnce(t *testing.T) { t.Fatalf("resolve calls = %d, want %d", got, want) } - if len(groups) != 1 { - t.Fatalf("len(groups) = %d, want 1", len(groups)) + if got, want := len(groups), 1; got != want { + t.Fatalf("len(groups) = %d, want %d", got, want) } } @@ -202,7 +205,7 @@ func TestGroupByShardRejectsNilResolver(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard: resolver is nil"; got != want { + if got, want := err.Error(), "xpg/topology/shard: resolver is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -220,21 +223,24 @@ func TestGroupByShardEmptyKeys(t *testing.T) { t.Fatalf("GroupByShard() error = %v", err) } - if len(groups) != 0 { - t.Fatalf("len(groups) = %d, want 0", len(groups)) + if got := len(groups); got != 0 { + t.Fatalf("len(groups) = %d, want 0", got) } } func TestGroupByShardWrapsResolveErrorWithIndex(t *testing.T) { t.Parallel() + topology := newTestTopology(t, "shard-a") + resolved := topology.At(0) sentinel := errors.New("resolve failed") + resolver := testResolverFunc[int](func(key int) (Shard, error) { if key == 3 { return Shard{}, sentinel } - return Shard{}, nil + return resolved, nil }) _, err := GroupByShard(resolver, []int{1, 2, 3}) @@ -242,7 +248,7 @@ func TestGroupByShardWrapsResolveErrorWithIndex(t *testing.T) { t.Fatalf("error = %v, want wrapped sentinel", err) } - if got, want := err.Error(), "xpg/shard: resolve key 2: resolve failed"; got != want { + if got, want := err.Error(), "xpg/topology/shard: resolve key 2: resolve failed"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/helpers_test.go b/topology/shard/helpers_test.go similarity index 79% rename from shard/helpers_test.go rename to topology/shard/helpers_test.go index 17aab99..63f980b 100644 --- a/shard/helpers_test.go +++ b/topology/shard/helpers_test.go @@ -5,7 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" + "github.com/mkbeh/xpg/topology/cluster" ) const testDatabaseURL = "postgres://postgres@127.0.0.1:1/postgres?sslmode=disable" @@ -30,7 +30,7 @@ func newTestCluster(t *testing.T, id ID, labels map[string]string) *cluster.Clus t.Fatalf("xpg.New() error = %v", err) } - shardCluster, err := cluster.New(cluster.Config{ + dbCluster, err := cluster.New(cluster.Config{ ID: id, Labels: labels, Primary: pool, @@ -40,23 +40,20 @@ func newTestCluster(t *testing.T, id ID, labels map[string]string) *cluster.Clus t.Fatalf("cluster.New() error = %v", err) } - t.Cleanup(shardCluster.Close) + t.Cleanup(dbCluster.Close) - return shardCluster + return dbCluster } func newTestTopology(t *testing.T, ids ...ID) *Topology { t.Helper() - configs := make([]Config, len(ids)) - + clusters := make([]*cluster.Cluster, len(ids)) for index, id := range ids { - configs[index] = Config{ - Cluster: newTestCluster(t, id, nil), - } + clusters[index] = newTestCluster(t, id, nil) } - topology, err := NewTopology(configs) + topology, err := NewTopology(clusters...) if err != nil { t.Fatalf("NewTopology() error = %v", err) } diff --git a/topology/shard/resolver.go b/topology/shard/resolver.go new file mode 100644 index 0000000..800cd39 --- /dev/null +++ b/topology/shard/resolver.go @@ -0,0 +1,10 @@ +package shard + +// Resolver maps a typed application key to the shard owning that key. +// +// Resolve should return ErrNoShard when the key cannot be mapped to a shard. +// When Resolve returns nil error, it must return a valid Shard. Implementations +// shared by concurrent callers must be concurrency-safe. +type Resolver[K any] interface { + Resolve(key K) (Shard, error) +} diff --git a/topology/shard/resolver/custom.go b/topology/shard/resolver/custom.go new file mode 100644 index 0000000..bd7b39c --- /dev/null +++ b/topology/shard/resolver/custom.go @@ -0,0 +1,55 @@ +package resolver + +import ( + "errors" + + "github.com/mkbeh/xpg/topology/shard" +) + +// ResolveFunc maps an application key to a shard ID. +// +// Resolve functions should return shard.ErrNoShard when a key cannot be mapped +// to a shard. Implementations shared by concurrent callers must be deterministic +// and concurrency-safe. They should not perform hidden I/O. +type ResolveFunc[K any] func(key K) (shard.ID, error) + +// CustomResolver adapts ResolveFunc to shard.Resolver. +type CustomResolver[K any] struct { + topology *shard.Topology + resolve ResolveFunc[K] +} + +// NewCustom binds custom routing logic to one immutable topology. +func NewCustom[K any](topology *shard.Topology, resolve ResolveFunc[K]) (*CustomResolver[K], error) { + if err := requireTopology(topology); err != nil { + return nil, err + } + + if resolve == nil { + return nil, errors.New("xpg/topology/shard/resolver: custom resolve function is nil") + } + + return &CustomResolver[K]{ + topology: topology, + resolve: resolve, + }, nil +} + +// Resolve maps key to a shard and rejects IDs absent from the bound topology. +func (resolver *CustomResolver[K]) Resolve(key K) (shard.Shard, error) { + if resolver == nil || resolver.topology == nil || resolver.resolve == nil { + return shard.Shard{}, errors.New("xpg/topology/shard/resolver: custom resolver is not initialized") + } + + id, err := resolver.resolve(key) + if err != nil { + return shard.Shard{}, err + } + + resolved, ok := resolver.topology.Shard(id) + if !ok { + return shard.Shard{}, &shard.UnknownShardError{ShardID: id} + } + + return resolved, nil +} diff --git a/shard/resolver/custom_test.go b/topology/shard/resolver/custom_test.go similarity index 83% rename from shard/resolver/custom_test.go rename to topology/shard/resolver/custom_test.go index 764c4f4..04d6c53 100644 --- a/shard/resolver/custom_test.go +++ b/topology/shard/resolver/custom_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) func TestNewCustomValidatesArguments(t *testing.T) { @@ -13,7 +13,7 @@ func TestNewCustomValidatesArguments(t *testing.T) { topology := newTestTopology(t, "shard-a") validResolve := ResolveFunc[int]( - func(int, *shard.Topology) (shard.ID, error) { + func(int) (shard.ID, error) { return "shard-a", nil }, ) @@ -27,12 +27,12 @@ func TestNewCustomValidatesArguments(t *testing.T) { { name: "nil topology", resolve: validResolve, - wantError: "xpg/shard/resolver: topology is nil or empty", + wantError: "xpg/topology/shard/resolver: topology is nil or empty", }, { name: "nil resolve function", topology: topology, - wantError: "xpg/shard/resolver: custom resolve function is nil", + wantError: "xpg/topology/shard/resolver: custom resolve function is nil", }, } @@ -62,11 +62,7 @@ func TestCustomResolverResolve(t *testing.T) { resolver, err := NewCustom( topology, - func(key int, gotTopology *shard.Topology) (shard.ID, error) { - if gotTopology != topology { - t.Fatal("resolve function received a different topology") - } - + func(key int) (shard.ID, error) { if key < 100 { return "shard-a", nil } @@ -96,7 +92,7 @@ func TestCustomResolverPropagatesResolveError(t *testing.T) { resolver, err := NewCustom( topology, - func(int, *shard.Topology) (shard.ID, error) { + func(int) (shard.ID, error) { return "", sentinel }, ) @@ -117,7 +113,7 @@ func TestCustomResolverPropagatesErrNoShard(t *testing.T) { resolver, err := NewCustom( topology, - func(int, *shard.Topology) (shard.ID, error) { + func(int) (shard.ID, error) { return "", shard.ErrNoShard }, ) @@ -138,7 +134,7 @@ func TestCustomResolverRejectsUnknownShard(t *testing.T) { resolver, err := NewCustom( topology, - func(int, *shard.Topology) (shard.ID, error) { + func(int) (shard.ID, error) { return "missing", nil }, ) @@ -171,7 +167,7 @@ func TestCustomResolverUninitialized(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard/resolver: custom resolver is not initialized"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: custom resolver is not initialized"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/resolver/doc.go b/topology/shard/resolver/doc.go similarity index 100% rename from shard/resolver/doc.go rename to topology/shard/resolver/doc.go diff --git a/shard/resolver/encoder.go b/topology/shard/resolver/encoder.go similarity index 96% rename from shard/resolver/encoder.go rename to topology/shard/resolver/encoder.go index 995357e..fd98310 100644 --- a/shard/resolver/encoder.go +++ b/topology/shard/resolver/encoder.go @@ -20,7 +20,7 @@ type KeyEncoderFunc[K any] func(K) ([]byte, error) // Encode calls the wrapped encoder function. func (encoder KeyEncoderFunc[K]) Encode(key K) ([]byte, error) { if encoder == nil { - return nil, errors.New("xpg/shard/resolver: key encoder function is nil") + return nil, errors.New("xpg/topology/shard/resolver: key encoder function is nil") } return encoder(key) diff --git a/shard/resolver/encoder_test.go b/topology/shard/resolver/encoder_test.go similarity index 96% rename from shard/resolver/encoder_test.go rename to topology/shard/resolver/encoder_test.go index 067187b..cb78236 100644 --- a/shard/resolver/encoder_test.go +++ b/topology/shard/resolver/encoder_test.go @@ -17,7 +17,7 @@ func TestKeyEncoderFuncNil(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard/resolver: key encoder function is nil"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: key encoder function is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/resolver/helpers_test.go b/topology/shard/resolver/helpers_test.go similarity index 64% rename from shard/resolver/helpers_test.go rename to topology/shard/resolver/helpers_test.go index a12991b..2881ec7 100644 --- a/shard/resolver/helpers_test.go +++ b/topology/shard/resolver/helpers_test.go @@ -5,8 +5,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/cluster" + "github.com/mkbeh/xpg/topology/shard" ) const testDatabaseURL = "postgres://postgres@127.0.0.1:1/postgres?sslmode=disable" @@ -14,11 +14,18 @@ const testDatabaseURL = "postgres://postgres@127.0.0.1:1/postgres?sslmode=disabl func newTestTopology(t *testing.T, ids ...shard.ID) *shard.Topology { t.Helper() - configs := make([]shard.Config, len(ids)) + clusters := make([]*cluster.Cluster, 0, len(ids)) - for index, id := range ids { + closeClusters := func() { + for index := len(clusters) - 1; index >= 0; index-- { + clusters[index].Close() + } + } + + for _, id := range ids { poolConfig, err := pgxpool.ParseConfig(testDatabaseURL) if err != nil { + closeClusters() t.Fatalf("pgxpool.ParseConfig() error = %v", err) } @@ -31,25 +38,26 @@ func newTestTopology(t *testing.T, ids ...shard.ID) *shard.Topology { xpg.WithName("shard."+string(id)+".primary"), ) if err != nil { + closeClusters() t.Fatalf("xpg.New() error = %v", err) } - shardCluster, err := cluster.New(cluster.Config{ + dbCluster, err := cluster.New(cluster.Config{ ID: id, Primary: pool, }) if err != nil { pool.Close() + closeClusters() t.Fatalf("cluster.New() error = %v", err) } - t.Cleanup(shardCluster.Close) - - configs[index] = shard.Config{Cluster: shardCluster} + clusters = append(clusters, dbCluster) } - topology, err := shard.NewTopology(configs) + topology, err := shard.NewTopology(clusters...) if err != nil { + closeClusters() t.Fatalf("shard.NewTopology() error = %v", err) } diff --git a/shard/resolver/range.go b/topology/shard/resolver/range.go similarity index 66% rename from shard/resolver/range.go rename to topology/shard/resolver/range.go index 9714777..9300ff9 100644 --- a/shard/resolver/range.go +++ b/topology/shard/resolver/range.go @@ -7,7 +7,7 @@ import ( "slices" "sort" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) // Range maps the bounded half-open interval [Start, End) to one shard. @@ -20,45 +20,50 @@ type Range[K cmp.Ordered] struct { ShardID shard.ID } -// RangeResolver routes ordered keys through non-overlapping ranges. +// RangeResolver resolves ordered keys through bounded, non-overlapping ranges. type RangeResolver[K cmp.Ordered] struct { ranges []rangeEntry[K] } -// NewRange creates a resolver from non-overlapping half-open ranges. +type rangeEntry[K cmp.Ordered] struct { + start K + end K + shard shard.Shard + + sourceIndex int +} + +// NewRange creates a resolver from bounded, non-overlapping half-open ranges. // -// The supplied ranges may be unordered. NewRange copies and sorts them by Start, -// validates their boundaries and overlap, and leaves the caller's slice unchanged. +// NewRange resolves every ShardID to its immutable Shard handle once, copies +// the routing data into an internal representation, sorts it by Start, and +// validates that ranges do not overlap. The caller's slice is not modified. func NewRange[K cmp.Ordered](topology *shard.Topology, ranges []Range[K]) (*RangeResolver[K], error) { if err := requireTopology(topology); err != nil { return nil, err } if len(ranges) == 0 { - return nil, errors.New("xpg/shard/resolver: range resolver requires at least one range") + return nil, errors.New("xpg/topology/shard/resolver: range resolver requires at least one range") } entries := make([]rangeEntry[K], len(ranges)) for index, valueRange := range ranges { if err := requireShardID(valueRange.ShardID); err != nil { - return nil, fmt.Errorf("xpg/shard/resolver: range %d: %w", index, err) + return nil, fmt.Errorf("xpg/topology/shard/resolver: range %d: %w", index, err) } - // Using < intentionally rejects empty and reversed ranges as well as - // ranges with NaN boundaries for floating-point key types. if !(valueRange.Start < valueRange.End) { //nolint:staticcheck // Negated comparison intentionally rejects NaN boundaries. - return nil, fmt.Errorf("xpg/shard/resolver: range %d must satisfy start < end", index) + return nil, fmt.Errorf("xpg/topology/shard/resolver: range %d must satisfy start < end", index) } resolved, ok := topology.Shard(valueRange.ShardID) if !ok { return nil, fmt.Errorf( - "xpg/shard/resolver: range %d: %w", + "xpg/topology/shard/resolver: range %d: %w", index, - &shard.UnknownShardError{ - ShardID: valueRange.ShardID, - }, + &shard.UnknownShardError{ShardID: valueRange.ShardID}, ) } @@ -77,21 +82,16 @@ func NewRange[K cmp.Ordered](topology *shard.Topology, ranges []Range[K]) (*Rang }, ) - // Once ranges are sorted by Start, checking adjacent entries is sufficient - // to detect every overlap. for index := 1; index < len(entries); index++ { previous := entries[index-1] current := entries[index] - // Adjacent half-open ranges are valid: - // - // [0, 100) and [100, 200) if previous.end <= current.start { continue } return nil, fmt.Errorf( - "xpg/shard/resolver: ranges %d and %d overlap", + "xpg/topology/shard/resolver: ranges %d and %d overlap", previous.sourceIndex, current.sourceIndex, ) @@ -102,10 +102,13 @@ func NewRange[K cmp.Ordered](topology *shard.Topology, ranges []Range[K]) (*Rang }, nil } -// Resolve returns the shard whose configured range contains key. +// Resolve returns the shard whose range contains key. +// +// Resolve performs only an in-memory lookup. It does not consult topology, +// acquire a connection, or execute a PostgreSQL query. func (resolver *RangeResolver[K]) Resolve(key K) (shard.Shard, error) { if resolver == nil || len(resolver.ranges) == 0 { - return shard.Shard{}, errors.New("xpg/shard/resolver: range resolver is not initialized") + return shard.Shard{}, errors.New("xpg/topology/shard/resolver: range resolver is not initialized") } // Non-overlap validation guarantees strictly increasing upper boundaries, @@ -131,11 +134,3 @@ func (resolver *RangeResolver[K]) Resolve(key K) (shard.Shard, error) { return entry.shard, nil } - -type rangeEntry[K cmp.Ordered] struct { - start K - end K - shard shard.Shard - - sourceIndex int -} diff --git a/shard/resolver/range_test.go b/topology/shard/resolver/range_test.go similarity index 86% rename from shard/resolver/range_test.go rename to topology/shard/resolver/range_test.go index d001386..9e78f28 100644 --- a/shard/resolver/range_test.go +++ b/topology/shard/resolver/range_test.go @@ -6,7 +6,7 @@ import ( "slices" "testing" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) func TestNewRangeValidatesArguments(t *testing.T) { @@ -25,12 +25,12 @@ func TestNewRangeValidatesArguments(t *testing.T) { ranges: []Range[int]{ {Start: 0, End: 10, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: topology is nil or empty", + wantError: "xpg/topology/shard/resolver: topology is nil or empty", }, { name: "empty ranges", topology: topology, - wantError: "xpg/shard/resolver: range resolver requires at least one range", + wantError: "xpg/topology/shard/resolver: range resolver requires at least one range", }, { name: "empty shard ID", @@ -38,7 +38,7 @@ func TestNewRangeValidatesArguments(t *testing.T) { ranges: []Range[int]{ {Start: 0, End: 10}, }, - wantError: "xpg/shard/resolver: range 0: shard ID must not be empty", + wantError: "xpg/topology/shard/resolver: range 0: shard ID must not be empty", }, { name: "empty interval", @@ -46,7 +46,7 @@ func TestNewRangeValidatesArguments(t *testing.T) { ranges: []Range[int]{ {Start: 10, End: 10, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: range 0 must satisfy start < end", + wantError: "xpg/topology/shard/resolver: range 0 must satisfy start < end", }, { name: "reversed interval", @@ -54,7 +54,7 @@ func TestNewRangeValidatesArguments(t *testing.T) { ranges: []Range[int]{ {Start: 20, End: 10, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: range 0 must satisfy start < end", + wantError: "xpg/topology/shard/resolver: range 0 must satisfy start < end", }, { name: "unknown shard", @@ -62,7 +62,7 @@ func TestNewRangeValidatesArguments(t *testing.T) { ranges: []Range[int]{ {Start: 0, End: 10, ShardID: "missing"}, }, - wantError: `xpg/shard/resolver: range 0: xpg/shard: unknown shard "missing"`, + wantError: `xpg/topology/shard/resolver: range 0: xpg/topology/shard: unknown shard "missing"`, }, } @@ -125,7 +125,7 @@ func TestNewRangeRejectsNaNBoundaries(t *testing.T) { } if got, want := err.Error(), - "xpg/shard/resolver: range 0 must satisfy start < end"; got != want { + "xpg/topology/shard/resolver: range 0 must satisfy start < end"; got != want { t.Fatalf("error = %q, want %q", got, want) } }) @@ -145,7 +145,7 @@ func TestNewRangeRejectsOverlapUsingSourceIndexes(t *testing.T) { t.Fatal("expected overlap error") } - if got, want := err.Error(), "xpg/shard/resolver: ranges 1 and 0 overlap"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: ranges 1 and 0 overlap"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -269,7 +269,7 @@ func TestRangeResolverUninitialized(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard/resolver: range resolver is not initialized"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: range resolver is not initialized"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/resolver/hash.go b/topology/shard/resolver/rendezvous.go similarity index 53% rename from shard/resolver/hash.go rename to topology/shard/resolver/rendezvous.go index a631afe..d34ea32 100644 --- a/shard/resolver/hash.go +++ b/topology/shard/resolver/rendezvous.go @@ -8,7 +8,7 @@ import ( "fmt" "math" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) const ( @@ -20,54 +20,52 @@ const ( rendezvousLengthSize = 4 ) -// HashResolver routes keys using rendezvous/HRW hashing with SHA-256. -// -// HashResolver captures the shard set when it is created. The shards remain -// borrowed from the topology, so the resolver must not outlive it. -type HashResolver[K any] struct { - shards []shard.Shard - prefix []byte - encoder KeyEncoder[K] - maxShardIDLength int +// RendezvousResolver implements rendezvous/HRW routing with SHA-256 and stable +// named shard IDs. +type RendezvousResolver[K any] struct { + shards []shard.Shard + prefix []byte + encoder KeyEncoder[K] + maxIDLength int } -// NewHash creates a rendezvous hash resolver bound to topology. +// NewRendezvous creates the version-1 rendezvous resolver bound to topology. // -// Namespace is an opaque non-empty string and part of the persistent placement -// contract. Changing the namespace, key encoder, shard IDs, or placement format -// changes shard placement and may require data migration. -func NewHash[K any]( +// Namespace is part of the persistent placement contract. Changing it changes +// shard placement and may require data migration. The topology's shard slice is +// copied once; Resolve does not consult topology. +func NewRendezvous[K any]( topology *shard.Topology, namespace string, encoder KeyEncoder[K], -) (*HashResolver[K], error) { +) (*RendezvousResolver[K], error) { if err := requireTopology(topology); err != nil { return nil, err } if encoder == nil { - return nil, errors.New("xpg/shard/resolver: key encoder is nil") + return nil, errors.New("xpg/topology/shard/resolver: key encoder is nil") } if namespace == "" { - return nil, errors.New("xpg/shard/resolver: hash namespace must not be empty") + return nil, errors.New("xpg/topology/shard/resolver: rendezvous namespace must not be empty") } - if len(namespace) > math.MaxUint32 { - return nil, errors.New("xpg/shard/resolver: hash namespace is too large") + if uint64(len(namespace)) > uint64(math.MaxUint32) { + return nil, errors.New("xpg/topology/shard/resolver: rendezvous namespace is too large") } shards := topology.Shards() - maxShardIDLength := 0 + maxIDLength := 0 for _, candidate := range shards { id := candidate.ID() - if len(id) > math.MaxUint32 { - return nil, errors.New("xpg/shard/resolver: shard ID is too large") + if uint64(len(id)) > uint64(math.MaxUint32) { + return nil, errors.New("xpg/topology/shard/resolver: shard ID is too large") } - maxShardIDLength = max(maxShardIDLength, len(id)) + maxIDLength = max(maxIDLength, len(id)) } prefixSize := len(rendezvousDomain) + rendezvousLengthSize + len(namespace) @@ -85,27 +83,27 @@ func NewHash[K any]( copy(prefix[namespaceOffset:], namespace) - return &HashResolver[K]{ - shards: shards, - prefix: prefix, - encoder: encoder, - maxShardIDLength: maxShardIDLength, + return &RendezvousResolver[K]{ + shards: shards, + prefix: prefix, + encoder: encoder, + maxIDLength: maxIDLength, }, nil } // Resolve maps key to a shard using rendezvous hashing. -func (resolver *HashResolver[K]) Resolve(key K) (shard.Shard, error) { +func (resolver *RendezvousResolver[K]) Resolve(key K) (shard.Shard, error) { if resolver == nil || len(resolver.shards) == 0 || resolver.encoder == nil { - return shard.Shard{}, errors.New("xpg/shard/resolver: hash resolver is not initialized") + return shard.Shard{}, errors.New("xpg/topology/shard/resolver: rendezvous resolver is not initialized") } encoded, err := resolver.encoder.Encode(key) if err != nil { - return shard.Shard{}, fmt.Errorf("xpg/shard/resolver: encode hash key: %w", err) + return shard.Shard{}, fmt.Errorf("xpg/topology/shard/resolver: encode rendezvous key: %w", err) } - if len(encoded) > math.MaxUint32 { - return shard.Shard{}, errors.New("xpg/shard/resolver: encoded key is too large") + if uint64(len(encoded)) > uint64(math.MaxUint32) { + return shard.Shard{}, errors.New("xpg/topology/shard/resolver: encoded key is too large") } keyLengthOffset := len(resolver.prefix) @@ -113,17 +111,7 @@ func (resolver *HashResolver[K]) Resolve(key K) (shard.Shard, error) { idLengthOffset := keyOffset + len(encoded) idOffset := idLengthOffset + rendezvousLengthSize - // Persistent placement format: - // - // domain || namespace_length || namespace || - // key_length || key || shard_id_length || shard_id - // - // The candidate-independent prefix and key are written once. Only the shard - // ID suffix is overwritten while evaluating candidates. - scoreInput := make( - []byte, - idOffset+resolver.maxShardIDLength, - ) + scoreInput := make([]byte, idOffset+resolver.maxIDLength) copy(scoreInput, resolver.prefix) @@ -157,9 +145,7 @@ func (resolver *HashResolver[K]) Resolve(key K) (shard.Shard, error) { // Shard ID is the deterministic tie-breaker, so placement does not // depend on topology registration order when scores are equal. - if !hasBest || - comparison > 0 || - (comparison == 0 && candidateID < bestID) { + if !hasBest || comparison > 0 || (comparison == 0 && candidateID < bestID) { selected = candidate best = score bestID = candidateID diff --git a/shard/resolver/hash_test.go b/topology/shard/resolver/rendezvous_test.go similarity index 69% rename from shard/resolver/hash_test.go rename to topology/shard/resolver/rendezvous_test.go index f29c5c4..6f74dab 100644 --- a/shard/resolver/hash_test.go +++ b/topology/shard/resolver/rendezvous_test.go @@ -5,10 +5,10 @@ import ( "fmt" "testing" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) -func TestNewHashValidatesArguments(t *testing.T) { +func TestNewRendezvousValidatesArguments(t *testing.T) { t.Parallel() topology := newTestTopology(t, "shard-a") @@ -24,19 +24,19 @@ func TestNewHashValidatesArguments(t *testing.T) { name: "nil topology", namespace: "users", encoder: StringKeyEncoder(), - wantError: "xpg/shard/resolver: topology is nil or empty", + wantError: "xpg/topology/shard/resolver: topology is nil or empty", }, { name: "nil encoder", topology: topology, namespace: "users", - wantError: "xpg/shard/resolver: key encoder is nil", + wantError: "xpg/topology/shard/resolver: key encoder is nil", }, { name: "empty namespace", topology: topology, encoder: StringKeyEncoder(), - wantError: "xpg/shard/resolver: hash namespace must not be empty", + wantError: "xpg/topology/shard/resolver: rendezvous namespace must not be empty", }, } @@ -44,7 +44,7 @@ func TestNewHashValidatesArguments(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - _, err := NewHash( + _, err := NewRendezvous( test.topology, test.namespace, test.encoder, @@ -60,15 +60,15 @@ func TestNewHashValidatesArguments(t *testing.T) { } } -func TestNewHashTreatsNamespaceAsOpaqueNonEmptyString(t *testing.T) { +func TestNewRendezvousTreatsNamespaceAsOpaqueNonEmptyString(t *testing.T) { t.Parallel() topology := newTestTopology(t, "shard-a") for _, namespace := range []string{"users", " users ", " "} { - resolver, err := NewHash(topology, namespace, StringKeyEncoder()) + resolver, err := NewRendezvous(topology, namespace, StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash(%q) error = %v", namespace, err) + t.Fatalf("NewRendezvous(%q) error = %v", namespace, err) } resolved, err := resolver.Resolve("alice") @@ -82,13 +82,13 @@ func TestNewHashTreatsNamespaceAsOpaqueNonEmptyString(t *testing.T) { } } -func TestHashResolverStablePlacementVectors(t *testing.T) { +func TestRendezvousResolverStablePlacementVectors(t *testing.T) { t.Parallel() topology := newTestTopology(t, "shard-a", "shard-b", "shard-c") - resolver, err := NewHash(topology, "users", StringKeyEncoder()) + resolver, err := NewRendezvous(topology, "users", StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash() error = %v", err) + t.Fatalf("NewRendezvous() error = %v", err) } tests := []struct { @@ -126,20 +126,20 @@ func TestHashResolverStablePlacementVectors(t *testing.T) { } } -func TestHashResolverPlacementDoesNotDependOnTopologyOrder(t *testing.T) { +func TestRendezvousResolverPlacementDoesNotDependOnTopologyOrder(t *testing.T) { t.Parallel() first := newTestTopology(t, "shard-a", "shard-b", "shard-c") second := newTestTopology(t, "shard-c", "shard-a", "shard-b") - firstResolver, err := NewHash(first, "users", StringKeyEncoder()) + firstResolver, err := NewRendezvous(first, "users", StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash(first) error = %v", err) + t.Fatalf("NewRendezvous(first) error = %v", err) } - secondResolver, err := NewHash(second, "users", StringKeyEncoder()) + secondResolver, err := NewRendezvous(second, "users", StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash(second) error = %v", err) + t.Fatalf("NewRendezvous(second) error = %v", err) } for _, key := range []string{"alice", "bob", "carol", "dave", "eve", "user-123"} { @@ -164,20 +164,20 @@ func TestHashResolverPlacementDoesNotDependOnTopologyOrder(t *testing.T) { } } -func TestHashResolverAddingShardOnlyMovesKeysToNewShard(t *testing.T) { +func TestRendezvousResolverAddingShardOnlyMovesKeysToNewShard(t *testing.T) { t.Parallel() before := newTestTopology(t, "shard-a", "shard-b") after := newTestTopology(t, "shard-a", "shard-b", "shard-c") - beforeResolver, err := NewHash(before, "users", StringKeyEncoder()) + beforeResolver, err := NewRendezvous(before, "users", StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash(before) error = %v", err) + t.Fatalf("NewRendezvous(before) error = %v", err) } - afterResolver, err := NewHash(after, "users", StringKeyEncoder()) + afterResolver, err := NewRendezvous(after, "users", StringKeyEncoder()) if err != nil { - t.Fatalf("NewHash(after) error = %v", err) + t.Fatalf("NewRendezvous(after) error = %v", err) } moved := 0 @@ -216,13 +216,13 @@ func TestHashResolverAddingShardOnlyMovesKeysToNewShard(t *testing.T) { } } -func TestHashResolverWrapsEncoderError(t *testing.T) { +func TestRendezvousResolverWrapsEncoderError(t *testing.T) { t.Parallel() topology := newTestTopology(t, "shard-a") sentinel := errors.New("encode failed") - resolver, err := NewHash( + resolver, err := NewRendezvous( topology, "users", KeyEncoderFunc[string](func(string) ([]byte, error) { @@ -230,7 +230,7 @@ func TestHashResolverWrapsEncoderError(t *testing.T) { }), ) if err != nil { - t.Fatalf("NewHash() error = %v", err) + t.Fatalf("NewRendezvous() error = %v", err) } _, err = resolver.Resolve("alice") @@ -239,17 +239,17 @@ func TestHashResolverWrapsEncoderError(t *testing.T) { } } -func TestHashResolverUninitialized(t *testing.T) { +func TestRendezvousResolverUninitialized(t *testing.T) { t.Parallel() - var resolver *HashResolver[string] + var resolver *RendezvousResolver[string] _, err := resolver.Resolve("alice") if err == nil { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard/resolver: hash resolver is not initialized"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: rendezvous resolver is not initialized"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/resolver/time_range.go b/topology/shard/resolver/time_range.go similarity index 68% rename from shard/resolver/time_range.go rename to topology/shard/resolver/time_range.go index 84d329b..d8d6972 100644 --- a/shard/resolver/time_range.go +++ b/topology/shard/resolver/time_range.go @@ -7,7 +7,7 @@ import ( "sort" "time" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) // TimeRange maps the bounded half-open interval [Start, End) to one shard. @@ -20,47 +20,55 @@ type TimeRange struct { ShardID shard.ID } -// TimeRangeResolver routes time instants through non-overlapping ranges. +// TimeRangeResolver resolves time instants through bounded, non-overlapping +// ranges. type TimeRangeResolver struct { ranges []timeRangeEntry } -// NewTimeRange creates a resolver from non-overlapping half-open time ranges. +type timeRangeEntry struct { + start time.Time + end time.Time + shard shard.Shard + + sourceIndex int +} + +// NewTimeRange creates a resolver from bounded, non-overlapping half-open time +// ranges. // -// The supplied ranges may be unordered. NewTimeRange normalizes boundaries to -// UTC, sorts ranges by Start, validates their overlap, and leaves the caller's -// slice unchanged. +// Range boundaries are normalized to UTC. Every ShardID is resolved to its +// immutable Shard handle once. The caller's slice and time values are not +// modified. func NewTimeRange(topology *shard.Topology, ranges []TimeRange) (*TimeRangeResolver, error) { if err := requireTopology(topology); err != nil { return nil, err } if len(ranges) == 0 { - return nil, errors.New("xpg/shard/resolver: time range resolver requires at least one range") + return nil, errors.New("xpg/topology/shard/resolver: time range resolver requires at least one range") } entries := make([]timeRangeEntry, len(ranges)) for index, valueRange := range ranges { if err := requireShardID(valueRange.ShardID); err != nil { - return nil, fmt.Errorf("xpg/shard/resolver: time range %d: %w", index, err) + return nil, fmt.Errorf("xpg/topology/shard/resolver: time range %d: %w", index, err) } start := timeToUTC(valueRange.Start) end := timeToUTC(valueRange.End) if !start.Before(end) { - return nil, fmt.Errorf("xpg/shard/resolver: time range %d must satisfy start < end", index) + return nil, fmt.Errorf("xpg/topology/shard/resolver: time range %d must satisfy start < end", index) } resolved, ok := topology.Shard(valueRange.ShardID) if !ok { return nil, fmt.Errorf( - "xpg/shard/resolver: time range %d: %w", + "xpg/topology/shard/resolver: time range %d: %w", index, - &shard.UnknownShardError{ - ShardID: valueRange.ShardID, - }, + &shard.UnknownShardError{ShardID: valueRange.ShardID}, ) } @@ -90,7 +98,7 @@ func NewTimeRange(topology *shard.Topology, ranges []TimeRange) (*TimeRangeResol // [00:00, 01:00) and [01:00, 02:00) if previous.end.After(current.start) { return nil, fmt.Errorf( - "xpg/shard/resolver: time ranges %d and %d overlap", + "xpg/topology/shard/resolver: time ranges %d and %d overlap", previous.sourceIndex, current.sourceIndex, ) @@ -102,10 +110,13 @@ func NewTimeRange(topology *shard.Topology, ranges []TimeRange) (*TimeRangeResol }, nil } -// Resolve returns the shard whose configured time range contains key. +// Resolve returns the shard whose time range contains key. +// +// Resolve performs only an in-memory lookup. It does not consult topology, +// acquire a connection, or execute a PostgreSQL query. func (resolver *TimeRangeResolver) Resolve(key time.Time) (shard.Shard, error) { if resolver == nil || len(resolver.ranges) == 0 { - return shard.Shard{}, errors.New("xpg/shard/resolver: time range resolver is not initialized") + return shard.Shard{}, errors.New("xpg/topology/shard/resolver: time range resolver is not initialized") } key = timeToUTC(key) @@ -134,14 +145,6 @@ func (resolver *TimeRangeResolver) Resolve(key time.Time) (shard.Shard, error) { return entry.shard, nil } -type timeRangeEntry struct { - start time.Time - end time.Time - shard shard.Shard - - sourceIndex int -} - -func timeToUTC(value time.Time) time.Time { - return value.UTC() +func timeToUTC(t time.Time) time.Time { + return t.UTC() } diff --git a/shard/resolver/time_range_test.go b/topology/shard/resolver/time_range_test.go similarity index 87% rename from shard/resolver/time_range_test.go rename to topology/shard/resolver/time_range_test.go index 246c34d..13be909 100644 --- a/shard/resolver/time_range_test.go +++ b/topology/shard/resolver/time_range_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) func TestNewTimeRangeValidatesArguments(t *testing.T) { @@ -27,12 +27,12 @@ func TestNewTimeRangeValidatesArguments(t *testing.T) { ranges: []TimeRange{ {Start: start, End: end, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: topology is nil or empty", + wantError: "xpg/topology/shard/resolver: topology is nil or empty", }, { name: "empty ranges", topology: topology, - wantError: "xpg/shard/resolver: time range resolver requires at least one range", + wantError: "xpg/topology/shard/resolver: time range resolver requires at least one range", }, { name: "empty shard ID", @@ -40,7 +40,7 @@ func TestNewTimeRangeValidatesArguments(t *testing.T) { ranges: []TimeRange{ {Start: start, End: end}, }, - wantError: "xpg/shard/resolver: time range 0: shard ID must not be empty", + wantError: "xpg/topology/shard/resolver: time range 0: shard ID must not be empty", }, { name: "empty interval", @@ -48,7 +48,7 @@ func TestNewTimeRangeValidatesArguments(t *testing.T) { ranges: []TimeRange{ {Start: start, End: start, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: time range 0 must satisfy start < end", + wantError: "xpg/topology/shard/resolver: time range 0 must satisfy start < end", }, { name: "reversed interval", @@ -56,7 +56,7 @@ func TestNewTimeRangeValidatesArguments(t *testing.T) { ranges: []TimeRange{ {Start: end, End: start, ShardID: "shard-a"}, }, - wantError: "xpg/shard/resolver: time range 0 must satisfy start < end", + wantError: "xpg/topology/shard/resolver: time range 0 must satisfy start < end", }, { name: "unknown shard", @@ -64,7 +64,7 @@ func TestNewTimeRangeValidatesArguments(t *testing.T) { ranges: []TimeRange{ {Start: start, End: end, ShardID: "missing"}, }, - wantError: `xpg/shard/resolver: time range 0: xpg/shard: unknown shard "missing"`, + wantError: `xpg/topology/shard/resolver: time range 0: xpg/topology/shard: unknown shard "missing"`, }, } @@ -101,7 +101,7 @@ func TestNewTimeRangeRejectsOverlapUsingSourceIndexes(t *testing.T) { t.Fatal("expected overlap error") } - if got, want := err.Error(), "xpg/shard/resolver: time ranges 1 and 0 overlap"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: time ranges 1 and 0 overlap"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -231,7 +231,7 @@ func TestTimeRangeResolverUninitialized(t *testing.T) { t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard/resolver: time range resolver is not initialized"; got != want { + if got, want := err.Error(), "xpg/topology/shard/resolver: time range resolver is not initialized"; got != want { t.Fatalf("error = %q, want %q", got, want) } } diff --git a/shard/resolver/validation.go b/topology/shard/resolver/validation.go similarity index 71% rename from shard/resolver/validation.go rename to topology/shard/resolver/validation.go index c70966f..f81431c 100644 --- a/shard/resolver/validation.go +++ b/topology/shard/resolver/validation.go @@ -3,12 +3,12 @@ package resolver import ( "errors" - "github.com/mkbeh/xpg/shard" + "github.com/mkbeh/xpg/topology/shard" ) func requireTopology(topology *shard.Topology) error { if topology == nil || topology.Len() == 0 { - return errors.New("xpg/shard/resolver: topology is nil or empty") + return errors.New("xpg/topology/shard/resolver: topology is nil or empty") } return nil diff --git a/shard/shard.go b/topology/shard/shard.go similarity index 62% rename from shard/shard.go rename to topology/shard/shard.go index c3afb65..a0ce6bb 100644 --- a/shard/shard.go +++ b/topology/shard/shard.go @@ -5,21 +5,27 @@ import ( "github.com/jackc/pgx/v5" "github.com/mkbeh/xpg" - "github.com/mkbeh/xpg/cluster" + "github.com/mkbeh/xpg/topology/cluster" ) // ID identifies one logical shard. +// +// ID is an alias of cluster.ID because a shard inherits the stable identity of +// its underlying cluster. type ID = cluster.ID -// Shard is a borrowed handle to one cluster registered in a Topology. +// Shard is an immutable, restricted view of one Cluster registered in a +// Topology. // -// Shard exposes shard-local operations without exposing cluster lifecycle or -// replica-set management. +// Shard exposes shard-local data access without exposing cluster lifecycle or +// replica-set management. A Shard does not own the underlying Cluster and is +// valid only for the lifetime of its owning Topology; it must not be used after +// Topology.Close. type Shard struct { cluster *cluster.Cluster } -// ID returns the logical shard ID. +// ID returns the stable logical shard ID inherited from the underlying Cluster. func (s Shard) ID() ID { if s.cluster == nil { return "" @@ -46,10 +52,9 @@ func (s Shard) Labels() map[string]string { return s.cluster.Labels() } -// Primary returns the shard primary pool. -// -// Primary returns nil when no primary is configured. The returned pool is -// borrowed and must not be closed separately. +// Primary returns the shard primary pool, or nil when the underlying cluster +// has no primary configured. The returned pool is borrowed and must not be +// closed separately. func (s Shard) Primary() *xpg.Pool { if s.cluster == nil { return nil @@ -58,7 +63,7 @@ func (s Shard) Primary() *xpg.Pool { return s.cluster.Primary() } -// ReadPool returns a borrowed pool according to policy. +// ReadPool returns a borrowed pool for a read operation according to policy. func (s Shard) ReadPool( ctx context.Context, policy cluster.ReadPolicy, @@ -83,8 +88,7 @@ func (s Shard) InPrimaryTx( return s.cluster.InPrimaryTx(ctx, options, fn) } -// InReadTx executes fn in a read-only transaction on a pool selected according -// to policy within the shard. +// InReadTx executes fn in a read-only transaction resolved within this shard. func (s Shard) InReadTx( ctx context.Context, policy cluster.ReadPolicy, diff --git a/shard/shard_test.go b/topology/shard/shard_test.go similarity index 89% rename from shard/shard_test.go rename to topology/shard/shard_test.go index 400822b..6afe989 100644 --- a/shard/shard_test.go +++ b/topology/shard/shard_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/jackc/pgx/v5" - "github.com/mkbeh/xpg/cluster" + "github.com/mkbeh/xpg/topology/cluster" ) func TestShardZeroValue(t *testing.T) { @@ -50,12 +50,12 @@ func TestShardZeroValue(t *testing.T) { func TestShardDelegatesClusterMetadataAndRouting(t *testing.T) { t.Parallel() - shardCluster := newTestCluster(t, "shard-a", map[string]string{ + dbCluster := newTestCluster(t, "shard-a", map[string]string{ "region": "eu-west", "role": "", }) - topology, err := NewTopology([]Config{{Cluster: shardCluster}}) + topology, err := NewTopology(dbCluster) if err != nil { t.Fatalf("NewTopology() error = %v", err) } @@ -82,7 +82,7 @@ func TestShardDelegatesClusterMetadataAndRouting(t *testing.T) { t.Fatalf("Label(region) after mutation = %q, want eu-west", got) } - if resolved.Primary() != shardCluster.Primary() { + if resolved.Primary() != dbCluster.Primary() { t.Fatal("Primary() did not return cluster primary") } @@ -91,7 +91,7 @@ func TestShardDelegatesClusterMetadataAndRouting(t *testing.T) { t.Fatalf("ReadPool() error = %v", err) } - if pool != shardCluster.Primary() { + if pool != dbCluster.Primary() { t.Fatal("ReadPool() did not return cluster primary") } } diff --git a/topology/shard/topology.go b/topology/shard/topology.go new file mode 100644 index 0000000..c3ea2e5 --- /dev/null +++ b/topology/shard/topology.go @@ -0,0 +1,105 @@ +package shard + +import ( + "errors" + "fmt" + "slices" + "sync" + + "github.com/mkbeh/xpg/topology/cluster" +) + +// Topology is an immutable ordered set of logical shards. +// +// NewTopology takes ownership of all clusters only after it returns +// successfully. Close closes every owned cluster exactly once. +type Topology struct { + shards []Shard + shardsByID map[ID]Shard + + closeOnce sync.Once +} + +// NewTopology validates and creates an immutable topology. Shards retain the +// cluster registration order. Every cluster must have a unique, non-empty ID. +func NewTopology(clusters ...*cluster.Cluster) (*Topology, error) { + if len(clusters) == 0 { + return nil, errors.New("xpg/topology/shard: topology must contain at least one shard") + } + + shards := make([]Shard, len(clusters)) + shardsByID := make(map[ID]Shard, len(clusters)) + + for index, candidate := range clusters { + if candidate == nil { + return nil, fmt.Errorf("xpg/topology/shard: shard %d: cluster is nil", index) + } + + id := candidate.ID() + if id == "" { + return nil, fmt.Errorf("xpg/topology/shard: shard %d: cluster ID must not be empty", index) + } + + if _, exists := shardsByID[id]; exists { + return nil, fmt.Errorf("xpg/topology/shard: duplicate shard ID %q", id) + } + + current := Shard{cluster: candidate} + shards[index] = current + shardsByID[id] = current + } + + return &Topology{ + shards: shards, + shardsByID: shardsByID, + }, nil +} + +// Len returns the number of registered shards. +func (t *Topology) Len() int { + if t == nil { + return 0 + } + + return len(t.shards) +} + +// At returns the shard at index in registration order. At panics when index is +// outside the topology, matching ordinary slice indexing semantics. +func (t *Topology) At(index int) Shard { + return t.shards[index] +} + +// Shards returns a defensive copy of shards in registration order. +func (t *Topology) Shards() []Shard { + if t == nil { + return nil + } + + return slices.Clone(t.shards) +} + +// Shard returns one shard by stable cluster ID. +func (t *Topology) Shard(id ID) (Shard, bool) { + if t == nil { + return Shard{}, false + } + + resolved, ok := t.shardsByID[id] + + return resolved, ok +} + +// Close closes owned clusters in reverse registration order. Close is safe to +// call multiple times. +func (t *Topology) Close() { + if t == nil { + return + } + + t.closeOnce.Do(func() { + for _, current := range slices.Backward(t.shards) { + current.cluster.Close() + } + }) +} diff --git a/shard/topology_test.go b/topology/shard/topology_test.go similarity index 74% rename from shard/topology_test.go rename to topology/shard/topology_test.go index 81712e5..4933b4b 100644 --- a/shard/topology_test.go +++ b/topology/shard/topology_test.go @@ -1,19 +1,17 @@ package shard -import ( - "testing" -) +import "testing" func TestNewTopologyRequiresShard(t *testing.T) { t.Parallel() - topology, err := NewTopology(nil) + topology, err := NewTopology() if err == nil { topology.Close() t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard: topology must contain at least one shard"; got != want { + if got, want := err.Error(), "xpg/topology/shard: topology must contain at least one shard"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -21,28 +19,13 @@ func TestNewTopologyRequiresShard(t *testing.T) { func TestNewTopologyRejectsNilCluster(t *testing.T) { t.Parallel() - topology, err := NewTopology([]Config{{}}) - if err == nil { - topology.Close() - t.Fatal("expected error") - } - - if got, want := err.Error(), "xpg/shard: shard 0: cluster is nil"; got != want { - t.Fatalf("error = %q, want %q", got, want) - } -} - -func TestNewTopologyRejectsEmptyClusterID(t *testing.T) { - t.Parallel() - - shardCluster := newTestCluster(t, "", nil) - topology, err := NewTopology([]Config{{Cluster: shardCluster}}) + topology, err := NewTopology(nil) if err == nil { topology.Close() t.Fatal("expected error") } - if got, want := err.Error(), "xpg/shard: shard 0: cluster ID must not be empty"; got != want { + if got, want := err.Error(), "xpg/topology/shard: shard 0: cluster is nil"; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -52,17 +35,14 @@ func TestNewTopologyRejectsDuplicateIDs(t *testing.T) { first := newTestCluster(t, "shard-a", nil) second := newTestCluster(t, "shard-a", nil) - topology, err := NewTopology([]Config{ - {Cluster: first}, - {Cluster: second}, - }) + + topology, err := NewTopology(first, second) if err == nil { topology.Close() t.Fatal("expected error") } - if got, want := err.Error(), - `xpg/shard: duplicate shard ID "shard-a" at indexes 0 and 1`; got != want { + if got, want := err.Error(), `xpg/topology/shard: duplicate shard ID "shard-a"`; got != want { t.Fatalf("error = %q, want %q", got, want) } } @@ -77,7 +57,6 @@ func TestTopologyPreservesRegistrationOrder(t *testing.T) { } want := []ID{"shard-b", "shard-a", "shard-c"} - for index, wantID := range want { if got := topology.At(index).ID(); got != wantID { t.Fatalf("At(%d).ID() = %q, want %q", index, got, wantID) @@ -89,7 +68,7 @@ func TestTopologyPreservesRegistrationOrder(t *testing.T) { } if got := resolved.ID(); got != wantID { - t.Fatalf("Shard(%q).ID() = %q", wantID, got) + t.Fatalf("Shard(%q).ID() = %q, want %q", wantID, got, wantID) } } } @@ -116,6 +95,10 @@ func TestTopologyShardUnknownID(t *testing.T) { if ok { t.Fatalf("Shard() = %+v, true; want false", resolved) } + + if got := resolved.ID(); got != "" { + t.Fatalf("Shard().ID() = %q, want empty", got) + } } func TestTopologyAtPanicsOutOfRange(t *testing.T) {