-
[OPIK-7398] [BE] feat: create spans_local_v2 shadow table with weekly partitioning and traces-informed codecs (#7628)
发布于
2026-08-04 08:30:58 +00:00 - [OPIK-7398] [BE] feat: create spans_local_v2 shadow table with weekly partitioning and traces-informed codecs
Creates spans_local_v2 empty next to the live spans table: weekly
PARTITION BY toMonday(id_at), microsecond timestamps, epoch/NaN
sentinels replacing the three Nullable columns, the is_deleted
ReplacingMergeTree meta-column, an id_at minmax skip index and raised
index_granularity_bytes.Every column the traces equivalent also has carries the exact codec the
traces tests pin after their three refinement passes. The spans-only
columns follow the same rules: FixedString(36) ids on ZSTD(1), Enum8 on
ZSTD(1), and small repetitive text (model, provider,
total_estimated_cost_version) on the 26.3-safe ZSTD(3). Only usage and
total_estimated_cost, which no rule covers, stay best guesses for the
spans codec benchmark to settle.No cutover, no backfill and no storage_policy here.
Counterpart of migration 000101 (OPIK_6900).
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] fix: correct the parent_span_id sentinel note and pin both halves of its contract
The cutover note claimed the DAO's presence checks become always-true on
a FixedString(36) parent_span_id. Verified against the table on 26.3,
that is wrong: ClickHouse trims the NUL padding when casting FixedString
to String, so LENGTH(CAST(parent_span_id AS Nullable(String))) still
reads 0 for the sentinel and the DAO predicates behave exactly as they
do on the live String column. A bare LENGTH would be 36, so the cast has
to stay.What is affected is the read path: the driver hands Java the padded form
as 36 NUL characters, which is not blank, so a !isBlank() guard lets it
through and UUID.fromString throws. Reads must go through
SentinelTranslation.emptyUuidToNull.Both facts are now pinned by a test so the cutover story can rely on
them instead of re-deriving them.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] fix: drop the false lossless claim from the timestamp narrowing note
created_at defaults to now64(9) and the ingest path binds nanoseconds,
so sub-microsecond digits do reach the DB: 1,998,018 of a 2M-row prod
sample carry them on created_at. The narrowing is still intended, but it
truncates rather than being a no-op, so say that instead.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] refactor: store id_at as DateTime64(3) to keep the millisecond the id encodes
UUIDv7ToDateTime returns DateTime64(3), so declaring id_at as DateTime
made the materialized expression a narrowing cast that dropped the
millisecond UUIDv7 carries. DateTime64(3) keeps it and matches the
function's own type, at 8 bytes instead of 4.toMonday still yields a Date, so the weekly partition expression and its
partition ids are unchanged.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] docs: note id_at as the exception to the microsecond timestamps
The header claimed every timestamp is DateTime64(6); id_at is
DateTime64(3) since the last change.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] refactor: apply review feedback on the spans_local_v2 schema and tests
Schema:
- model, provider and total_estimated_cost_version become
LowCardinality(String); a 4M-row prod sample holds 53, 14 and 2
distinct values. Their codec follows environment's ZSTD(1). - id_at drops to DateTime64(0): a weekly partition and the id-range
filters need whole seconds only. DateTime64 rather than DateTime is
about range, not precision, since DateTime wraps past 2106-02-07 and
prod holds ids dated 2199. - Add idx_spans_id_bf, a bloom filter on id, for traces parity: minmax
serves id ranges, the filter serves exact-match lookups. The minmax
index is renamed idx_spans_id_minmax to match.
SentinelTranslation gains the two UUID helpers the cutover read and
write paths will need, with coverage: emptyUuidToNullableUuid, which
folds the sentinel check into the parse, and a nullToEmptyUuid(UUID)
overload.Tests:
- Drop showCreateTableMatchesSpec, which asserted ClickHouse and
Liquibase internals rather than a contract. - getById uses asterisk_include_materialized_columns instead of
repeating the DDL's materialized column list. - randomCost varies the whole-unit part and all twelve fractional
digits, not just the trailing nine. - Cover an id past the DateTime ceiling, and id_at's second precision.
- Cover the plain CAST AS String, not only the Nullable form.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] refactor: drop parent_span_id from the sort key, index it instead
The query-pattern audit settles it: parent_span_id is not a SpanField,
so it cannot be filtered through the API, and 0 of 162,172 span queries
in a 7-day prod sample carry a predicate on it, against 94,238 that
filter trace_id. With ~1.7 distinct parents per trace it barely
subdivided a trace's ~4.5 spans. trace_id stays for the same reason it
was measured: it is the filter that actually gets used.Dedup identity is unchanged, since id alone is unique per span. Dropping
it also removes a hazard: parent_span_id is mutable, and a mutable
sort-key column breaks ReplacingMergeTree dedup, which is what forces
the deliberate poison-pill CAST in SpanDAO's upsert.The children-of-a-span path moves to a bloom filter on the column, since
equality is the only useful predicate on a UUID-or-sentinel field.SpanDAO's LIMIT 1 BY tiebreakers still target the live table and are
untouched; swapping them is cutover work.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] docs: correct the stale id_at precision comment on the column
The header bullet was updated when id_at moved to DateTime64(0) but the
inline comment above the column still claimed DateTime64(3) kept the
millisecond.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] refactor: express SentinelTranslation through Optional
Every translation is the same shape — take the value, reject the absent
forms, fall back — so Optional states it directly instead of each method
spelling out its own null and sentinel checks.Signatures are unchanged, so the ~40 call sites across the DAOs are
untouched, and the existing unit tests pin the behaviour as identical.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] chore: renumber the spans_local_v2 migration to 000114
main has since taken 000112 (add_subcategory_to_cipx_spend_blocks) and 000113
(add_id_bloom_filter_index_to_traces), and the migration-prefix CI check rejects a duplicate
prefix. Renamed the file and its changeset id to 000114, the next free number. Liquibase
discovers this directory with includeAll and executes in filename order, so no changelog
edit is needed, and nothing has run this changeset anywhere yet.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] fix: partition spans_local_v2 on the honest Date32 week
Replicates the traces_local_v2 fix from OPIK_7456 (#7690), which landed
after this table was written. toMonday / toStartOfWeek return a 16-bit
Date that wraps at both ends: a far-future id (litellm mints ~2201, and
prod already holds ids dated 2199) folds forward into a plausible recent
week, and an epoch id_at — what UUIDv7ToDateTime returns for any non-v7
id, which is commoner — underflows backward to ~2149. Those rows are
legitimate customer data, so mixing them into a real week means a
per-week DROP PARTITION, retention or tiering pass touches them and real
rows together.The Date32 arithmetic computes the same Monday across the in-range
calendar, never wraps, needs no server setting, and through toYYYYMMDD
stays a UInt32 so the partition id remains a readable YYYYMMDD.
enable_extended_results_for_datetime_functions would fix toMonday
instead, but it also changes toStartOfInterval and breaks the metrics
API, so it is not an option.id_at was already DateTime64(0) here, so unlike traces this table needs
no recreate: the column type and the key expression are both right at
creation, which is the only moment either can be set.SpanDAO's toMonday(id_at) read predicates stay as they are. They prune
via the id_at minmax ClickHouse keeps for the partition-key columns,
independent of the key expression.The migration also moves to 000115, since #7690 took 000114 on main.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] test: name the far-future fixture for what it is
badId read as invalid input, but the row is legitimate customer data
that merely carries a future timestamp — the whole point of the guard.Also records why the id_at assertions are not redundant with
farFutureIdBeyondDateTimeCeilingKeepsItsInstant: the expected Monday
comes from the value read back, so without anchoring that value the
partition assertion would pass just as happily if id_at and the
partition wrapped together.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7398] [BE] test: assert raw sentinels and key on the full sort key
keyColumnsOnlyRowTakesEveryDefault is a raw-column test, so it now
asserts the stored epoch and NaN directly instead of routing them
through SentinelTranslation to null. allAbsentColumnsRoundTripAsNull
already pins the outside view, so the translation assertion here was
restating that test rather than checking what the columns hold.The two read helpers also filter on trace_id, so the WHERE matches the
full (workspace_id, project_id, trace_id, id) sort key instead of
skipping a prefix column.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Co-authored-by: Claude Opus 5 (1M context) noreply@anthropic.com
下载附件