diff --git a/apps/opik-backend/config.yml b/apps/opik-backend/config.yml index bba61f5d02b..7d78715dd5f 100644 --- a/apps/opik-backend/config.yml +++ b/apps/opik-backend/config.yml @@ -155,6 +155,20 @@ databaseAnalyticsDataModel: # MODIFY TTL target `traces_local` only (the Distributed `traces` rejects them); ADD/DROP/MODIFY COLUMN must target # both `traces_local` and `traces`, else reads can't see the column (code 47). tracesDistributedWrapEnabled: ${ANALYTICS_DB_DATA_MODEL_TRACES_DISTRIBUTED_WRAP_ENABLED:-false} + # Default: false + # Description: Enables partition-aware PRUNING of trace deletes - it does NOT create or activate any partitioning. + # With it on, a trace DELETE bounds itself to the weekly partitions its own ids resolve to instead of being planned + # against every part of the table. Turning it on therefore ASSERTS a schema fact rather than causing one: that the + # live mutation target already IS the weekly partitioned successor, id_at as DateTime64(0,'UTC') under + # PARTITION BY toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1))). Installing that schema is the + # EXCHANGE step of the cutover, never this flag. Purely an optimisation: false keeps the unbounded mutation, which + # is always correct and merely slower. A third flag on purpose - the partitioning appears at the EXCHANGE, and + # neither sibling marks it: + # traceColumnsNonNullable must be rolled out BEFORE the EXCHANGE, tracesDistributedWrapEnabled flips at the wrap, + # which may be deferred long after it. Leave false at deploy time; set true once the EXCHANGE is confirmed, and back + # to false BEFORE a rollback promotes the original `traces` (legacy `traces` has no PARTITION BY and a 32-bit + # DateTime id_at that overflows past 2106, so the predicate would silently match zero rows for a far-future id). + tracesWeeklyPartitionPruningEnabled: ${ANALYTICS_DB_DATA_MODEL_TRACES_WEEKLY_PARTITION_PRUNING_ENABLED:-false} # Description: UUIDv7 ingestion validation. Rejects writes whose `id` embeds a timestamp outside the # window, protecting data quality. diff --git a/apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md b/apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md index bb732b08d2c..83f434ba578 100644 --- a/apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md +++ b/apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md @@ -322,6 +322,30 @@ the `traceColumnsNonNullable` flip"). On rollback, after swapping the Nullable original back, revert the flag to `false` **and** run that repair. +**The `tracesWeeklyPartitionPruningEnabled` flip (optional, and why it goes last).** `databaseAnalyticsDataModel.tracesWeeklyPartitionPruningEnabled` +(env `ANALYTICS_DB_DATA_MODEL_TRACES_WEEKLY_PARTITION_PRUNING_ENABLED`, default `false`) lets a trace `DELETE` bind itself to +the weekly partitions its own ids resolve to (OPIK-6901), instead of being planned against every part of the table — on +prod-test, 12 ids rewrote 3,928 parts / 5.40 TiB without it. It asserts a **schema** fact: that `traces` (or +`traces_local`) is the successor, with `id_at` as `DateTime64(0,'UTC')` under the weekly `PARTITION BY`. + +> **It enables the *pruning*, not the partitioning — the name is deliberate.** Setting it does not create, activate or +> migrate anything; the partitioned schema arrives with the `EXCHANGE` above and nowhere else. So it is never a step that +> *makes* the cutover progress, and setting it early does not bring the partitioning forward — it only starts emitting a +> predicate against whatever table is live, which is the failure below. + +It is a third flag precisely because **neither of the two above marks the `EXCHANGE`**, which is when the partitioning +appears: `traceColumnsNonNullable` must lead it (above), and `tracesDistributedWrapEnabled` flips at the wrap, which may +be deferred long after it (`--skip-wrap` … `--wrap-only`). So gate on this one, not on either of those. + +Unlike its siblings it is **safe to lag and unsafe to lead**: `false` is the previous unbounded delete, always correct +and merely slower, so turn it on at leisure **after** the `EXCHANGE` is confirmed. Turning it on early — while `traces` is +still the original — is the failure mode worth avoiding: the original has **no `PARTITION BY` at all** (nothing to prune) +and declares `id_at` as a 32-bit `DateTime` that overflows past 2106, so a far-future id (the litellm ~2201 rows) is +stored under a wrapped recent timestamp that the derived partition cannot match, and the delete reports success having +matched **zero rows**. For the same reason, a stage B/C **rollback must revert it to `false` — and roll-restart every +instance — before promoting the original**, ahead of the swap rather than after it. Like its siblings it comes from a +startup snapshot of `OpikConfiguration`, so the config change alone changes nothing until each instance restarts. + ## Batching and throttling On a large production table a single week can be enormous, so the backfill does **not** run one INSERT per week. Two @@ -832,6 +856,11 @@ statements, so a failure *between* them needs a restart path: again, so the flip has to be undone in two steps — `rollback.sh` prints both when the stage finishes. The rollback is not complete until they land. +> **If `tracesWeeklyPartitionPruningEnabled` was turned on, revert it *before* the stage runs, not after.** It asserts the +> live table is the partitioned successor, and the restored original is not one, so a stale `true` makes trace deletes +> match zero rows while reporting success — see "The `tracesWeeklyPartitionPruningEnabled` flip". It is the one flag whose +> revert has to lead the swap; the two steps below follow it. + 1. **Revert `traceColumnsNonNullable` to `false` AND roll-restart every backend instance.** The flag is read from a **startup snapshot** of `OpikConfiguration` (bound via `toInstance`), so a config change does **not** take effect until each instance restarts — exactly like the forward rollout before the EXCHANGE. Until the restart completes, the app diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java index ceceab54498..0b86a21739e 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java @@ -32,6 +32,7 @@ import com.comet.opik.utils.ErrorUtils; import com.comet.opik.utils.JsonUtils; import com.comet.opik.utils.TruncationUtils; +import com.comet.opik.utils.WeeklyPartitions; import com.comet.opik.utils.template.TemplateUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -1921,8 +1922,21 @@ ORDER BY (workspace_id, project_id, trace_id, id) DESC, last_updated_at DESC * so a single statement can span several projects (e.g. a reused id resolved to all its owning projects, or a * cross-project batch) instead of one delete per project (OPIK-7483). Every deleted row carries its {@code * project_id}, so no delete is ever project-less - also required once {@code traces} is a Distributed table - * (OPIK-7455). No {@code id_at}/time predicate on purpose, so it still deletes rows whose {@code id_at} is - * untrustworthy (e.g. a wrapped timestamp); correctness here does not depend on {@code id_at}. + * (OPIK-7455). + *
+ * {@code
+ * Why it matters: a mutation selects parts at the partition stage, where the (workspace_id, project_id, id)
+ * predicate prunes nothing, so deleting a handful of rows rewrote every part of the table. Measured on prod-test
+ * (271.6 M rows, 3,928 parts): 12 ids rewrote 3,928 parts / 5.40 TiB. With this predicate the same batch
+ * selects 5 parts. An {@code id_at} range is not a substitute: on a batch spanning 1996 and 2200 a
+ * range still selected 2,644 parts, where the exact set selected 4.
*
* The pairs are bound (never inlined) as two positional string arrays and zipped back into {@code (project_id, id)}
* tuples with {@code arrayZip}, so the query text is constant regardless of batch size and no value reaches the SQL
@@ -1934,6 +1948,7 @@ ORDER BY (workspace_id, project_id, trace_id, id) DESC, last_updated_at DESC
DELETE FROM
+ * This has to be its own flag because neither existing schema flag marks the EXCHANGE, which is the moment the
+ * partitioning appears, and both are wrong in a different direction:
+ *
+ * Only ever {@code true} while {@code traces} really is that successor, so unlike its siblings it is safe to lag:
+ * {@code false} is the always-correct unbounded behaviour, and only {@code true} asserts something about the
+ * schema. Turn it on once the EXCHANGE is confirmed, and back off before a rollback stage B/C promotes the
+ * original.
+ */
+ private boolean tracesWeeklyPartitionPruningEnabled() {
+ return configuration.getDatabaseAnalyticsDataModel().tracesWeeklyPartitionPruningEnabled();
+ }
+
+ /**
+ * The partitions a delete batch may bound itself to, or empty to leave the mutation unbounded. Empty whenever the
+ * live table is not the partitioned successor, ahead of asking {@link WeeklyPartitions} at all — the derivation is
+ * only meaningful against a table that partitions on it.
+ */
+ private Optional {@code tracesWeeklyPartitionPruningEnabled}: enables partition-aware pruning of trace deletes — a trace
+ * {@code DELETE} bounds itself to the weekly partitions its own ids resolve to instead of being planned against every
+ * part of the table (OPIK-6901). It does not create or activate any partitioning; installing the partitioned
+ * schema is the EXCHANGE step of the cutover. Turning it on therefore asserts a schema fact rather than causing
+ * one: that the live mutation target already is the weekly partitioned successor — {@code id_at} as
+ * {@code DateTime64(0, 'UTC')} under
+ * {@code PARTITION BY toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1)))}. Purely an optimisation:
+ * {@code false} keeps the unbounded mutation, which is always correct and merely slower, and only {@code true} asserts
+ * anything about the schema. It is deliberately a third flag rather than a reuse of the two above, because the partitioning appears at the
+ * EXCHANGE and neither of them marks that moment. {@code traceColumnsNonNullable} must be rolled out
+ * before the EXCHANGE (a rolling restart cannot be atomic with a metadata swap), and
+ * {@code tracesDistributedWrapEnabled} flips at the wrap, a separate step that may be deferred long after it — so one
+ * flag would be true too early and the other true too late. Emitting the predicate too early is the harmful direction:
+ * legacy {@code traces} has no {@code PARTITION BY} at all and declares {@code id_at} as a 32-bit {@code DateTime} that
+ * overflows past 2106, so a far-future id is stored under a wrapped timestamp the derived partition cannot match and
+ * the delete would silently affect zero rows. Left {@code false} at deploy time; set {@code true} once the EXCHANGE is
+ * confirmed on the target, and back to {@code false} before a rollback promotes the original {@code traces}. Mirrors the partition expression of {@code traces_local_v2} / {@code spans_local_v2} exactly —
+ * {@code PARTITION BY toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1)))}, where {@code id_at} is
+ * {@code DateTime64(0, 'UTC') MATERIALIZED UUIDv7ToDateTime(toUUID(id))} — i.e. the Monday of the id's UTC week as
+ * {@code yyyyMMdd}. Both tables share the expression, so it is derived here once rather than per DAO. Why the caller must treat an empty result as "no predicate", never as "no partitions". The whole value of
+ * the derivation is that a partition the batch resolves to is the only place its rows can be; a value that is
+ * merely close is a silently skipped delete, not a slower one. So this returns a set only when every id in the batch
+ * is one it can derive exactly, and empty otherwise — leaving the caller to emit its unbounded form, which is always
+ * correct and merely slower. The two rejections are: Far-future ids within the range are supported on purpose and are the common case of the two: a UUIDv7
+ * minted with a bad clock (litellm BerriAI/litellm#31294
+ * mints ~2201) has a bogus but self-consistent {@code id_at}, so it lives in the far-future partition this computes and
+ * stays deletable and prunable. Verified on prod-test: 0 partition mismatches across 11.23 M far-future rows.
+ * A {@code null} batch throws rather than reading as empty, which is the one place this class is deliberately
+ * intolerant. Empty is a documented answer — "this batch cannot be pruned, emit the unbounded form" — and a
+ * caller that lost its batch would receive that answer, silently issue a correct-but-unbounded mutation, and never
+ * learn it had a bug. A null collection here is a programming error, not a data condition; a null element
+ * is the data condition, and that keeps returning empty.
+ *
+ * @throws NullPointerException if {@code ids} is null.
+ */
+ public static Optional Everything is asserted through the DAO's own delete. This suite writes no query that re-implements or
+ * re-evaluates the partition expression, and it asserts nothing about the EXCHANGE — the EXCHANGE is only setup (see
+ * below). Its own SQL is three statements, all plain binds: seed a row, count a row, read a statement back from
+ * {@code system.query_log}. Correctness is established the way production would feel it — the rows go away. If
+ * the DAO's predicate resolved to any partition other than the one ClickHouse filed a row under, the mutation would
+ * select the wrong parts and that row would survive, so
+ * {@link #deleteClearsEveryEraAndBindsExactlyThosePartitions} passing is the agreement between the migration's
+ * {@code PARTITION BY} as installed, the DAO's predicate, and {@link WeeklyPartitions#of}.
+ *
+ * Each test then pairs that with the SQL ClickHouse actually received, because rows alone cannot see pruning
+ * silently stop — a delete that stopped bounding itself is still correct, just slow, and that is the regression this
+ * change exists to prevent. Read back by {@code log_comment} plus the test's own trace id, and checked as an
+ * exact bound partition set: a superset would keep every delete correct while handing back the whole benefit.
+ *
+ * The eras in {@link #deleteClearsEveryEraAndBindsExactlyThosePartitions} are load-bearing, not variety.
+ * {@code toMonday} agrees with the {@code Date32} expression across the ordinary calendar and diverges only far-future
+ * or at the epoch, so a recent-only batch would accept the very expression migration 000114 was written to escape. The
+ * 2200 row also covers the {@code DateTime64} half of what the flag asserts: against a 32-bit {@code id_at} it would be
+ * stored under a wrapped recent timestamp, the derived partition would not match, and it would survive.
+ *
+ * Two internal touches, on the pattern of {@code TracesDistributedWrapMutationTest}: the EXCHANGE has no public API,
+ * so {@link #beforeAll} runs it in raw SQL identical to the swap block of {@code 000003_exchange_and_wrap.sql}; and the
+ * ingestion path rejects a non-v7, backdated or far-future {@code id} by design ({@code IdGenerator.validateId}), so
+ * those rows are seeded raw and those batches are handed to {@link TraceDAO#delete} directly — the only way to reach
+ * that arm.
+ *
+ * Why the topology setup is here at all — it is setup, never the subject. Nothing asserts anything about the
+ * EXCHANGE or the wrap themselves; {@code TracesLocalV2CutoverTest} owns that. They are required because the DAO names
+ * its target table: after the Liquibase migrations the live {@code traces} is still the legacy table — no
+ * {@code PARTITION BY} at all and a 32-bit {@code DateTime} {@code id_at} — while the partitioned successor exists only
+ * as the empty {@code traces_local_v2}, which no DAO query can reach. Without installing it these tests would run
+ * against the one table where this predicate must never be emitted, and would pass while proving nothing: the predicate
+ * is harmless against an unpartitioned table for recent ids. Hand-authoring a partitioned {@code traces} in the test
+ * instead would duplicate migration 000114 and reintroduce exactly the drift this suite exists to detect. Both steps are
+ * idempotent, so the suite keeps working once the cutover migration lands and they become no-ops — and that is
+ * asserted rather than assumed, by {@link PruningEnabled#topologySetupIsANoOpOnceTheEstateProvidesIt}. Two states
+ * are therefore covered: with the swap, which every other test here needs today, and without it,
+ * which is what the estate will look like once the migrations create {@code traces_local} partitioned with the
+ * {@code Distributed} {@code traces} over it and this suite's {@code EXCHANGE} stops being needed at all.
+ *
+ * Topology: the post-cutover end state, with both schema flags on. The wrap is applied and
+ * {@code tracesDistributedWrapEnabled} set, so the DAO's mutations reach the data the way production routes them —
+ * {@code DELETE FROM traces_local}, chosen by the configuration switch that governs it, not by a table this suite
+ * renamed under the DAO. {@code traces} is the {@code Distributed} wrapper that reads and inserts flow through, which
+ * is why the endpoint-created row and the raw-seeded ones land in the same place.
+ * {@link #distributedTracesRejectsDirectMutation} keeps that claim honest: had the wrap not taken effect,
+ * {@code traces} would still be a {@code MergeTree} and every pruned delete here would have run against it.
+ *
+ * This supersedes an earlier note in this file that both flags on at once was untested and would need its own suite.
+ * It does not: the wrap flag is how the DAO is pointed at the data, so enabling it costs two setup statements and
+ * covers the combination the fleet actually ends up in. What is no longer covered here is the transient
+ * post-EXCHANGE/pre-wrap window — the pruning predicate is identical in both, since {@code traces_local} is the same
+ * physical table under a different name, and the flag's own javadoc records that it must hold in that window.
+ *
+ * Dedicated, non-reused ClickHouse and ZooKeeper containers are required because the setup destructively renames the
+ * live {@code traces} table — the EXCHANGE swaps it, and the wrap then renames it to {@code traces_local} — so a reused
+ * container would corrupt other suites and reruns.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class TracesPartitionPruningMutationTest {
+
+ private static final String API_KEY = "apiKey-" + UUID.randomUUID();
+ private static final String WORKSPACE_NAME = "workspace-" + RandomStringUtils.secure().nextAlphanumeric(32);
+ private static final String WORKSPACE_ID = UUID.randomUUID().toString();
+ private static final String USER = "user-" + RandomStringUtils.secure().nextAlphanumeric(32);
+
+ /**
+ * The partition-key fragment the DAO's template emits. Only ever compared against the statement read back
+ * from {@code system.query_log} — never spliced into a query this suite runs. Verbatim comparison is safe because
+ * {@code query_log} stores the query as submitted, so both sides are the DAO's own template text.
+ */
+ private static final String PARTITION_PREDICATE = "toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1)))";
+
+ // The suite's whole SQL surface, per .agents/skills/opik-backend/SKILL.md "SQL Query Construction": one text block
+ // per query, every varying value a :placeholder. There are no StringTemplate fragments and no interpolation at all,
+ // because nothing here re-implements the DAO's predicate - PARTITION_PREDICATE is only ever compared against the
+ // statement the DAO emitted, never spliced into a query of ours.
+ //
+ // The EXCHANGE/RENAME pair in installPartitionedSuccessorUnderTraces() stays as inline literals on purpose: they
+ // are single literals built by no Java string operation, and they are kept byte-identical to
+ // 000003_exchange_and_wrap.sql by eye, so they belong at the call site next to the javadoc that says so - as
+ // TracesDistributedWrapMutationTest does.
+ private static final String PARTITION_KEY_OF_TABLE = """
+ SELECT partition_key
+ FROM system.tables
+ WHERE database = currentDatabase()
+ AND name = :table
+ """;
+
+ private static final String TABLE_COUNT = """
+ SELECT toString(count())
+ FROM system.tables
+ WHERE database = currentDatabase()
+ AND name = :table
+ """;
+
+ private static final String TABLE_ENGINE_FULL = """
+ SELECT engine_full
+ FROM system.tables
+ WHERE database = currentDatabase()
+ AND name = 'traces'
+ """;
+
+ private static final String INSERT_RAW_TRACE = """
+ INSERT INTO traces_local (workspace_id, project_id, id)
+ VALUES (:workspace_id, :project_id, :id)
+ """;
+
+ private static final String LAST_TRACE_DELETE = """
+ SELECT query
+ FROM system.query_log
+ WHERE log_comment LIKE 'delete_traces:%'
+ AND type = 'QueryFinish'
+ AND query LIKE concat('%', :trace_id, '%')
+ ORDER BY event_time_microseconds DESC
+ LIMIT 1
+ """;
+
+ /**
+ * The newest {@code delete_traces} statement carrying exactly {@code pairs_size} pairs. A request larger than
+ * {@link com.comet.opik.infrastructure.FilterUtils#ANALYTICS_DELETE_BATCH_SIZE} is chunked by the DAO into one
+ * statement per chunk, and pruning is derived per chunk — so a test that reads only one statement cannot see
+ * the second chunk at all.
+ */
+ private static final String DELETE_BY_PAIR_COUNT = """
+ SELECT query
+ FROM system.query_log
+ WHERE log_comment LIKE concat('delete_traces:%pairs_size=', :pairs_size)
+ AND type = 'QueryFinish'
+ ORDER BY event_time_microseconds DESC
+ LIMIT 1
+ """;
+
+ /**
+ * Scoped by the same key {@code TraceDAO.delete} matches on — {@code (workspace_id, project_id, id)}. An oracle
+ * narrower than the delete would answer a different question: a row for the same id in another project would keep
+ * the count at {@code 1} after a successful delete, failing a test whose subject worked.
+ */
+ private static final String LIVE_ROW_COUNT = """
+ SELECT toString(uniqExact(id))
+ FROM traces_local
+ WHERE workspace_id = :workspace_id
+ AND project_id = :project_id
+ AND id = :id
+ """;
+
+ /**
+ * The {@code IN} clause the DAO emitted, captured to end of line: the predicate sits on its own line in the
+ * template with {@code SETTINGS log_comment} on the next, so the line boundary delimits it exactly. Read this way
+ * rather than by matching a bracket style, because the driver's rendering of a {@code Long[]} is its own choice —
+ * what matters is which partition values are in the clause, not how it punctuates them.
+ */
+ private static final Pattern EMITTED_IN_CLAUSE = Pattern.compile(
+ Pattern.quote(PARTITION_PREDICATE) + "\\s+IN\\s+([^\\n]*)");
+
+ /**
+ * The emitted statement's shape, so its {@code WHERE} clause can be lifted verbatim and re-asked as a
+ * {@code SELECT}: {@code EXPLAIN} does not accept a mutation. Captures the target table too, since the DAO picks
+ * {@code traces} or {@code traces_local} depending on the wrap flag.
+ */
+ private static final Pattern DELETE_SHAPE = Pattern.compile(
+ "DELETE\\s+FROM\\s+(\\S+)\\s+(WHERE\\b.*?)\\s+SETTINGS\\b", Pattern.DOTALL);
+
+ /** {@code EXPLAIN} index entries that reflect partition-level part selection. */
+ private static final Set
+ * The predicate fragment is {@link #PARTITION_PREDICATE}, and using the constant does not re-author what is under
+ * test: every caller has already asserted the emitted statement contains that exact text, so the constant
+ * is pinned to the DAO's own SQL by assertion rather than by string surgery on it. The partition values come from
+ * the emitted statement too, parsed by {@link #boundPartitionsOf} and bound here.
+ *
+ * The DAO's {@code workspace_id} and {@code (project_id, id)} predicates are deliberately not reproduced. They are
+ * sort-key filters, not partition filters, so they cannot change partition selection — and leaving them out makes
+ * the unbounded case a full scan, which is the conservative direction for an assertion that the fallback prunes
+ * nothing.
+ */
+ private static final String EXPLAIN_SELECTED_PARTS = """
+ EXPLAIN indexes = 1, json = 1
+ SELECT id
+ FROM
+ * Ids are minted mid-week ({@link #idInWeekOf}), so the assertions exercise the map back to Monday rather
+ * than identity. The three eras are not interchangeable samples: {@code toMonday} agrees with the {@code Date32}
+ * expression across the ordinary calendar and diverges only far-future or at the epoch, so a recent-only batch would
+ * accept the very expression migration 000114 was written to escape. The 2199 row is the litellm shape and the one
+ * that makes it bite; it also covers the {@code DateTime64} half of what the flag asserts, since a 32-bit
+ * {@code id_at} would store it under a wrapped recent timestamp and the derived partition would miss it.
+ */
+ private static final List
+ * Never use this for anything the DAO executes — see {@link #appTemplate}. It carries none of the production
+ * {@code queryParameters}, so a statement the real connection would accept can fail here for reasons production
+ * would never hit.
+ */
+ private final TransactionTemplateAsync template;
+
+ {
+ Startables.deepStart(redisContainer, mysqlContainer, clickHouseContainer, zookeeperContainer)
+ .join();
+ wireMock = WireMockUtils.startWireMock();
+ MigrationUtils.runMysqlDbMigration(mysqlContainer);
+ MigrationUtils.runClickhouseDbMigration(clickHouseContainer);
+ template = TransactionTemplateAsync.create(ClickHouseContainerUtils
+ .newDatabaseAnalyticsFactory(clickHouseContainer, ClickHouseContainerUtils.DATABASE_NAME)
+ .build());
+ ensurePartitionedSuccessorUnderTraces();
+ ensureDistributedWrap();
+ }
+
+ private TraceResourceClient traceResourceClient;
+ private TraceDAO traceDAO;
+
+ /**
+ * The app's own connection handle, used for anything the DAO executes. It is not interchangeable with
+ * {@link #template}: the app builds its factory from {@code config-test.yml}, which carries the production
+ * {@code queryParameters} — including {@code max_query_size=100000000}. The container-derived handle sets none of
+ * them, so a full-size delete chunk (10,000 pairs inline to ~762 KiB of SQL) dies on ClickHouse's 256 KiB default.
+ * Routing the DAO through the container handle silently ran every delete in this suite on non-production settings.
+ */
+ private TransactionTemplateAsync appTemplate;
+
+ /**
+ * One app per flag state, identical in every other respect. {@code tracesWeeklyPartitionPruningEnabled} is the only
+ * thing that varies between the two nested classes, which is what lets them be read as an A/B: same schema, same
+ * topology, same fixtures, one flag.
+ *
+ * The other two flags are fixed on, as production runs them post-cutover — the successor's {@code end_time}/
+ * {@code ttft} are non-nullable sentinel columns, and the wrap is what points the DAO's mutations at
+ * {@code traces_local}.
+ */
+ private TestDropwizardAppExtension newApp(boolean pruningEnabled) {
+ return TestDropwizardAppExtensionUtils.newTestDropwizardAppExtension(
+ AppContextConfig.builder()
+ .jdbcUrl(mysqlContainer.getJdbcUrl())
+ .databaseAnalyticsFactory(ClickHouseContainerUtils.newDatabaseAnalyticsFactory(
+ clickHouseContainer, ClickHouseContainerUtils.DATABASE_NAME))
+ .redisUrl(redisContainer.getRedisURI())
+ .runtimeInfo(wireMock.runtimeInfo())
+ .customConfigs(List.of(
+ new CustomConfig("databaseAnalyticsDataModel.traceColumnsNonNullable", "true"),
+ new CustomConfig("databaseAnalyticsDataModel.tracesDistributedWrapEnabled", "true"),
+ new CustomConfig("databaseAnalyticsDataModel.tracesWeeklyPartitionPruningEnabled",
+ String.valueOf(pruningEnabled))))
+ .build());
+ }
+
+ /**
+ * Wires the currently-running nested class's app in. Safe on shared outer fields because JUnit runs nested
+ * containers one at a time — this tree configures no parallel execution — so only one app is live at a time.
+ */
+ private void bindApp(ClientSupport clientSupport, TraceDAO traceDAO, TransactionTemplateAsync appTemplate) {
+ var baseUrl = TestUtils.getBaseUrl(clientSupport);
+ ClientSupportUtils.config(clientSupport);
+ mockTargetWorkspace(wireMock.server(), API_KEY, WORKSPACE_NAME, WORKSPACE_ID, USER);
+ this.traceResourceClient = new TraceResourceClient(clientSupport, baseUrl);
+ this.traceDAO = traceDAO;
+ this.appTemplate = appTemplate;
+ }
+
+ @AfterAll
+ void afterAll() {
+ wireMock.server().stop();
+ clickHouseContainer.stop();
+ zookeeperContainer.stop();
+ network.close();
+ }
+
+ /** A UUIDv7 in the given week, minted mid-week so the partition assertion exercises the map back to Monday. */
+ private static UUID idInWeekOf(LocalDate monday) {
+ return ID_GENERATOR.generateId(monday.plusDays(2).atTime(12, 0).toInstant(ZoneOffset.UTC));
+ }
+
+ /**
+ * The partition name for a week, which is its Monday as {@code yyyyMMdd}. Formatting a Monday the test already
+ * names — not re-deriving "the Monday of an arbitrary date", which is the part under test.
+ */
+ private static long partitionNameOf(LocalDate monday) {
+ return monday.getYear() * 10000L + monday.getMonthValue() * 100L + monday.getDayOfMonth();
+ }
+
+ /**
+ * The partition a single id resolves to. Derived through {@code WeeklyPartitions} on purpose: this suite is about
+ * the predicate reaching ClickHouse, and the derivation's own expected values are pinned against real ClickHouse
+ * output in {@code WeeklyPartitionsTest}, so restating them here would only duplicate that. Where the expectation
+ * needs to be readable on its own — the era batch — the partitions are stated as literals instead.
+ */
+ private static long partitionOf(UUID id) {
+ return WeeklyPartitions.of(List.of(id)).orElseThrow().iterator().next();
+ }
+
+ /**
+ * The partition values actually bound into the emitted {@code IN} clause, so a test can assert the set is exact
+ * rather than merely inclusive. The driver substitutes bound values into the query text client-side, which is why
+ * they are readable here at all; the two assertions below are what make a change in that behaviour say so plainly
+ * instead of quietly turning every set assertion into a tautology on an empty set.
+ */
+ private static Set
+ * The id filter is what makes the lookup deterministic. Every test in this class deletes under the same workspace,
+ * so ordering by {@code event_time_microseconds} alone would hand back a neighbouring test's delete whenever this
+ * one had not reached {@code query_log} yet, or whenever two landed in the same microsecond — and that flake could
+ * pass for the wrong reason, since a neighbour's statement has the same shape. Each test's target id is freshly
+ * minted, so it identifies the statement exactly; the {@code ORDER BY}/{@code LIMIT} now only picks the newest
+ * attempt when surefire retries a test.
+ *
+ * Filtering on the id rather than on a marker injected into {@code details}: the DAO owns {@code log_comment} and
+ * puts {@code pairs_size} there, and the id is already in the statement text, so this needs no production change to
+ * serve a test.
+ */
+ private String lastTraceDeleteSql(UUID traceId) {
+ execute("SYSTEM FLUSH LOGS", _ -> {
+ });
+ var sql = queryOneString(LAST_TRACE_DELETE, statement -> statement.bind("trace_id", traceId.toString()));
+ assertThat(sql)
+ .as("query_log holds a delete_traces statement mentioning id '%s'", traceId)
+ .isNotNull();
+ return sql;
+ }
+
+ /**
+ * The newest {@code delete_traces} statement that carried exactly {@code pairsSize} pairs. Chunks are identified by
+ * their pair count rather than by a contained id, because the point is to inspect a specific chunk of one
+ * request — and the DAO stamps each chunk's size into its {@code log_comment}.
+ *
+ * The returned text is truncated for large statements. ClickHouse caps {@code query_log.query} at
+ * {@code log_queries_cut_to_length} (100,000 bytes by default), and a full 10,000-pair chunk inlines to ~762 KiB,
+ * so anything at the tail of such a statement — the partition predicate included — is simply absent. Only assert on
+ * the text of statements small enough to be recorded whole.
+ */
+ private String deleteSqlForChunkOf(int pairsSize) {
+ execute("SYSTEM FLUSH LOGS", _ -> {
+ });
+ var sql = queryOneString(DELETE_BY_PAIR_COUNT,
+ statement -> statement.bind("pairs_size", String.valueOf(pairsSize)));
+ assertThat(sql)
+ .as("query_log holds a delete_traces statement with pairs_size=%s", pairsSize)
+ .isNotBlank();
+ return sql;
+ }
+
+ /** {@code "1"} while a live (non-lightweight-deleted) row exists for the id, {@code "0"} once it is gone. */
+ private String liveRowCount(UUID projectId, UUID id) {
+ return queryOneString(LIVE_ROW_COUNT, statement -> statement
+ .bind("project_id", projectId.toString())
+ .bind("workspace_id", WORKSPACE_ID)
+ .bind("id", id.toString()));
+ }
+
+ /**
+ * Setup, not a test: puts the partitioned successor under the name the DAO deletes from, so the pruning assertions
+ * are made against a table that actually has weekly partitions.
+ *
+ * Idempotent on purpose, because the estate this runs against is going to change. Today {@code traces} is
+ * the legacy table and the successor is the empty {@code traces_local_v2}, so the two cutover statements are needed.
+ * Once the cutover migration lands, {@code traces} is the partitioned successor and {@code traces_local_v2}
+ * is gone — at which point this is a no-op and the suite keeps working unchanged, instead of dying in
+ * {@link #beforeAll} on an {@code EXCHANGE} against a table that no longer exists. If neither state holds it fails
+ * with that said plainly, rather than surfacing as a bare "table not found".
+ *
+ * The EXCHANGE (000003 exchange block): puts the successor under {@code traces} and the original under
+ * {@code traces_local_v2}, then a RENAME parks the original as {@code traces_pre_cutover_backup}. The wrap is
+ * deliberately not applied — it is a separate, deferrable step, and the flag under test must hold on its own between
+ * the two (which is why it is not the wrap flag). Kept identical to the cutover SQL by eye, as
+ * {@code TracesLocalV2CutoverTest.exchangeTables} and the wrap suite do.
+ */
+ private void ensurePartitionedSuccessorUnderTraces() {
+ if (tableExists("traces_local") || partitionKeyOf("traces").contains("id_at")) {
+ return; // Already installed, or the cutover migration has landed.
+ }
+ assertThat(tableExists("traces_local_v2"))
+ .as("neither `traces_local` nor a partitioned `traces` nor `traces_local_v2` is present (partition key"
+ + " of `traces`: '%s') - this suite needs one of those states to install the successor from",
+ partitionKeyOf("traces"))
+ .isTrue();
+ execute("EXCHANGE TABLES traces AND traces_local_v2 ON CLUSTER '{cluster}'", _ -> {
+ });
+ execute("RENAME TABLE traces_local_v2 TO traces_pre_cutover_backup ON CLUSTER '{cluster}'", _ -> {
+ });
+ }
+
+ /**
+ * Setup, not a test: applies the sharding-readiness wrap, so the DAO's mutations reach the data through the
+ * configuration switch that governs them in production ({@code tracesDistributedWrapEnabled}) rather than through
+ * a table this suite renamed under it. After this, {@code traces} is a {@code Distributed} wrapper and
+ * {@code traces_local} holds the partitioned data — the post-cutover end state.
+ *
+ * Kept identical to the wrap block of {@code 000003_exchange_and_wrap.sql}, as
+ * {@code TracesDistributedWrapMutationTest.applyDistributedWrap} and
+ * {@code TracesLocalV2CutoverTest.wrapInDistributed} do: build the wrapper under a temp name first, then one atomic
+ * multi-target {@code RENAME} rotates the data to {@code traces_local} and the wrapper into {@code traces}, so
+ * {@code traces} is never absent. Re-entrant: it returns early when the wrap is already applied, and
+ * clears a wrapper stranded by an interrupted run before rebuilding it.
+ */
+ private void ensureDistributedWrap() {
+ // Accepting any Distributed table would let a wrapper pointing at another database or another local table
+ // block the rebuild and silently route reads and inserts elsewhere - so the check is on the definition, not the
+ // engine name. Matched on the two parts that decide where rows actually go (the database and the local target)
+ // rather than the whole string, which ClickHouse re-prints and which would make this brittle about spacing.
+ var engineFull = Optional.ofNullable(queryOneString(TABLE_ENGINE_FULL, _ -> {
+ })).orElse("");
+ if (engineFull.startsWith("Distributed")) {
+ assertThat(engineFull)
+ .as("`traces` is already Distributed but not over this database's traces_local: %s", engineFull)
+ .contains("'" + ClickHouseContainerUtils.DATABASE_NAME + "'")
+ .contains("'traces_local'");
+ return;
+ }
+ // Clear a wrapper left behind by a run that died between the CREATE and the RENAME. It holds no data - a
+ // Distributed table is a routing definition - so dropping it is safe, and without this the CREATE below fails
+ // on a duplicate name and buries the real state. Same reset TracesLocalV2CutoverTest performs.
+ execute("DROP TABLE IF EXISTS traces_dist ON CLUSTER '{cluster}' SYNC", _ -> {
+ });
+ execute(TemplateUtils.newST(CREATE_DISTRIBUTED_WRAPPER)
+ .add("database", ClickHouseContainerUtils.DATABASE_NAME)
+ .render(), _ -> {
+ });
+ execute("""
+ RENAME TABLE
+ traces TO traces_local,
+ traces_dist TO traces
+ ON CLUSTER '{cluster}'
+ """, _ -> {
+ });
+ }
+
+ private boolean tableExists(String table) {
+ return "1".equals(queryOneString(TABLE_COUNT, statement -> statement.bind("table", table)));
+ }
+
+ /**
+ * The table's partition-key expression, or {@code ""} when there is no such table — never {@code null}. A missing
+ * row is a legitimate state here (a half-applied wrap can leave {@code traces} renamed away), and the setup guard
+ * has to be able to report that rather than die dereferencing it, which is what would bury the diagnostic.
+ */
+ private String partitionKeyOf(String table) {
+ return Optional
+ .ofNullable(queryOneString(PARTITION_KEY_OF_TABLE, statement -> statement.bind("table", table)))
+ .orElse("");
+ }
+
+ /**
+ * A trace with every trace-table column populated. Only the span-derived aggregates podam would otherwise
+ * fabricate ({@code feedbackScores}, {@code usage}) are nulled, since they are not columns of the {@code traces}
+ * table and would only add noise. The generated {@code projectName} is fresh per trace, so a test that wants two
+ * traces in one project has to say so; that keeps one test's rows out of another's reads.
+ */
+ private Trace.TraceBuilder newTrace() {
+ return factory.manufacturePojo(Trace.class).toBuilder()
+ .feedbackScores(null)
+ .usage(null);
+ }
+
+ private UUID projectIdOf(Trace trace) {
+ return traceResourceClient
+ .getTraces(trace.projectName(), null, API_KEY, WORKSPACE_NAME, List.of(), List.of(), 100, Map.of())
+ .content().stream()
+ .filter(found -> found.id().equals(trace.id()))
+ .map(Trace::projectId)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private List
+ * {@code SELECT id} rather than {@code count()}, so no trivial-count optimisation can answer from metadata without
+ * selecting parts at all.
+ *
+ * Only {@code MinMax} and {@code Partition} entries are considered, and {@code PrimaryKey} is deliberately
+ * excluded: the DAO's {@code WHERE} also filters {@code workspace_id} and {@code (project_id, id)}, which are the
+ * sort key, so {@code PrimaryKey} prunes parts for the unbounded statement too — counting it would make the
+ * fallback look pruned and destroy the discrimination this test rests on. Across the entries that do qualify it
+ * takes the smallest selected count and the largest initial count, so it does not depend on which of the two
+ * reports the pruning.
+ *
+ * Empty is a meaningful answer rather than a failure: the {@code Indexes} block carries a partition entry only when
+ * the query filters on the partition key, so its absence is exactly what the fallback should produce.
+ */
+ private Optional
+ * Both assertions here are the inverse of one in {@link PruningEnabled}, on identical data: no partition predicate
+ * is emitted, and the planner selects every part. Delete the flag gate so pruning always happens and these fail;
+ * delete the pruning and the other class fails. Correctness is unaffected either way, which is the point — the rows
+ * go away in both, so only these assertions can tell the two states apart.
+ */
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ @ExtendWith(DropwizardAppExtensionProvider.class)
+ class PruningDisabled {
+
+ @RegisterApp
+ private final TestDropwizardAppExtension app = newApp(false);
+
+ @BeforeAll
+ void beforeAll(ClientSupport clientSupport, TraceDAO traceDAO, TransactionTemplateAsync appTemplate) {
+ bindApp(clientSupport, traceDAO, appTemplate);
+ }
+
+ @Test
+ @DisplayName("the same batch still clears every era, and emits no partition predicate")
+ void deleteStillClearsEveryEraWithoutPruning() {
+ var projectId = ID_GENERATOR.generateId();
+ var ids = ERA_MONDAYS.stream().map(TracesPartitionPruningMutationTest::idInWeekOf).toList();
+ ids.forEach(id -> insertRawTrace(projectId, id));
+
+ delete(ids.stream().map(id -> Pair.of(projectId, id)).collect(Collectors.toUnmodifiableSet()));
+
+ assertThat(ids.stream().map(id -> liveRowCount(projectId, id)))
+ .as("the delete is still correct with the flag off - that is what makes it an optimisation")
+ .containsOnly("0");
+ var sql = lastTraceDeleteSql(ids.getFirst());
+ assertThat(sql)
+ .as("and carries no id_at narrowing of any kind")
+ .doesNotContain("id_at");
+ assertThat(EMITTED_IN_CLAUSE.matcher(sql).find())
+ .as("nor a partition IN clause: %s", sql)
+ .isFalse();
+ }
+
+ @Test
+ @DisplayName("the planner selects every part - the enabled class's assertion, inverted on the same data")
+ void plannerPrunesNothingWithoutPruning() {
+ var projectId = ID_GENERATOR.generateId();
+ var ids = ERA_MONDAYS.stream().map(TracesPartitionPruningMutationTest::idInWeekOf).toList();
+ ids.forEach(id -> insertRawTrace(projectId, id));
+
+ delete(Set.of(Pair.of(projectId, ids.getFirst())));
+
+ var parts = partsSelectedBy(lastTraceDeleteSql(ids.getFirst()));
+ assertThat(parts.map(selected -> selected.selected() == selected.total()).orElse(true))
+ .as("no partition pruning with the flag off: %s", parts)
+ .isTrue();
+ }
+ }
+}
diff --git a/apps/opik-backend/src/test/java/com/comet/opik/infrastructure/TracesPruningDisabledMutationTest.java b/apps/opik-backend/src/test/java/com/comet/opik/infrastructure/TracesPruningDisabledMutationTest.java
new file mode 100644
index 00000000000..736e2c50726
--- /dev/null
+++ b/apps/opik-backend/src/test/java/com/comet/opik/infrastructure/TracesPruningDisabledMutationTest.java
@@ -0,0 +1,287 @@
+package com.comet.opik.infrastructure;
+
+import com.comet.opik.api.Trace;
+import com.comet.opik.api.resources.utils.ClickHouseContainerUtils;
+import com.comet.opik.api.resources.utils.ClientSupportUtils;
+import com.comet.opik.api.resources.utils.MigrationUtils;
+import com.comet.opik.api.resources.utils.MySQLContainerUtils;
+import com.comet.opik.api.resources.utils.RedisContainerUtils;
+import com.comet.opik.api.resources.utils.TestDropwizardAppExtensionUtils;
+import com.comet.opik.api.resources.utils.TestDropwizardAppExtensionUtils.AppContextConfig;
+import com.comet.opik.api.resources.utils.TestUtils;
+import com.comet.opik.api.resources.utils.WireMockUtils;
+import com.comet.opik.api.resources.utils.resources.TraceResourceClient;
+import com.comet.opik.domain.TraceDAO;
+import com.comet.opik.extensions.DropwizardAppExtensionProvider;
+import com.comet.opik.extensions.RegisterApp;
+import com.comet.opik.infrastructure.auth.RequestContext;
+import com.comet.opik.infrastructure.db.TransactionTemplateAsync;
+import com.comet.opik.podam.PodamFactoryUtils;
+import com.redis.testcontainers.RedisContainer;
+import io.r2dbc.spi.Statement;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testcontainers.clickhouse.ClickHouseContainer;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.lifecycle.Startables;
+import org.testcontainers.mysql.MySQLContainer;
+import reactor.core.publisher.Mono;
+import ru.vyarus.dropwizard.guice.test.ClientSupport;
+import ru.vyarus.dropwizard.guice.test.jupiter.ext.TestDropwizardAppExtension;
+import uk.co.jemos.podam.api.PodamFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import static com.comet.opik.api.resources.utils.AuthTestUtils.mockTargetWorkspace;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+/**
+ * The other side of {@code TracesPartitionPruningMutationTest}: the trace delete with
+ * {@code databaseAnalyticsDataModel.tracesWeeklyPartitionPruningEnabled} off — its default — against the
+ * legacy {@code traces}. That is not an exotic topology; it is the state every deployment is in today, and the
+ * state a stage B/C rollback returns to.
+ *
+ * What it guards is a false-flag regression: the partition predicate emitted while the flag is off, or any
+ * other {@code id_at} narrowing creeping into the unbounded form. Legacy {@code traces} has no {@code PARTITION BY} at
+ * all and declares {@code id_at} as a 32-bit {@code DateTime} that overflows past 2106 (migrations 000001 and 000091),
+ * so a predicate here prunes nothing and can silently exclude rows.
+ *
+ * Nothing else catches it. Every other trace-delete suite runs in exactly this state and asserts only that rows go
+ * away — which they do for a recent id, because the legacy {@code id_at} is accurate for one. The damage shows
+ * only on a far-future id (litellm BerriAI/litellm#31294
+ * mints ~2201): the 32-bit column stores a wrapped recent timestamp, a derived partition cannot match it, and the
+ * delete reports success having matched zero rows. No existing test has such a row, because ingestion rejects
+ * far-future ids by design ({@code IdGenerator.validateId}) — so this seeds one in raw SQL and deletes it through
+ * {@link TraceDAO#delete}, which is also the only way to reach that arm.
+ *
+ * Shared, reusable containers: unlike the post-EXCHANGE suite this changes no topology, so there is nothing here
+ * that could corrupt another suite or a rerun.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@ExtendWith(DropwizardAppExtensionProvider.class)
+class TracesPruningDisabledMutationTest {
+
+ private static final String API_KEY = "apiKey-" + UUID.randomUUID();
+ private static final String WORKSPACE_NAME = "workspace-" + RandomStringUtils.secure().nextAlphanumeric(32);
+ private static final String WORKSPACE_ID = UUID.randomUUID().toString();
+ private static final String USER = "user-" + RandomStringUtils.secure().nextAlphanumeric(32);
+
+ /** A UUIDv7 carrying id_at 2200-01-01 — the litellm shape, and the id the legacy 32-bit column wraps. */
+ private static final UUID FAR_FUTURE_ID = UUID.fromString("0699eb8a-59dd-7215-8000-03b8d2a8d5e2");
+
+ private static final String INSERT_RAW_TRACE = """
+ INSERT INTO traces (workspace_id, project_id, id)
+ VALUES (:workspace_id, :project_id, :id)
+ """;
+
+ /**
+ * Scoped by the same key {@code TraceDAO.delete} matches on — {@code (workspace_id, project_id, id)}. An oracle
+ * narrower than the delete answers a different question, and this suite runs on shared containers where other
+ * suites' rows are present, so the scoping is what keeps the count about this test's row.
+ */
+ private static final String LIVE_ROW_COUNT = """
+ SELECT toString(uniqExact(id))
+ FROM traces
+ WHERE workspace_id = :workspace_id
+ AND project_id = :project_id
+ AND id = :id
+ """;
+
+ private static final String LAST_TRACE_DELETE = """
+ SELECT query
+ FROM system.query_log
+ WHERE log_comment LIKE 'delete_traces:%'
+ AND type = 'QueryFinish'
+ AND query LIKE concat('%', :trace_id, '%')
+ ORDER BY event_time_microseconds DESC
+ LIMIT 1
+ """;
+
+ private static final String LEGACY_SCHEMA = """
+ SELECT concat(
+ (SELECT partition_key FROM system.tables
+ WHERE database = currentDatabase() AND name = 'traces'),
+ '|',
+ (SELECT type FROM system.columns
+ WHERE database = currentDatabase() AND table = 'traces' AND name = 'id_at'))
+ """;
+
+ private final RedisContainer redisContainer = RedisContainerUtils.newRedisContainer();
+ private final MySQLContainer mysqlContainer = MySQLContainerUtils.newMySQLContainer();
+ private final GenericContainer> zookeeperContainer = ClickHouseContainerUtils.newZookeeperContainer();
+ private final ClickHouseContainer clickHouseContainer = ClickHouseContainerUtils
+ .newClickHouseContainer(zookeeperContainer);
+
+ private final WireMockUtils.WireMockRuntime wireMock;
+
+ private final PodamFactory factory = PodamFactoryUtils.newPodamFactory();
+
+ @RegisterApp
+ private final TestDropwizardAppExtension app;
+
+ {
+ Startables.deepStart(redisContainer, mysqlContainer, clickHouseContainer, zookeeperContainer).join();
+ wireMock = WireMockUtils.startWireMock();
+ var databaseAnalyticsFactory = ClickHouseContainerUtils.newDatabaseAnalyticsFactory(
+ clickHouseContainer, ClickHouseContainerUtils.DATABASE_NAME);
+ MigrationUtils.runMysqlDbMigration(mysqlContainer);
+ MigrationUtils.runClickhouseDbMigration(clickHouseContainer);
+ // No customConfigs on purpose: the defaults ARE the state under test.
+ app = TestDropwizardAppExtensionUtils.newTestDropwizardAppExtension(
+ AppContextConfig.builder()
+ .jdbcUrl(mysqlContainer.getJdbcUrl())
+ .databaseAnalyticsFactory(databaseAnalyticsFactory)
+ .redisUrl(redisContainer.getRedisURI())
+ .runtimeInfo(wireMock.runtimeInfo())
+ .build());
+ }
+
+ private TraceResourceClient traceResourceClient;
+ private TransactionTemplateAsync template;
+ private TraceDAO traceDAO;
+
+ @BeforeAll
+ void beforeAll(ClientSupport clientSupport, TransactionTemplateAsync template, TraceDAO traceDAO) {
+ var baseUrl = TestUtils.getBaseUrl(clientSupport);
+ ClientSupportUtils.config(clientSupport);
+ mockTargetWorkspace(wireMock.server(), API_KEY, WORKSPACE_NAME, WORKSPACE_ID, USER);
+ traceResourceClient = new TraceResourceClient(clientSupport, baseUrl);
+ this.template = template;
+ this.traceDAO = traceDAO;
+
+ // This whole suite is about the pre-cutover / post-rollback state. Once the cutover migration lands there is no
+ // legacy `traces` left to test - it becomes the partitioned successor with a DateTime64 id_at - and the
+ // far-future wrapping hazard this guards stops existing. Skip with the reason stated rather than fail on a
+ // premise that has legitimately gone away; JUnit reports the skip and its description in the test report.
+ assumeThat(legacySchema())
+ .as("pre-cutover estate: legacy `traces`, unpartitioned with a 32-bit DateTime id_at")
+ .isEqualTo("|DateTime('UTC')");
+ }
+
+ @AfterAll
+ void afterAll() {
+ wireMock.server().stop();
+ }
+
+ @Test
+ @DisplayName("with pruning off, a far-future row on the legacy table is still deleted, unbounded")
+ void farFutureRowOnLegacyTableIsStillDeleted() {
+ // The project and its id come from the real ingestion path - create a trace through the endpoint, then read the
+ // project id back off it - so the delete runs against a project that exists and a genuine UUIDv7 project id,
+ // not a fabricated one.
+ var seedTrace = factory.manufacturePojo(Trace.class).toBuilder()
+ .feedbackScores(null)
+ .usage(null)
+ .build();
+ traceResourceClient.createTrace(seedTrace, API_KEY, WORKSPACE_NAME);
+ var projectId = projectIdOf(seedTrace);
+ assertThat(projectId.version()).as("the project id is a real UUIDv7, as the backend mints them").isEqualTo(7);
+
+ // Only the far-future row is raw, because ingestion rejects it by design (24h window). A recent id would prove
+ // nothing here anyway: the legacy id_at is accurate for one, so even a wrongly-emitted predicate would match it
+ // and the row would still go.
+ insertRawTrace(projectId, FAR_FUTURE_ID);
+ assertThat(liveRowCount(projectId, FAR_FUTURE_ID)).as("the far-future row is seeded").isEqualTo("1");
+
+ delete(Set.of(Pair.of(projectId, FAR_FUTURE_ID)));
+
+ assertThat(liveRowCount(projectId, FAR_FUTURE_ID))
+ .as("it is deleted - a partition predicate here would have matched nothing and reported success")
+ .isEqualTo("0");
+
+ var sql = lastTraceDeleteSql(FAR_FUTURE_ID);
+ assertThat(sql)
+ .as("no partition predicate is emitted while the flag is off")
+ .doesNotContain("toYYYYMMDD", "toDayOfWeek", "toIntervalDay");
+ assertThat(sql)
+ .as("and no id_at narrowing of any kind - toMonday, a range, or otherwise")
+ .doesNotContain("id_at");
+ }
+
+ /** Invokes the DAO under a workspace/user context, as {@code TraceService} does for the live delete path. */
+ private void delete(Set
+ * The in-range expected values are not hand-computed: each is what ClickHouse itself returned for
+ * {@code toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1)))} on prod-test for that id. If the table's
+ * partition expression ever changes, these assertions are what should fail.
+ *
+ * The range-boundary cases are the exception and say so: the ids are constructed rather than observed, because the point
+ * of each is an {@code id_at} value the column cannot store, which is exactly what no real row has.
+ */
+class WeeklyPartitionsTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource
+ @DisplayName("matches the partition ClickHouse computed")
+ void matchesThePartitionClickHouseComputed(String era, UUID id, long expectedPartition) {
+ assertThat(WeeklyPartitions.of(List.of(id))).contains(Set.of(expectedPartition));
+ }
+
+ /**
+ * One row per era the derivation has to get right, each pinned to the value ClickHouse itself returned for that id
+ * on prod-test — not to a hand-computed Monday. The eras are not interchangeable samples: an expression that is
+ * correct for the ordinary calendar and wrong at the extremes is exactly the {@code toMonday} trap migration 000114
+ * was written to escape, so dropping any of the three would weaken the pin rather than tidy it.
+ */
+ private static Stream
+ *
+ * Emitting it against the legacy table is not merely unhelpful, it is wrong: legacy {@code traces} has no
+ * {@code PARTITION BY} at all (one {@code all} partition, so nothing to prune) and declares {@code id_at} as a
+ * 32-bit {@code DateTime} that overflows past 2106, so a far-future id — the litellm ~2201 ids, real
+ * customer-facing rows — is stored under a wrapped recent timestamp that the derived partition cannot match. The
+ * delete would then match zero rows and report success.
+ *
+ *
+ *
+ * } and the predicate
+ * {@code
+