Skip to content

Commit c19f904

Browse files
thiagohoraclaude
andcommitted
[OPIK-7686] [DOCS] Document where the async-insert cutover bump lives
Records the decision from the #7675 review thread and makes the runbook actionable: the cutover bump to async_insert_busy_timeout_max_ms is a temporary ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS entry on the environment overlay, not a new Helm chart value. New "Where the buffer bump lives" section states the decision with its rationale, the exact overlay location for the Comet SaaS cutover (values-production-opik.yaml in comet-helm, under opik.component.backend.env), the target value, and the revert step -- delete the key rather than set 250, since unset means "leave queryParameters alone" and restores the baked-in default in one edit. It also corrects the record: the reason first given on #7675 for keeping these out of the chart (empty values would not be inert) was wrong, since config.yml already ships the empty default everywhere. The exclusion stands on reversibility and scope instead. Two operational facts the runbook did not state, verified against the production deployment: - The backend consumes the ConfigMap through envFrom.configMapRef, which is injected at container start only, so the edit alone does not reach a running pod. The chart annotates the pods reloader.stakater.com/auto and Stakater Reloader runs on the cluster, so the roll is automatic -- but the operator must wait for it before continuing, since the ceiling has to be live on every instance. - The revert is delivered by a second rolling restart while pods hold inserts parked for up to the widened ceiling, so the chosen ceiling must stay below terminationGracePeriodSeconds (the Kubernetes default 30s here -- neither chart nor overlay sets it). Because traceColumnsNonNullable rides the same overlay and ConfigMap, and "The final cutover window" step 1 asks for both, they should land in one commit so the fleet restarts once. Also adds the explicit warning against bumping the value by editing ANALYTICS_DB_QUERY_PARAMETERS, a Go/No-Go item for having the bump and its revert pre-written, and cross-references from prereq 6, sequence step 2, the manual-actions note and the final-cutover-window step. No chart change: the chart-value option was not chosen, so the three ANALYTICS_DB_ASYNC_INSERT_* knobs stay out of values.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 234e82a commit c19f904

1 file changed

Lines changed: 107 additions & 11 deletions

File tree

  • apps/opik-backend/data-migrations/traces-local-v2-cutover

apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,17 @@ new table before the EXCHANGE. The replay matches the **full key**, not `id` alo
7373
6. **Cutover buffer knob ready**`databaseAnalytics.asyncInsertBusyTimeoutMaxMs` (env
7474
`ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS`), unset by default so the buffer inherits the
7575
`async_insert_busy_timeout_max_ms=250` carried by `queryParameters`. Raise it to ~10000 for the cutover, then unset it
76-
again. The ceiling is a backend per-query setting applied on the backend's own ClickHouse client, so the migration
77-
scripts' direct `clickhouse-client` session **cannot read or verify it**. It is therefore **operator-asserted**:
78-
`exchange_and_wrap.sh` refuses the EXCHANGE without `--confirm-buffer-raised` (a fail-fast acknowledgment gate — it
79-
forces the operator to confirm the step, though it cannot prove the value took effect). Confirm it actually took
80-
effect on the prod-clone/staging load test (the Go/No-Go "Async-insert ceiling confirmed" item) before production.
81-
**Also confirm client/SDK insert timeouts
82-
exceed the widened buffer** (~10s) — with `wait_for_async_insert=1` a raised ceiling blocks each insert until it
83-
flushes, so a shorter client timeout would surface as ingestion errors during the window.
76+
again. **Where it is set, the exact value, the rollout and the revert step are in
77+
["Where the buffer bump lives"](#where-the-buffer-bump-lives-and-how-to-revert-it)** — it is a temporary env var on the
78+
environment overlay, not a chart value (OPIK-7686). Have the overlay change written and reviewed *before* the window,
79+
so applying it is a merge, not an edit. The ceiling is a backend per-query setting applied on the backend's own
80+
ClickHouse client, so the migration scripts' direct `clickhouse-client` session **cannot read or verify it**. It is
81+
therefore **operator-asserted**: `exchange_and_wrap.sh` refuses the EXCHANGE without `--confirm-buffer-raised` (a
82+
fail-fast acknowledgment gate — it forces the operator to confirm the step, though it cannot prove the value took
83+
effect). Confirm it actually took effect on the prod-clone/staging load test (the Go/No-Go "Async-insert ceiling
84+
confirmed" item) before production. **Also confirm client/SDK insert timeouts exceed the widened buffer** (~10s) —
85+
with `wait_for_async_insert=1` a raised ceiling blocks each insert until it flushes, so a shorter client timeout would
86+
surface as ingestion errors during the window.
8487
7. **Schema-state flag wired, with a rollout plan**`databaseAnalyticsDataModel.traceColumnsNonNullable` (env
8588
`ANALYTICS_DB_DATA_MODEL_TRACE_COLUMNS_NON_NULLABLE`, default `false`). The successor's `end_time`/`ttft` are
8689
**non-nullable sentinel** columns, so the app must represent an absent value as the epoch/NaN sentinel — not `null`
@@ -158,7 +161,9 @@ new table before the EXCHANGE. The replay matches the **full key**, not `id` alo
158161
It executes the reference statement in
159162
[`000001_backfill_traces_local_v2.sql`](scripts/db-app-analytics/000001_backfill_traces_local_v2.sql) — the script
160163
reads that file and substitutes the window bounds, so the two never drift.
161-
2. **Raise the buffer ceiling** (config, see below), then **[`scripts/delta_replay.sh`](scripts/delta_replay.sh)**
164+
2. **Raise the buffer ceiling** (config — see
165+
["Where the buffer bump lives"](#where-the-buffer-bump-lives-and-how-to-revert-it) for the overlay entry, the value and
166+
the rollout wait), then **[`scripts/delta_replay.sh`](scripts/delta_replay.sh)**
162167
(reference SQL [`000002_delta_and_deletion_replay.sql`](scripts/db-app-analytics/000002_delta_and_deletion_replay.sql))
163168
— delta-insert (anchored at `backfill_start`), then **deletion replay**. The replay runs with
164169
`lightweight_deletes_sync = 2`, so it returns only once the delete mutation has applied on **every** replica.
@@ -263,6 +268,88 @@ new table before the EXCHANGE. The replay matches the **full key**, not `id` alo
263268
delta one). This is normal — `ReplacingMergeTree` collapses them on merge / under `FINAL` / `LIMIT 1 BY id`, highest
264269
`last_updated_at` winning. Do not "fix" it.
265270

271+
### Where the buffer bump lives (and how to revert it)
272+
273+
**Decision (OPIK-7686): a temporary env var on the deployed chart's environment overlay — not a chart value.** The three
274+
`ANALYTICS_DB_ASYNC_INSERT_*` knobs are deliberately absent from the Opik chart's `values.yaml` (OPIK-6880, #7675).
275+
Rationale:
276+
277+
- **No chart change is needed.** `component.backend.env` is a free-form map rendered straight into the backend
278+
ConfigMap, so an overlay entry is already sufficient.
279+
- **Removal is a clean one-step rollback.** Unset means "leave `queryParameters` alone" (`DatabaseAnalyticsFactory`), so
280+
*deleting* the key restores the baked-in `async_insert_busy_timeout_max_ms=250`. There is no "set it back to 250" edit,
281+
and therefore no pinned value that can later drift from the `config.yml` default. For a time-boxed window that
282+
reversibility is the property worth optimising for.
283+
- **The value is environment- and window-specific** — prod, during the cutover. An overlay keeps it version-controlled
284+
and auditable without turning a temporary state into a permanent chart default that every self-hosted install
285+
inherits.
286+
- **A chart value would save no work**: you edit the overlay either way.
287+
288+
> For the record, the reason first given on #7675 for excluding these — that rendering them would send empty strings
289+
> where the backend expects an integer, so it "would not be inert" — was **wrong**. `config.yml` ships
290+
> `${ANALYTICS_DB_ASYNC_INSERT_*:-}` as the default for all three, so the empty case is the normal path in every
291+
> environment today: an empty substitution leaves a bare YAML scalar that parses to a null `Integer`, and `@Min(1)` does
292+
> not fire on null. Exposing them in the chart with empty defaults *would* be safe. The decision above rests on
293+
> reversibility and scope, not on safety.
294+
295+
> **Never bump it by editing `ANALYTICS_DB_QUERY_PARAMETERS`.** That means re-pasting the entire tuning string
296+
> (`compress`, `failover`, `async_insert`, `wait_for_async_insert`, the skip-index and shard settings, …), which risks
297+
> silently dropping one of the others and drifting from the `config.yml` default. The dedicated
298+
> `ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS` override exists precisely so the cutover states only the one value it
299+
> is changing.
300+
301+
**Comet SaaS (the production cutover).** The overlay is `values-production-opik.yaml` in `comet-ml/comet-helm`, under
302+
`opik.component.backend.env` (the `opik` chart is a subchart there, so the values are namespaced under `opik:`):
303+
304+
```yaml
305+
opik:
306+
component:
307+
backend:
308+
env:
309+
# OPIK-6875 traces cutover ONLY — delete this line to revert (do not set 250).
310+
ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS: "10000"
311+
```
312+
313+
Only the **ceiling** changes. Leave `…_MIN_MS` and `…_MAX_DATA_SIZE` unset: the floor stays at the
314+
`async_insert_busy_timeout_min_ms=100` carried by `queryParameters`, and widening the ceiling alone is what parks the
315+
inserts.
316+
317+
**The rollout is a pod restart, and it happens by itself.** The backend reads the ConfigMap through
318+
`envFrom.configMapRef: opik-backend`, which Kubernetes injects at container start only — editing the ConfigMap does not
319+
reach a running pod. The chart annotates the pods `reloader.stakater.com/auto: "true"` and Stakater Reloader runs on the
320+
cluster, so merging the overlay change triggers the rolling restart on its own; there is no `kubectl rollout restart` to
321+
issue. What the operator **must** do is wait for that roll to complete before continuing, because the ceiling has to be
322+
live on *every* instance:
323+
324+
```bash
325+
kubectl rollout status deployment/opik-backend -n cometml-production
326+
kubectl get cm opik-backend -n cometml-production \
327+
-o jsonpath='{.data.ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS}{"\n"}'
328+
# and confirm no surviving pod predates the roll:
329+
kubectl get pods -n cometml-production -l component=opik-backend \
330+
-o custom-columns=NAME:.metadata.name,START:.status.startTime
331+
```
332+
333+
Four consequences to plan for:
334+
335+
- **One roll, not two, before the tail.** `traceColumnsNonNullable = true` (prereq 7) lives in the same overlay, the
336+
same ConfigMap and the same Reloader roll — and step 1 of "The final cutover window" asks for both. Land them in
337+
**one commit** so the fleet restarts once.
338+
- **Keep the chosen ceiling below the pod's termination grace period.** The revert is delivered by a second rolling
339+
restart, and at that moment pods are holding inserts parked for up to the widened ceiling. Neither the chart nor the
340+
overlay sets `terminationGracePeriodSeconds`, so it is the Kubernetes default **30s** — comfortably above the ~10000ms
341+
ceiling, but a much larger ceiling would let `SIGKILL` land on parked inserts. Check the two numbers against each other
342+
before picking a value.
343+
- **The roll itself costs ingestion capacity** (rolling-update `maxUnavailable` plus the backend PDB), so do it while
344+
there is slack — not between the final delta and the EXCHANGE.
345+
- **Reloader restarts the fleet on *any* change to that ConfigMap.** Hold unrelated overlay edits for the whole window,
346+
the same way prereq 12 freezes DDL.
347+
348+
**Self-hosted / on-premise.** Same env var and the same "delete to revert" semantics; only the delivery differs — a
349+
`component.backend.env` entry in whatever values file the install uses, or `ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS`
350+
in the compose `.env` (`docker-compose.yaml` already forwards all three). General tuning guidance for these knobs lives
351+
in the self-host [ClickHouse async-insert notes](../../../opik-documentation/documentation/fern/docs/self-host/troubleshooting.mdx).
352+
266353
### The final cutover window (the zero-loss invariant)
267354
268355
The buffer widening (prereq 6) is what makes the flip lossless, but the guarantee rests on a timing invariant worth
@@ -273,7 +360,9 @@ still land in the *old* `traces` in the gap between the last delta read and the
273360
run. The binding constraint is therefore **not** "replay < buffer window"; it is that the **gap between the final delta
274361
and the `EXCHANGE` completing must stay within the buffer hold**. So run the tail as tightly as possible:
275362
276-
1. Widen the buffer, and **roll out `traceColumnsNonNullable = true` to every backend instance** (see below).
363+
1. Widen the buffer, and **roll out `traceColumnsNonNullable = true` to every backend instance** (see below). Both are
364+
entries in the same overlay and the same backend ConfigMap, so land them in **one commit** and let the single restart
365+
carry both — see ["Where the buffer bump lives"](#where-the-buffer-bump-lives-and-how-to-revert-it).
277366
2. Do the QA verify on an **earlier** pass (it can take minutes on a large table — do not let it be the last thing
278367
before the swap).
279368
3. Run a **final** `delta_replay.sh` as the last write-facing step.
@@ -728,7 +817,8 @@ server's major version, either way:
728817
`CLICKHOUSE_HOST`; for a ClickHouse on the host's own loopback, add `--network=host` via `CLICKHOUSE_CLIENT_DOCKER_OPTS`.
729818

730819
**The only manual actions are not SQL:** (1) raising/restoring the async-insert buffer ceiling
731-
(`databaseAnalytics.asyncInsertBusyTimeoutMaxMs`) around steps 2–3; (2) flipping
820+
(`databaseAnalytics.asyncInsertBusyTimeoutMaxMs`) around steps 2–3 — see
821+
["Where the buffer bump lives"](#where-the-buffer-bump-lives-and-how-to-revert-it); (2) flipping
732822
`databaseAnalyticsDataModel.traceColumnsNonNullable` to `true` in lockstep with the EXCHANGE (and back on rollback) —
733823
see "The final cutover window"; and (3) the go/no-go judgement between steps. All three are *backend config* / judgement changes (env + rolling
734824
restart, or a config push) that these DB-facing scripts cannot and should not make. They are deliberately operator-owned;
@@ -1079,6 +1169,12 @@ cheap (stage A); the bridge stays enabled so nothing is lost on a retry.
10791169
- [ ] **Async-insert ceiling confirmed** — raising `asyncInsertBusyTimeoutMaxMs` demonstrably widens the adaptive buffer
10801170
under load, not just the cap. `exchange_and_wrap.sh` enforces the acknowledgment via `--confirm-buffer-raised`, but
10811171
that is an assertion only — this checklist item is the actual "it took effect under load" verification.
1172+
- [ ] **The buffer-bump and revert changes are pre-written and reviewed** — the overlay entry raising
1173+
`ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS` (with `traceColumnsNonNullable = true` in the same commit) and the
1174+
revert that *deletes* the key, both prepared before the window so each is a merge rather than an edit under
1175+
pressure. Confirm the chosen ceiling is below `terminationGracePeriodSeconds` and that the Reloader-driven roll is
1176+
expected to complete inside the schedule — see
1177+
["Where the buffer bump lives"](#where-the-buffer-bump-lives-and-how-to-revert-it).
10821178
- [ ] **Data Retention confirmed disabled** for the cutover window (`RETENTION_ENABLED=false`). Retention deletes bypass
10831179
the deletion bridge, so a sweep in the window would leak/resurrect across the swap; `exchange_and_wrap.sh` and
10841180
`rollback.sh` (stages B/C) enforce `--confirm-retention-paused`, but that is an assertion — this item is the real

0 commit comments

Comments
 (0)