Skip to content

feat(api): add an optional stateless odbingest node group - #14

Open
tdakkota wants to merge 2 commits into
mainfrom
claude/role-node-groups
Open

feat(api): add an optional stateless odbingest node group#14
tdakkota wants to merge 2 commits into
mainfrom
claude/role-node-groups

Conversation

@tdakkota

Copy link
Copy Markdown
Contributor

Closes #11 (partially — the query half is deferred, see below).

Adds a role dimension to OtelDBCluster so ingest capacity scales independently of storage, by
deploying a pool of stateless odbingest write nodes.

Design

spec.ingest is a new optional stanza (*IngestSpec) alongside the existing spec.replicas,
rather than a restructure into three symmetric stanzas à la VMCluster.

VictoriaMetrics needs three symmetric stanzas because it has no all-in-one binary — there is no
"just replicas" mode for it to stay compatible with. oteldb has one, it is the default, and it is
what every existing OtelDBCluster means. Making the stanza a pointer keeps "absent" and "present
at defaults" distinguishable: omit it and nothing new is deployed, ingest: {} opts in.

spec.storage was also already taken, and means the durable tier — a spec.storage.replicaCount
next to spec.storage.backend would read as a contradiction, and renaming it is a breaking
v1alpha1 change bought for nothing.

role binary workload replicas
storage oteldb StatefulSet + per-pod PVC spec.replicas (unchanged)
ingest odbingest Deployment spec.ingest.replicas (default 2)

Deployment for ingest: the pods hold nothing, are not ring members, and need no stable identity, no
PVC, and no ordered rollout.

The divergence from VM that carries the design

vminsert must be handed an explicit -storageNode FQDN list, so the VM chart re-templates it from
the storage replica count and rolls both stateless tiers on every storage scale. oteldb does not
work that way — verified against cmd/odbingest/app.go on oteldb main, where router.Open takes
{Etcd, Root, RF, ShardsPerTenant, DialTimeout} and no node list at all. etcd holds membership, the
ring is rendezvous-hashed, and owners are computed locally from the watched member list.

Scaling spec.replicas therefore requires no change to the ingest pool — no config re-render,
no rollout, no restart. The two replica counts are genuinely independent.

Why this belongs in the CRD

What the pool does need is the ring's shape, and ClusterConfig's own doc comment on the oteldb
side is blunt about getting it wrong:

A mismatched ShardsPerTenant or RF does not fail — it resolves a different owner set than the
nodes do, and writes land where no read will look for them.

A silent, non-erroring, data-losing misconfiguration split across two workloads is exactly what an
operator should make unrepresentable. Both roles now render etcd / rf / shards_per_tenant /
root from one ringConfig(cr, endpoints) helper, spec.ingest.extraConfig reserves the whole
cluster block, and TestIngestRingMatchesStorageRing asserts the two renderers agree key for key.

Backward compatibility

  • spec.replicas keeps its meaning and its default. An existing CR with just replicas: 3 produces
    byte-identical output and keeps meaning symmetric all-in-one nodes. Symmetric stays the default.
  • The storage StatefulSet's selector is left byte-identical. It is immutable after creation, so
    changing it would wedge every existing cluster on upgrade. The two roles are told apart by
    app.kubernetes.io/name alone (oteldb vs odbingest); TestIngestAndStorageSelectorsAreDisjoint
    pins that neither selector matches the other role's pods.
  • The existing <name> client Service is not touched — same selector, same ports, still
    fronting the storage nodes, which still serve every API. A Service cannot select two pod sets, and
    silently repointing an in-use ingest endpoint at a new workload during an operator upgrade is a
    data-path change nobody asked for. Senders opt in by pointing at <name>-ingest.
  • Dropping spec.ingest again prunes the Deployment/Service/ConfigMap; owner refs alone would only
    collect them when the whole cluster goes away.

Services and ports

<name>-ingest publishes 4317 OTLP/gRPC, 4318 OTLP/HTTP, 19291 remote write (when metrics are
enabled) and 8090 self-metrics.

One wrinkle: odbingest serves OTLP/HTTP, remote write and its health endpoints on a single
listener — otlp.Register(mux) and mux.Handle(rw.Path, handler) share one mux bound to
prometheus_remote_write.bind — with only OTLP/gRPC on its own port. There is no :4318 and no
separate :13133. So the Service publishes 4318, the port stock OTLP/HTTP exporters target, and
remaps it onto the ingest-http container port, and the probes hit /healthz and /readyz there.

