dicer-go · Project Newsletter · May 2026
May 1, 2026v0.1.0 · 2026.05
github.com/nilbot/godicer
Contents
Executive Summary
dicer-go is a complete Go rewrite of Databricks' Dicer v1.0.0 — a production-proven auto-sharding control plane that replaces static hash-mod sharding with dynamic slice assignment. The MVP ships ~5,056 lines across 56 Go files, fully implements the five-phase assignment algorithm, and is evaluator-ready today.
Key Takeaways
- The five-phase sharding algorithm that drives a 10× database load reduction in Databricks' Unity Catalog is fully ported and running in Go.
- HTTP+JSON wire protocol and YAML config eliminate the protoc dependency that made the upstream Scala service expensive to operate in Go shops.
- Lock-free Clerk lookups via
atomic.Pointer[Assignment]and binary search mean key-to-pod routing never contends with assignment updates.
- What does Dicer do, and what does it cost to run the upstream Scala version in a Go-native stack?
- What were the three most consequential design decisions in the Go rewrite, and why were they made?
- What is production-ready today, and what is explicitly deferred to the next milestone?
What Is Dicer, and Why Rewrite It
Static sharding breaks under load. Dicer was built at Databricks to solve that — and the Go rewrite makes it operationally viable for teams that never wanted a JVM in their Kubernetes cluster.
The Problem with Static Hash-Mod Sharding
In a hash-mod scheme, a key routes to pod = hash(key) % N. This is simple and fast, but it has three failure modes that compound at scale. First, hot-key skew: a small number of keys can generate a disproportionate fraction of traffic, and there is no mechanism to move them to a less-loaded pod. Second, rolling deploys: when N changes, nearly every key remaps, causing a cache miss storm. Third, pod failures: a dead pod takes its entire slice of the keyspace with it, and the recovery path requires either a cold restart or manual re-seeding.
Databricks built Dicer to replace hash-mod with a dynamic slice assignment model. The keyspace is divided into contiguous half-open ranges called slices — each covering a segment of [Empty, Inf) — and pods register for slices by sending heartbeats to a central Assigner. The Assigner runs a load-balancing algorithm every two seconds and publishes a new assignment when the current distribution is outside tolerance. Pods receive assignments via a server-streaming watch endpoint and begin routing accordingly within milliseconds.
Why Rewrite Rather Than Wrap
The upstream Dicer is written in Scala and speaks gRPC with protobuf. Running it in a Go-native infrastructure stack requires a JVM process, a protoc code-generation step for every client, and a gRPC stub in every service that wants to act as a Slicelet. For shops already running Go everywhere, that operational surface is larger than the problem it solves.
The Go rewrite targets the same algorithmic semantics as Dicer v1.0.0 — released 2026-01-13 — while changing three things: the runtime (JVM → Go), the wire protocol (gRPC/protobuf → HTTP/JSON), and the configuration format (textproto → YAML). The routing semantics, the five-phase algorithm structure, and the imbalance tolerance model are preserved exactly.
Architecture & Design Decisions
Three roles, three binaries, one HTTP mux. The design minimizes moving parts so a single-replica Assigner on a Kubernetes cluster is the only infrastructure addition a team needs.
The Three-Role Model
Five Design Decisions Worth Explaining
1. HTTP+JSON over gRPC/protobuf
The upstream wire protocol is gRPC with protobuf — sensible for Scala where grpc-java is a first-class dependency. In Go, adding gRPC means protoc code generation, a google.golang.org/grpc import tree, and a separate listener port. Go 1.22's enhanced net/http mux handles GET /v1/dicer/{target}/watch with path parameters natively. The HTTP+JSON approach eliminates all of that: the Assigner and any NDJSON-capable HTTP client can speak the wire protocol today, including curl.
2. YAML configuration over textproto
Upstream config is textproto, which requires a .proto schema and protoc to parse. YAML has no compile step, ships with gopkg.in/yaml.v3 (the only non-stdlib dependency in the control plane), and is already the dominant config format on Kubernetes. The schema maps directly to the upstream TargetConfigP structure, including scope-based overrides driven by the LOCATION environment variable.
3. Immutable assignments and atomic.Pointer
An Assignment is constructed once, validated on creation, and never mutated. Clerks hold an atomic.Pointer[Assignment] and swap it when a new assignment arrives from the watch stream. Key lookup is a binary search over a sorted slice of entries — no mutex, no channel, no allocation on the hot path. The target for SquidForKey is under 200 ns at 10,000 slices on a modern CPU.
4. In-memory Prometheus without client_golang
The metrics package implements a thin counter/gauge interface backed by a sync/atomic integer store, with a text-format exporter on /metrics. This avoids pulling in prometheus/client_golang — a substantial import tree — while keeping the endpoint compatible with any Prometheus scraper. Teams who want the full registry can swap the backend for promhttp.HandlerFor() in one function call.
5. FNV-1a + SplitMix64 for key hashing
The upstream uses FarmHash, which requires a C extension in Go. dicer-go uses FNV-1a (stdlib) fed through a SplitMix64 avalanche step, producing uniform distribution without a CGO dependency. The resulting hash space is byte-order compatible with Go clients but not with the upstream Scala Clerks — an acceptable trade-off for new deployments. Teams needing bytewise compatibility can substitute FarmHash64 in pkg/dicer/fingerprint.go without touching the algorithm.
The Five-Phase Algorithm
The Assigner runs one generator goroutine per target. Every two seconds it samples pod health and load, runs five deterministic phases, and either commits a new assignment to the store or skips the tick if nothing changed.
Phase Walkthrough
Phase 1 — Constraint
Each pod declares a replica target: how many slices it wants to serve simultaneously. The constraint phase clamps replica counts to [MinReplicas, MaxReplicas] per the target config. If a pod currently holds more replicas than its maximum, the excess are scheduled for removal from the most-loaded pods first. If fewer replicas exist than the configured minimum, the coldest available pods receive new ones.
Phase 2 — Deallocate
Pods that have not sent a heartbeat within the TTL window are considered unhealthy. All their slice assignments are orphaned and flagged for redistribution in Phase 4. This is the primary recovery path for pod failures during rolling deploys.
Phase 3 — Merge
Adjacent slice pairs whose combined load falls below the merge threshold are coalesced into a single slice. The merge phase runs until the total replica count across all pods is at or below NumResources × MaxAvgSliceReplicas. This prevents the keyspace from fragmenting into thousands of micro-slices during sustained low-traffic periods.
Phase 4 — Place
Orphaned slices from Phase 2 are assigned to healthy pods with available capacity. Placement prefers the pod that previously held the slice — reducing cache miss storms during recovery — and breaks ties by choosing the least-loaded pod. This affinity logic mirrors the upstream "minimal churn" property.
Phase 5 — Split
Slices whose measured load exceeds max_load_hint are split recursively until each half-open range [Low, Mid) and [Mid, High) is within tolerance. The splitter hooks for hot-key isolation (routing a single hot key to its own slice) are present in the code but deferred to a later milestone, matching the upstream's own deferred status for this feature.
max_load_hint), TIGHT (~2.5%), and LOOSE (~40%) — let teams tune how aggressively the Assigner responds to transient load spikes. Tight is suited to SLO-critical caches; Loose is suited to batch pipelines where churn cost exceeds imbalance cost.
Concurrency Model
The generator for each target runs in a single goroutine, ticking on a time.Ticker. This mirrors the upstream SequentialExecutionContext from Scala and ensures the algorithm sees a consistent snapshot of pod health and load within a single tick. The store uses optimistic concurrency control: a write succeeds only if the predecessor generation matches what is currently stored. Watch subscribers receive only the latest assignment — a one-item buffer with overwrite semantics prevents slow consumers from accumulating stale events.
MVP Status & Deferred Scope
The v0.1.0 tag represents a complete, evaluable implementation of Dicer's core control loop. Six features are explicitly deferred — all with a clear rationale and a bounded scope for follow-up.
What's Shipped
| Component | Status | Notes |
|---|---|---|
Core library API (Assignment, Slice, Clerk, Slicelet) |
Done | Full interface, validated on construction |
| Assigner control plane (HTTP, watch, heartbeat, load) | Done | Runs on :24500; metrics on :7777 |
| Five-phase assignment algorithm | Done | Structure correct; tuning constants are best-effort port |
| In-memory store with OCC and watch fan-out | Done | Coalescing subscriber buffers, immutable snapshots |
| Clerk: lock-free lookup + watch consumer | Done | atomic.Pointer, O(log n) binary search |
| Slicelet: heartbeat + async load accumulation | Done | Buffered channel; never blocks request handlers |
| YAML configuration with scope-based overrides | Done | Maps to upstream TargetConfigP schema |
| Prometheus metrics (9 counters/gauges) | Done | Text-format exporter; no client_golang dependency |
| Demo CLI triple (assigner, server, client) | Done | Three-terminal quick-start works end-to-end |
| Helm chart (single-replica Assigner) | Done | Deployment + Service + ConfigMap |
| CI (Go 1.22 & 1.23, gofmt, vet, race test) | Done | GitHub Actions matrix |
| Etcd backing store (HA multi-replica Assigner) | Stub | Returns ErrUnsupported; leader election deferred |
Deferred Features and Why
The following are deliberately out of scope for v0.1.0. Each is bounded and has a clear follow-up path.
- Algorithm constant transcription. The five-phase structure is correct, but the imbalance thresholds and reservation fractions are a best-effort mechanical port of ~603 lines of
Algorithm.scala. A line-by-line transcription is the highest-priority follow-up before any production deployment. - Etcd backing store and HA. Multi-replica Assigner requires leader election via
go.etcd.io/etcd/client/v3/concurrency.Election. The stub is in place; the implementation is scoped to a dedicated milestone. - Hot-key isolation. The splitter has hooks for routing a single hot key to its own slice. This mirrors the upstream's own deferred status for this feature.
- State transfer. Upstream-deferred; not included here.
- TLS/mTLS. Flag surface matches upstream; certificate handling is deferred.
- FarmHash compatibility. Teams needing bytewise key-space compatibility with upstream Scala Clerks can substitute FarmHash64 in
pkg/dicer/fingerprint.go.
Getting Started
The three-terminal quick-start below runs a fully functional auto-sharding demo in under two minutes. No external dependencies beyond Go 1.22+ and a POSIX shell.
Build
git clone https://github.com/nilbot/godicer
cd godicer
make build # produces bin/dicer-assigner, bin/dicer-demo-server, bin/dicer-demo-client
Terminal 1 — Start the Assigner
mkdir -p /tmp/dicer/target_config
cat > /tmp/dicer/target_config/demo.yaml <<EOF
owner_team_name: caching
default_config:
primary_rate_metric_config:
max_load_hint: 30000
imbalance_tolerance_hint: DEFAULT
EOF
./bin/dicer-assigner \
--target-config-dir=/tmp/dicer/target_config \
--rpc-port=24500 \
--info-port=7777 \
--location=kubernetes-cluster:dev/local/public/local/dev/01
Terminal 2 — Start a Slicelet-backed Demo Server
POD_IP=127.0.0.1 POD_UID=$(uuidgen) \
LOCATION=kubernetes-cluster:dev/local/public/local/dev/01 \
./bin/dicer-demo-server \
--target=demo \
--assigner=localhost:24500 \
--rpc-port=24510
Terminal 3 — Send Load via a Clerk-backed Demo Client
./bin/dicer-demo-client \
--target=demo \
--slicelet=localhost:24510 \
--assigner=localhost:24500
The client routes keys through the Clerk, which watches the Slicelet for the current assignment and falls back to the Assigner directly. After a few ticks, the Assigner will emit rebalanced assignments visible in the Terminal 1 log.
Kubernetes Deployment
helm install dicer-assigner deploy/helm/dicer-assigner/ \
--set image.tag=v0.1.0 \
--set-string targetConfigs.myservice="owner_team_name: my-team\ndefault_config:\n primary_rate_metric_config:\n max_load_hint: 60000\n imbalance_tolerance_hint: DEFAULT"
Observability
The Assigner exposes Prometheus metrics on :7777/metrics and a health endpoint on :7777/healthz. The nine metrics cover generation count, slice churn (keys added, removed, moved), active target count, and Clerk lookup hit/miss rates. Wire the /metrics endpoint into any standard Prometheus scrape config.
- Run the three-terminal demo and observe live rebalancing by killing and restarting
dicer-demo-server. - Review
internal/algorithm/constants.goagainstAlgorithm.scalafor the algorithm constant transcription milestone. - Open an issue or PR on GitHub if you find a divergence from upstream sharding semantics.
References
- Databricks Dicer v1.0.0 —
github.com/databricks/dicer(released 2026-01-13) - dicer-go repository —
github.com/nilbot/godicer - Go 1.22 enhanced HTTP mux —
pkg.go.dev/net/http - gopkg.in/yaml.v3 — YAML configuration parsing