Documentation
¶
Index ¶
- Constants
- func EstimateRowSize(values []any) int
- func StatusRow(a Applier) string
- func ValidateKeyRanges(ranges []string) error
- type Applier
- type ApplierConfig
- type ApplyCallback
- type LogicalRow
- type ShardedApplier
- func (a *ShardedApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error
- func (a *ShardedApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, ...) (int64, error)
- func (a *ShardedApplier) GetTargets() []Target
- func (a *ShardedApplier) Start(ctx context.Context) error
- func (a *ShardedApplier) Stats() Stats
- func (a *ShardedApplier) Stop() error
- func (a *ShardedApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, ...) (int64, error)
- func (a *ShardedApplier) Wait(ctx context.Context) error
- type SingleTargetApplier
- func (a *SingleTargetApplier) ActiveWriteWorkers() int
- func (a *SingleTargetApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error
- func (a *SingleTargetApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, ...) (int64, error)
- func (a *SingleTargetApplier) GetTargets() []Target
- func (a *SingleTargetApplier) SetWriteWorkers(n int)
- func (a *SingleTargetApplier) Start(ctx context.Context) error
- func (a *SingleTargetApplier) Stats() Stats
- func (a *SingleTargetApplier) Stop() error
- func (a *SingleTargetApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, ...) (int64, error)
- func (a *SingleTargetApplier) Wait(ctx context.Context) error
- type Stats
- type Target
Constants ¶
const ( // MaxStatementSizeBytes is the byte budget for the estimated rendered // size of a single multi-row DML statement (1 MiB). Both write paths // batch against it: the copy path splits chunks into chunklets // (splitRowsIntoChunklets) and the binlog-apply path cuts flush // batches (pkg/change) so a REPLACE/DELETE can't grow unbounded with // wide rows. Still far below the typical 64 MiB max_allowed_packet // because the size estimates are rough — estimateValueSize is // deliberately biased low (hex encoding, string escaping and wide // integers all under-measure; see its doc) and leans on that ~64x // headroom — and a single row larger than the budget still goes in // its own statement. // // One bound to respect when changing this: chunklets must keep the write // pool fed, so chunks-in-flight x chunklets-per-chunk has to stay above the // write worker count. At a 16 MiB target chunk, ~22 chunks in flight and a // write ceiling of 188 that means >= ~8.5 chunklets per chunk, i.e. the // budget can only roughly double before the largest pools start starving. // (When chunkletMaxRows binds instead, chunklets-per-chunk is set by the // row count and this bound is much looser.) MaxStatementSizeBytes = 1024 * 1024 )
Variables ¶
This section is empty.
Functions ¶
func EstimateRowSize ¶ added in v0.17.0
EstimateRowSize estimates the size in bytes of a row's values as they will be rendered into a VALUES clause. It does not need to be precise: the budget it feeds (MaxStatementSizeBytes, 1 MiB) sits ~64x below a typical max_allowed_packet, so the estimate only has to be the right order of magnitude to keep a statement well clear of the wire limit.
It is exported for callers that batch rows before handing them to Apply and so need to bound a batch by the same measure the applier itself uses — the checksum's chunk repair does this.
It does need to be cheap. It runs on every value of every copied row, once per row on top of the rendering writeChunklet does anyway, so it is pure overhead on the hottest client-side path. The previous implementation measured len(fmt.Sprintf("%v", value)), which was neither cheap nor accurate: a text-protocol Scan into *any hands back []byte for essentially every column, and %v renders a []byte as "[49 50 51 …]" — roughly four characters per byte. That cost ~2.2us and ~12 allocations per row and over-estimated by ~2.7x, so chunklets were being cut well short of the budget they were supposed to fill. A type switch is ~290x cheaper, allocates nothing, and lands much closer to what datum.String() actually emits.
func StatusRow ¶ added in v0.17.0
StatusRow renders a's Stats() as the applier row of a runner status block. Runner Status() can be called before the applier is constructed, so this must be nil-safe; the empty string it returns then makes the block drop the row.
func ValidateKeyRanges ¶ added in v0.16.0
ValidateKeyRanges parses each Vitess-style key range and checks that no two overlap — the same rules NewShardedApplier enforces at construction. It exists so callers can fail fast on a bad shard layout before doing any work (e.g. move validates reverse-window source key ranges before the copy, since the sharded reverse applier is only constructed after the forward cutover).
Types ¶
type Applier ¶
type Applier interface {
// Start initializes the applier and starts its workers
Start(ctx context.Context) error
// Apply sends rows to be written to the target(s).
// The chunk parameter provides metadata about the source table and target table.
// The rows parameter contains the actual row data to be written.
// The callback is invoked when all rows are safely flushed.
//
// For the copier: callback will call chunker.Feedback()
// For the subscription: callback will update binlog coordinates
Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error
// Stats returns a point-in-time snapshot of the write pipeline: queue
// occupancy, pending work, live workers, mean rows per chunklet, and
// rolling percentiles of the four per-chunklet phases (queue-wait, build,
// write, handoff). Safe to call concurrently with Apply; values are
// approximate. See the Stats type for field semantics.
Stats() Stats
// DeleteKeys deletes rows by their key values synchronously. Each entry
// in keys is one key tuple of the original (typed) column values, in
// sourceTable.KeyColumns order.
// An empty locks slice means no under-lock flush: the delete runs on the
// regular write connection(s). When locks is non-empty, the delete is
// executed under the supplied table lock(s):
// - Single-target implementations accept zero or one lock. Zero means no
// under-lock flush; more than one lock is a caller bug and is an error.
// - Multi-target (sharded) implementations expect one lock per target,
// each acquired on that target's own connection, and execute each
// target's statements under that target's lock (matched by connection
// identity). A missing lock for any shard is an error.
// Returns the number of rows affected and any error.
DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)
// UpsertRows performs an upsert (REPLACE INTO ... VALUES) synchronously.
// The rows are LogicalRow structs containing the row images.
// An empty locks slice means no under-lock flush; when locks is non-empty
// the upsert is executed under the supplied table lock(s). See DeleteKeys
// for the per-implementation lock contract (single-target accepts zero or
// one lock; sharded expects one lock per shard).
// Returns the number of rows affected and any error.
UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)
// Wait blocks until all pending work is complete and all callbacks have been invoked
Wait(ctx context.Context) error
// Stops the applier workers
Stop() error
// GetTargets returns target information for direct database access.
// This is used by operations like checksum that need to query targets directly.
// For SingleTargetApplier, this returns a single target.
// For ShardedApplier, this returns all shards.
GetTargets() []Target
}
Applier is an interface for applying rows to one or more target databases. Implementations can apply to a single target (SingleTargetApplier) or fan out to multiple targets based on a hash function (ShardedApplier).
The Applier is responsible for: - Batching/splitting rows into optimal write sizes - Tracking pending writes - Invoking callbacks when writes are complete
func NewSingleTargetForTest ¶ added in v0.13.0
NewSingleTargetForTest builds a SingleTargetApplier suitable for use as the repl client's applier. The repl client requires a non-nil applier — every memory-comparable PK routes through bufferedMap, which calls Applier.UpsertRows / DeleteKeys. See issue #746.
type ApplierConfig ¶
type ApplierConfig struct {
Threads int // number of write threads
ChunkletMaxRows int
ChunkletMaxSize int
Logger *slog.Logger
DBConfig *dbconn.DBConfig
// MetricsSink, when non-nil, makes the applier periodically report its
// Stats() snapshot as gauges (see pkg/metrics applier_* names). Nil
// disables emission entirely — no goroutine is started.
MetricsSink metrics.Sink
}
func NewApplierDefaultConfig ¶
func NewApplierDefaultConfig() *ApplierConfig
NewApplierDefaultConfig returns a default config for the applier.
func (*ApplierConfig) Validate ¶
func (cfg *ApplierConfig) Validate() error
Validate checks the ApplierConfig for required fields.
type ApplyCallback ¶
ApplyCallback is invoked when rows have been safely flushed to the target(s). affectedRows is the total number of rows affected across all targets. err is non-nil if there was an error applying the rows.
type LogicalRow ¶
LogicalRow represents the current state of a row in the subscription buffer. This could be that it is deleted, or that it has RowImage that describes it. If there is a RowImage, then it needs to be converted into the RowImage of the newTable.
type ShardedApplier ¶
ShardedApplier applies rows to multiple target databases based on a Vitess-style vindex. It extracts a specific column value from each row, applies a hash function to it, and routes the row to the appropriate shard based on the hash value and key ranges.
The sharding column and hash function are configured per-table in the TableInfo.ShardingColumn and TableInfo.HashFunc fields. This allows different tables to use different sharding keys in multi-table migrations.
func NewShardedApplier ¶
func NewShardedApplier(targets []Target, cfg *ApplierConfig) (*ShardedApplier, error)
NewShardedApplier creates a new ShardedApplier with multiple target databases.
The sharding column and hash function are configured per-table in the TableInfo.ShardingColumn and TableInfo.HashFunc fields. This allows different tables to use different sharding keys in multi-table migrations.
func (*ShardedApplier) Apply ¶
func (a *ShardedApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error
Apply sends rows to be written to the appropriate target shards. Rows are distributed across shards based on the sharding column and hash function configured in the chunk's Table.ShardingColumn and Table.HashFunc.
func (*ShardedApplier) DeleteKeys ¶
func (a *ShardedApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)
DeleteKeys deletes rows by their key values synchronously, broadcasting to all shards. Each entry in keys is one primary-key tuple of the original (typed) column values, in sourceTable.KeyColumns order. If locks is non-empty, each shard's delete is executed under the table lock that was acquired on that shard's own connection (one lock per shard, matched via resolveShardLocks).
Note: we only track modifications by PRIMARY KEY, not by shard key (aka primary vindex). For this reason we can't extract the vindex value, and must instead broadcast the deletes to all shards. The vindex value is considered immutable, and we will error if it changes on an update.
Note: the sharded applier supports renaming the table (targetTable, nil => same name as sourceTable) but no column transformations. The rename exists for the reverse feed of a sharded-source move, which writes back to the source's retired `_old` tables.
func (*ShardedApplier) GetTargets ¶
func (a *ShardedApplier) GetTargets() []Target
GetTargets returns the target database configurations for direct access. This is used by operations like checksum that need to query targets directly.
func (*ShardedApplier) Start ¶
func (a *ShardedApplier) Start(ctx context.Context) error
Start initializes all shard workers and begins processing. This method is idempotent and can restart the applier after Stop() is called.
Lifecycle: callers MUST call Stop() to terminate the per-shard write workers and the single feedbackCoordinator. Cancelling the ctx passed here does NOT by itself shut down the goroutine pipeline — it only aborts in-flight writes. Workers for a shard exit when its chunkletBuffer is closed (by Stop), and the coordinator exits when every shard's chunkletCompletions has been closed (by the last worker's defer per shard). Failing to call Stop() will leak goroutines.
func (*ShardedApplier) Stats ¶ added in v0.16.0
func (a *ShardedApplier) Stats() Stats
Stats returns a point-in-time snapshot of the write pipeline, aggregated across shards: queue depth/cap are summed, and active workers is the sum of each shard's live (started minus finished) workers. The embedded mutex is held so the buffer reads cannot race Start()'s channel reinitialization on restart; len/cap on a closed channel are safe.
func (*ShardedApplier) Stop ¶
func (a *ShardedApplier) Stop() error
Stop signals the applier to shut down gracefully
func (*ShardedApplier) UpsertRows ¶
func (a *ShardedApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)
UpsertRows performs upserts synchronously, distributing across shards. The rows are LogicalRow structs containing inline row images from the binlog. Each shard issues `REPLACE INTO target (cols) VALUES (...)`; the REPLACE semantics — and their eventual-consistency implications for callers — are documented on SingleTargetApplier.UpsertRows. The short version: REPLACE may delete rows whose PKs are not in the `rows` argument (via the unique-key conflict resolution) and those rows are re-inserted by their own events in subsequent batches.
If locks is non-empty, each shard's upsert is executed under the table lock that was acquired on that shard's own connection (one lock per shard, matched via resolveShardLocks).
Note: we only track modifications by PRIMARY KEY, not be shard key (aka primary vindex). For this reason we could get in trouble if there was a PK update that mutated the vindex column. This is because we would only see the last operation (modification) and not know to DELETE from one of the shards.
The way we address this, is we consider the vindex column immutable. The replication client is told that it should error if there are any updates to it, and the entire operation is canceled. The enforcement lives in pkg/change: the subscription resolves the sharding column to an ordinal (Subscription.ImmutableColumnOrdinal) and both processRowsEvent implementations fail fatally when an UPDATE's before/after images differ at that position (see change.checkImmutableColumn).
This is likely not too big of a limitation, as Vitess itself recommends that vindex columns be immutable. If it turns out to be a problem, we can revisit tracking by other columns later.
Note: the sharded applier supports renaming the table (mapping's target, which defaults to the source when no NewTable is set) but no column transformations. The rename exists for the reverse feed of a sharded-source move, which writes back to the source's retired `_old` tables. The sharding column and hash always come from the mapping's SOURCE table — the watched table whose row images we are routing.
func (*ShardedApplier) Wait ¶
func (a *ShardedApplier) Wait(ctx context.Context) error
Wait blocks until all pending work is complete and all callbacks have been invoked. Checking callbacksInFlight in addition to len(pendingWork) is what upholds the "all callbacks have been invoked" half of the contract: claimed work has already left the map, but its callback may still be running (#765).
type SingleTargetApplier ¶
SingleTargetApplier applies rows to a single target database. It internally splits rows into chunklets for optimal batching and tracks completion to invoke callbacks when all chunklets for a set of rows are done.
func NewSingleTargetApplier ¶
func NewSingleTargetApplier(target Target, cfg *ApplierConfig) (*SingleTargetApplier, error)
NewSingleTargetApplier creates a new SingleTargetApplier
func (*SingleTargetApplier) ActiveWriteWorkers ¶ added in v0.15.0
func (a *SingleTargetApplier) ActiveWriteWorkers() int
ActiveWriteWorkers returns the current number of live write workers.
func (*SingleTargetApplier) Apply ¶
func (a *SingleTargetApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error
Apply sends rows to be written to the target database
func (*SingleTargetApplier) DeleteKeys ¶
func (a *SingleTargetApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)
Each entry in keys is one primary-key tuple of the original (typed) column values, in sourceTable.KeyColumns order. If locks contains a lock, the delete is executed under the table lock.
func (*SingleTargetApplier) GetTargets ¶
func (a *SingleTargetApplier) GetTargets() []Target
GetTargets returns the target database configuration for direct access. This is used by operations like checksum that need to query targets directly.
func (*SingleTargetApplier) SetWriteWorkers ¶ added in v0.15.0
func (a *SingleTargetApplier) SetWriteWorkers(n int)
SetWriteWorkers reconciles the live write-worker count to n, spawning new workers or parking existing ones as needed. It is idempotent and safe to call repeatedly from the autoscaler. n is clamped to a minimum of 1 so the applier always makes some progress. Calls after Stop() begins are no-ops.
Parking is cooperative: closing a worker's quit channel makes it exit the next time it returns to its select (after finishing any chunklet currently in flight), so no completion is ever lost.
func (*SingleTargetApplier) Start ¶
func (a *SingleTargetApplier) Start(ctx context.Context) error
Start initializes the applier's async write workers and begins processing. This does not control the synchronous methods like UpsertRows/DeleteKeys. This method is idempotent - calling it multiple times is safe.
Lifecycle: callers MUST call Stop() to terminate the write workers and feedbackCoordinator. Cancelling the ctx passed here does NOT by itself shut down the goroutine pipeline — it only aborts in-flight writes. Workers exit when chunkletBuffer is closed (by Stop) or when their quit channel is closed (by SetWriteWorkers scaling down), and the coordinator exits when chunkletCompletions is closed (by Stop, after all workers exit). Failing to call Stop() will leak goroutines.
func (*SingleTargetApplier) Stats ¶ added in v0.16.0
func (a *SingleTargetApplier) Stats() Stats
Stats returns a point-in-time snapshot of the write pipeline. The embedded mutex is held so the buffer read cannot race Start()'s channel reinitialization on restart; len/cap on a closed channel are safe.
func (*SingleTargetApplier) Stop ¶
func (a *SingleTargetApplier) Stop() error
Stop signals the applier to shut down gracefully This does not control the synchronous methods like UpsertRows/DeleteKeys, which can continue after Stop() is called. This method is idempotent - calling it multiple times is safe.
func (*SingleTargetApplier) UpsertRows ¶
func (a *SingleTargetApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)
UpsertRows performs an upsert (REPLACE INTO ... VALUES) synchronously. The rows are LogicalRow structs containing inline row images from the binlog. If locks contains a lock, the upsert is executed under the table lock.
REPLACE semantics, and why we use them:
MySQL's `REPLACE INTO target (cols) VALUES (...)` treats each value tuple as an INSERT, except that for any row in `target` that conflicts with the new row on PRIMARY KEY *or any UNIQUE index*, the old row is deleted before the new row is inserted. Per the docs, conflicts on multiple unique indexes can lead to multiple deletions for a single new row.
Two implications matter for callers reading this code:
A single REPLACE may delete rows whose PKs are *not* in the `rows` argument. If row B's image collides on a unique key with some other row A currently in the destination (because A was the previous holder of that unique value), REPLACE deletes A while inserting B. A is then transiently missing from the destination until its own event arrives in a later flush (or a later batch in the same flush) and re-inserts it. This is what restores the order-independence the pre-#821 deltaMap had with `REPLACE INTO ... SELECT`. See block/spirit#847.
Eventual consistency. Between the moment REPLACE deletes A and the moment A's image is re-applied, the destination is not a valid snapshot of source — it has fewer rows. Spirit relies on the bufferedMap being an *up-to-date and disjoint* representation of pending changes (each PK appears at most once, holding the latest row image) so that every transiently-deleted row will be re-inserted as flushes progress. The destination converges back to source's current state once the last unflushed event for each affected PK has been applied. The post-cutover checksum (with `FixDifferences=true`) is the backstop that catches any divergence that survives.
We supply inline row images rather than `REPLACE INTO ... SELECT FROM source`, so the read-after-commit race that motivated #746 does not apply.
func (*SingleTargetApplier) Wait ¶
func (a *SingleTargetApplier) Wait(ctx context.Context) error
Wait blocks until all pending work is complete and all callbacks have been invoked. Checking callbacksInFlight in addition to len(pendingWork) is what upholds the "all callbacks have been invoked" half of the contract: claimed work has already left the map, but its callback may still be running (#765).
type Stats ¶ added in v0.16.0
type Stats struct {
// QueueDepth is the number of chunklets currently waiting in the
// buffer(s) — summed across shards for the sharded applier.
QueueDepth int
// QueueCap is the total buffer capacity (summed across shards).
QueueCap int
// PendingWork is the number of chunks accepted by Apply() whose
// callback has not fired yet (queued + in-flight).
PendingWork int
// ActiveWorkers is the number of live write workers.
ActiveWorkers int
// RowsPerChunklet is the mean rows per chunklet since the applier started.
// splitRowsIntoChunklets cuts on whichever of chunkletMaxRows or
// MaxStatementSizeBytes binds first, and which one that is depends on the
// table's width and column types — so it cannot be predicted from the
// config, only measured. It matters because the chunklet is the unit of
// nearly everything on the write path: one statement, one completion, one
// handoff. A value at chunkletMaxRows means the row cap binds and raising
// MaxStatementSizeBytes would do nothing.
//
// A value below the row cap does NOT by itself mean the byte cap binds:
// the mean also drops when the batches handed to Apply() are small — each
// chunk's remainder is a short chunklet, and on the sharded applier a
// chunk is split per shard *after* fan-out, so the same chunk yields more,
// shorter chunklets. The caps-question reading is only sound on the copy
// path's large steady-state batches, where the remainder is noise; there,
// well below the row cap means the byte cap is what's cutting.
//
// A mean rather than a percentile: the question is which cap is in force,
// which the mean answers, and the last chunklet of every chunk is a short
// remainder that would skew a low percentile.
RowsPerChunklet float64
// Rolling percentiles over the last timingRingSize chunklets. Zero when no
// chunklet has completed yet. Together these account for a write worker's
// whole cycle, which matters when the pipeline stops responding to more
// workers: each phase is limited by something different, and only one of
// them is the target's write capacity.
//
// QueueWait is the time a chunklet spent between Apply() offering it to
// the buffer and a write worker dequeueing it (including send-side
// backpressure when the buffer is full).
QueueWaitP50 time.Duration
QueueWaitP90 time.Duration
// BuildTime is the client-side cost of turning the chunklet's rows into an
// INSERT statement — a datum conversion and a string format per value, so
// it scales with rows × columns and is spent on spirit's own CPU while
// holding no connection. It is a *component of* WriteTime, not additional
// to it; subtract it to get time actually spent at the server. A BuildTime
// approaching WriteTime means the client is the bottleneck, which no
// server-side signal (CPU, commit latency, Threads_running) can report and
// which more write workers cannot fix.
BuildTimeP50 time.Duration
BuildTimeP90 time.Duration
// WriteTime is the time spent turning the chunklet into a statement and
// executing it against the target(s) — BuildTime plus the round trip,
// including any retry backoff inside it.
WriteTimeP50 time.Duration
WriteTimeP90 time.Duration
// Handoff is the time a write worker spent publishing its completion after
// the write finished. A single feedbackCoordinator goroutine drains those
// completions and invokes the chunk callback inline, so this is where a
// slow callback shows up as backpressure on every write worker at once.
// Non-trivial Handoff with the queue pegged means workers are blocked
// behind the completion path rather than the target, and adding workers
// will not help.
HandoffP50 time.Duration
HandoffP90 time.Duration
}
Stats is a point-in-time snapshot of an applier's write pipeline. It exists so status blocks and metrics can distinguish a read-limited pipeline (queue near empty) from a write-limited one (queue pegged at capacity with queue-wait far above write time) — without this, write-side saturation is invisible: the copier's end-to-end chunk feedback misattributes it to the read side. All fields are approximate; they are read without pausing the pipeline.
func (Stats) String ¶ added in v0.16.0
String renders the snapshot as the applier row of a runner's status block, so migrate, move and sync report identical fields. Durations are rounded to the millisecond — finer precision is noise at status cadence. The fields are not prefixed with "applier-": the row is labelled, which is the whole point of the block layout.
It renders a deliberately small subset of Stats, because the status block is read every 30 seconds by a human and a field that reads the same on every healthy run costs attention without paying it back (#329). Five fields are always present — queue occupancy, worker count, queue wait, and the write p50/p90 — because those are what you steer by.
Two more appear only when they have something to say: build time when it is a large enough share of write time to mean the client is the bottleneck (and large enough in absolute terms to be worth reading), and handoff when it rises off the floor. Both diagnose a pipeline that has stopped responding to more write workers (see github.com/block/spirit/issues/1097), and both are silent on a healthy run — so their *presence* is the signal, and their absence is not a gap.
Nothing is lost by trimming: every field stays on Stats, and the metrics sink emits them all, which is what dashboards should read anyway.
type Target ¶
type Target struct {
DB *sql.DB
Config *mysql.Config
KeyRange string // Vitess-style key range: "-80", "80-", "80-c0", or "0" for unsharded
}
Target represents a shard target with its database connection, configuration, and key range. Key ranges are expressed as Vitess-style strings (e.g., "-80", "80-", "80-c0"). An empty string or "0" means all key space (unsharded).