/readyz already answers 503 until the ring has a member, so it is wired straight to the readiness
probe and a starting pod stays out of the load balancer until the cluster is reachable.

HPA: deliberately not modelled

Both stateless roles are plain Deployments with stable names and labels, so a user's own HPA or
KEDA ScaledObject can target apps/v1 Deployment <name>-ingest today with no operator support.

Building it into the CRD first requires settling the operator-versus-HPA fight over
.spec.replicas: the operator must learn to omit replicas once an HPA owns the scale subresource,
or the two flap against each other every reconcile. Worth doing, cleanly separable, not this PR.

Not in this PR

spec.query is deferred

odbselect's config exists only on the unmerged oteldb/oteldb#1266 branch, and a shared-config-block
extraction is in flight upstream that may still move it. A CRD field is effectively permanent once
shipped, so adding spec.query now means either guessing a schema that drifts or shipping a field
the operator cannot honour. The role machinery here is generic, so adding the second stanza once
#1266 lands is a small diff.

cmd/odbingest's config is on oteldb main and stable, which is what this is grounded in.

Blocking: odbingest is not in any released image

Found while grounding this — odbingest is not built by oteldb's .goreleaser.yaml and not
copied by release.Dockerfile, which installs only oteldb, odbbackup, odbrestore and
odbmigrate. No published ghcr.io/oteldb/oteldb tag contains the binary, and nothing in oteldb or
oteldb/charts references it.

The operator side is correct regardless — the container runs /usr/local/bin/odbingest out of the
cluster image and spec.ingest.image overrides it — but the pool cannot actually start until oteldb
ships the binary. Called out in the README, the IngestSpec godoc and the sample CR so it is not
discovered at rollout time. The upstream fix is a builds: entry plus one COPY line.

Verification

make lint (0 issues), make test (envtest, controller package at 84.4%) and go build ./... all
pass. Generated manifests and deepcopy are in a separate commit.

Symmetric oteldb nodes weld ingest capacity to storage capacity: absorbing a write
spike means adding a node, which then takes ownership of a share of the shards.

spec.ingest deploys a pool of stateless odbingest write nodes as a Deployment,
fronted by a <name>-ingest Service. It is opt-in: an existing OtelDBCluster with
just replicas: N is untouched and keeps meaning symmetric all-in-one nodes.

odbingest is not handed a storage node list. It watches etcd for membership and
resolves shard owners from the ring locally, so scaling spec.replicas needs no
re-render and no restart of the pool. It does need the ring's shape, and a
mismatch there fails silently — a different owner set means writes land where no
read looks — so both roles render rf/shards_per_tenant/root from one spec.cluster
via ringConfig, and spec.ingest.extraConfig reserves the whole cluster block.

The storage StatefulSet's selector is left byte-identical (it is immutable after
creation); the roles are told apart by app.kubernetes.io/name alone.

Refs #11.
@tdakkota
tdakkota marked this pull request as ready for review August 17, 2026 21:30
tdakkota added a commit to oteldb/e2e that referenced this pull request Aug 17, 2026
Deploy an odbingest pool (spec.ingest) and an odbselect pool (spec.query) alongside the storage
nodes, then write through the ingest pool's Service and read back through the query pool's for all
three signals. That crossing is the point of the split and the one thing a symmetric suite cannot
check: with one process doing both, a write-then-read proves nothing about the boundary. Also
asserts the pools stay out of the storage ring and that losing a pod from each costs only capacity.

Sibling repos can now be pinned to a git ref (OTELDB_REF / OPERATOR_REF), cloned into a cache
instead of the local tree, so this runs against the unmerged branches now and main later. odbingest
and odbselect go through the same fast build path as oteldb.

Three fixes the pools exposed, all pre-existing: the fast image installed binaries only at /<bin>,
but the pools' Deployments exec the release path /usr/local/bin/<bin>; WaitRollout raced the
operator creating the workload, since `kubectl rollout status` fails NotFound rather than waiting;
and `make deploy` leaves a reused cluster running the previous manager, because the image tag never
changes. Gated behind E2E_ROLE_POOLS until oteldb/operator#14, #15 and oteldb/oteldb#1266 land.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Role node groups: scale ingest and query independently of storage

1 participant