Skip to content

fix(ai-cost): scope the invoice layers' incremental boundary to one source - #2621

Merged
Gregory91G merged 1 commit into
mainfrom
fix-invoice-class-watermark
Aug 18, 2026
Merged

fix(ai-cost): scope the invoice layers' incremental boundary to one source#2621
Gregory91G merged 1 commit into
mainfrom
fix-invoice-class-watermark

Conversation

@Gregory91G

Copy link
Copy Markdown
Contributor

Closes #2620. Follow-up to #2607, which #2432 crossed with in flight.

#2607 replaced the table-wide _version > max(_version) on the AI silver classes with
silver_incremental_watermark, and taught the convention checker to forbid the old form.
#2432 landed the invoice path a few hours later from a branch that predated that fix, so
class_ai_invoice is the last AI class carrying the shape #2607 removed. This restores it
to the convention.

For the record, since the ordering is what caused this: #2607 is not a new connector — it
fixes the existing Claude Team path. #2432 did not disagree with it; it was simply written
first and merged second.

The defect, in #2607's own words

A class table is written by every connector that feeds it, and each runs on its own
schedule. _version is a timestamp whose meaning differs per producer […] so a single
max(_version) over the whole table lets whichever producer commits first raise the
boundary above another producer's rows. Those rows are then below it forever: nothing
re-reads them, and until now nothing reported the loss either.

That precondition is this class's stated purpose — "Unified vendor invoices across AI
vendors
", with the staging model documenting itself as defining the contract future
vendors emit into.

Nothing is being lost today: one contributor means the table-wide maximum is its own.
This is preventive.

Two layers

silver/ai/class_ai_invoice.sql is the obvious half. The other is
claude_team__ai_invoice.sql, whose boundary is the same shape over collected_at.

Fixing only the class would change nothing observable — a second instance's rows are dropped
at staging before silver ever sees them. Two instances of one connector do write to one
staging table: that is why insight_source_id is part of every unique_key, and why
check_connection refuses an empty one with "would collide unique_keys across connector
instances".

Both layers now take the shared macro rather than a second implementation of it. Staging's
empty-table guard goes with the change: coalesce to the epoch already admits an instance
the table has never seen, which is exactly what the guard stood in for.

Test plan

Automated, run:

  • metrics/test_ai_invoice_silver.py + metrics/test_ai_seat_extra_usage_history.py
    13 passed against a stand built from wiped volumes.
  • The new case has teeth. It seeds a second source instance read before everything
    the class already holds — the shape a per-table boundary silently drops — and reverting
    either layer alone fails it with assert [] == ['in_SECOND']. Both reverts were run over
    the full module: with a -k filter the earlier fixtures never run, staging is empty, and
    the empty-table guard hides the bug.
  • ruff check + ruff format --check at the pinned version — clean.
  • connectors-ddl snapshot unchanged, and expected to be: the column list is untouched
    and the snapshots hold bronze DDL only.

Manual, needs a stand — please tick these yourself:

  • After a deploy, confirm staging.claude_team__ai_invoice and silver.class_ai_invoice
    keep accepting rows on the next scheduled sync (the boundary changes shape, not meaning,
    so an in-place upgrade needs no full refresh).

Not in this PR

assert_ai_staging_rows_reach_silver — the completeness check #2607 added to report exactly
this loss — cannot run: its ref() calls sit inside a loop over
materialised_models_for_tag, which is empty at parse time, so dbt records no dependencies
and fails the test at run time with "unable to infer all dependencies … ref() is placed
within a conditional block". Reproduced against a live stand with
dbt test --selector data_quality.

class_ai_invoice is deliberately not added to its list: a fourth entry would only widen
the set of stands whose scheduled data-quality run turns red. Written up in #2620 for whoever
owns the check — -- depends_on: hints cannot be written for refs chosen at run time, so it
needs a design call rather than a one-liner.

…ource

#2607 replaced the table-wide `_version > max(_version)` on the AI silver classes
with `silver_incremental_watermark`, because a class is written by every connector
feeding it and each producer stamps `_version` from its own clock: whichever
commits first raises the boundary above another producer's rows, and those rows
stay below it forever, silently. `class_ai_invoice` landed from a branch that
predated that fix, so it is the only AI class still carrying the pattern.

The same boundary sits one layer down, in `claude_team__ai_invoice`, and fixing
only the class would have changed nothing observable: a second instance's rows are
dropped at staging before silver ever sees them. Two instances of one connector do
write to one staging table — that is why `insight_source_id` is part of every
unique_key and why `check_connection` refuses an empty one. Both layers now take
the same macro, rather than a second implementation of it, and staging's
empty-table guard goes with it: a `coalesce` to the epoch already admits an
instance the table has never seen, which is what the guard stood in for.

The e2e gains a second source instance read BEFORE everything the class already
holds — the shape a per-table boundary silently drops. Reverting either layer
alone fails it.

`assert_ai_staging_rows_reach_silver` deliberately does NOT gain this class. Its
`ref()` calls sit inside a loop over `materialised_models_for_tag`, which is empty
at parse time, so dbt records no dependencies and the test errors with "unable to
infer all dependencies" on any deployment where a listed class has a materialised
contributor. Adding a fourth entry would only widen the set of stands where a
scheduled data-quality run turns red.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
@Gregory91G
Gregory91G requested a review from a team as a code owner August 18, 2026 03:23
@Gregory91G Gregory91G added component:ingestion dbt / approved connector scope Connector Individual connector labels Aug 18, 2026
@Gregory91G Gregory91G self-assigned this Aug 18, 2026
@Gregory91G
Gregory91G requested a review from cyberantonz August 18, 2026 03:23
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review available on request

  • 🔍 Trigger review

Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment @coderabbitai review to review the latest changes. For a full review, comment @coderabbitai full review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf9579d5-a335-46e9-bc19-967253f7717c


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Gregory91G
Gregory91G added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 9c37d3e Aug 18, 2026
62 checks passed
@Gregory91G
Gregory91G deleted the fix-invoice-class-watermark branch August 18, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component:ingestion dbt / approved connector scope Connector Individual connector

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Only the first connected AI billing account's invoices are counted in AI cost metrics

2 participants