Skip to content

Commit b823be1

Browse files
jopemachineclaude
andcommitted
fix(BA-6927): stop cross-multiplying usage bucket amounts and durations
FairShareAggregator accumulated the raw resource amounts and the slice durations of a bucket separately and multiplied them afterwards, yielding (sum amount_k) * (sum duration_k) instead of sum(amount_k * duration_k). The cross product inflates a bucket by the number of kernel slices folded into it, so a user running 4 GPU kernels saw 4x their real fGPU-seconds and project/domain buckets were off by more, since they aggregate the kernels of every user beneath them. BucketDelta now carries resource-seconds directly, multiplied per slice before accumulation, and the three repository call sites no longer re-multiply by the accumulated duration. usage_bucket_entries.amount is renamed to resource_seconds to match what it holds; its precision widens to 32 digits because a domain-level daily mem bucket can exceed the previous 1e18 ceiling. duration_seconds stays as a reporting-only total and is never multiplied with resource_seconds. Existing buckets cannot be corrected in place, in either the JSONB mirror or the normalized entries, so the migration rebuilds both from kernel_usage_records, which stores per-slice resource-seconds and was never affected. The oldest retained day is excluded because retention purges kernel records by period_end and may have truncated it; buckets older than the kernel record retention window keep their inflated values rather than being silently zeroed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1b14eef commit b823be1

10 files changed

Lines changed: 452 additions & 118 deletions

File tree

src/ai/backend/manager/data/fair_share/types.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,15 +417,24 @@ class DomainUsageBucketKey:
417417

418418
@dataclass
419419
class BucketDelta:
420-
"""Separated resource amount and duration for a usage bucket.
420+
"""Accumulated resource-seconds and observation duration for a usage bucket.
421421
422-
Stores raw resource amounts and duration separately instead of
423-
pre-multiplied resource-seconds. The product ``amount * duration_seconds``
424-
is computed at SQL query time where PostgreSQL auto-extends NUMERIC precision,
425-
eliminating overflow risk for large memory values.
422+
``resource_seconds`` is the time-weighted total ``sum(amount_k * duration_k)``
423+
over every observation slice folded into this bucket, and is the value the
424+
usage bucket tables store.
425+
426+
It MUST be accumulated as a sum of per-slice products. Accumulating the raw
427+
amounts and the durations separately and multiplying them afterwards yields
428+
``(sum amount_k) * (sum duration_k)``, a cross product that inflates the
429+
result by the number of slices merged into the bucket -- i.e. by the number
430+
of concurrently running kernels.
431+
432+
``duration_seconds`` is the total observed duration (kernel-seconds, so
433+
concurrent kernels each contribute their own slice length). It is carried
434+
for reporting only and is never multiplied with ``resource_seconds``.
426435
"""
427436

428-
slots: ResourceSlot = field(default_factory=ResourceSlot)
437+
resource_seconds: ResourceSlot = field(default_factory=ResourceSlot)
429438
duration_seconds: int = 0
430439

431440

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""fix usage bucket cross-product inflation
2+
3+
Renames ``usage_bucket_entries.amount`` to ``resource_seconds`` and rebuilds the
4+
corrupted aggregates from ``kernel_usage_records``.
5+
6+
The aggregator used to accumulate the raw resource amounts and the slice
7+
durations into a bucket separately and multiply them afterwards, producing
8+
``(sum amount_k) * (sum duration_k)`` instead of ``sum(amount_k * duration_k)``.
9+
That cross product inflates every bucket by the number of kernel slices folded
10+
into it, so a user running 4 kernels saw 4x their real usage and project/domain
11+
buckets were off by even more.
12+
13+
Both representations are affected: the JSONB ``resource_usage`` column on the
14+
three bucket tables and the normalized ``usage_bucket_entries`` rows. Neither
15+
can be corrected in place, so both are rebuilt from ``kernel_usage_records``,
16+
which stores per-slice resource-seconds and was never affected.
17+
18+
Revision ID: c4a91d7e05b2
19+
Revises: 3f9a1c7b2e04
20+
Create Date: 2026-07-20 00:00:00.000000
21+
22+
"""
23+
24+
from datetime import date, timedelta
25+
26+
import sqlalchemy as sa
27+
from alembic import op
28+
29+
# revision identifiers, used by Alembic.
30+
revision = "c4a91d7e05b2"
31+
down_revision = "3f9a1c7b2e04"
32+
# Part of: NEXT_RELEASE_VERSION
33+
branch_labels = None
34+
depends_on = None
35+
36+
37+
# Bucket tables keyed by the columns that identify the owning entity. Every key
38+
# column has the same name in kernel_usage_records, so the join is by name.
39+
_BUCKET_LEVELS: list[tuple[str, str, list[str]]] = [
40+
("user_usage_buckets", "user", ["user_uuid", "project_id", "resource_group"]),
41+
("project_usage_buckets", "project", ["project_id", "resource_group"]),
42+
("domain_usage_buckets", "domain", ["domain_name", "resource_group"]),
43+
]
44+
45+
46+
def upgrade() -> None:
47+
op.alter_column(
48+
"usage_bucket_entries",
49+
"amount",
50+
new_column_name="resource_seconds",
51+
existing_type=sa.Numeric(precision=24, scale=6),
52+
type_=sa.Numeric(precision=32, scale=6),
53+
existing_nullable=False,
54+
)
55+
56+
conn = op.get_bind()
57+
covered = _covered_date_range(conn)
58+
if covered is None:
59+
# No usage records to rebuild from (fresh install, or everything purged).
60+
return
61+
rebuild_from, rebuild_to = covered
62+
for table_name, bucket_type, key_columns in _BUCKET_LEVELS:
63+
_rebuild_buckets(conn, table_name, bucket_type, key_columns, rebuild_from, rebuild_to)
64+
65+
66+
def downgrade() -> None:
67+
# The rebuilt values are correct resource-seconds; reverting the rename keeps
68+
# them, since the pre-fix column held a (differently scaled) usage figure too.
69+
op.alter_column(
70+
"usage_bucket_entries",
71+
"resource_seconds",
72+
new_column_name="amount",
73+
existing_type=sa.Numeric(precision=32, scale=6),
74+
type_=sa.Numeric(precision=24, scale=6),
75+
existing_nullable=False,
76+
)
77+
78+
79+
def _covered_date_range(conn: sa.engine.Connection) -> tuple[date, date] | None:
80+
"""Return the date range that kernel_usage_records can faithfully rebuild.
81+
82+
The oldest retained day is excluded: retention purges kernel usage records by
83+
``period_end``, so the boundary day may be partially purged and would rebuild
84+
to an under-counted value. Buckets outside this range keep their inflated
85+
values rather than being silently zeroed -- they cannot be recovered, and
86+
destroying them would lose the only usage history that remains.
87+
"""
88+
row = conn.execute(
89+
sa.text(
90+
"SELECT min((period_start AT TIME ZONE 'UTC')::date) AS min_date, "
91+
" max((period_start AT TIME ZONE 'UTC')::date) AS max_date "
92+
"FROM kernel_usage_records"
93+
)
94+
).one()
95+
if row.min_date is None or row.max_date is None:
96+
return None
97+
rebuild_from = row.min_date + timedelta(days=1)
98+
if rebuild_from > row.max_date:
99+
return None
100+
return rebuild_from, row.max_date
101+
102+
103+
def _rebuild_buckets(
104+
conn: sa.engine.Connection,
105+
table_name: str,
106+
bucket_type: str,
107+
key_columns: list[str],
108+
rebuild_from: date,
109+
rebuild_to: date,
110+
) -> None:
111+
"""Recompute one bucket level's entries and JSONB from kernel_usage_records."""
112+
key_list = ", ".join(key_columns)
113+
join_on = " AND ".join(f"b.{col} = agg.{col}" for col in key_columns)
114+
params = {"rebuild_from": rebuild_from, "rebuild_to": rebuild_to}
115+
116+
# Per-slot resource-seconds, summed over every kernel slice of the day.
117+
agg_cte = f"""
118+
WITH agg AS (
119+
SELECT {key_list},
120+
(period_start AT TIME ZONE 'UTC')::date AS period_date,
121+
kv.key AS slot_name,
122+
SUM(kv.value::numeric) AS resource_seconds
123+
FROM kernel_usage_records,
124+
LATERAL jsonb_each_text(resource_usage) AS kv
125+
WHERE (period_start AT TIME ZONE 'UTC')::date
126+
BETWEEN :rebuild_from AND :rebuild_to
127+
GROUP BY {key_list}, period_date, kv.key
128+
)
129+
"""
130+
131+
# Drop the stale entries for the rebuilt window; capacity is refilled by the
132+
# next fair share observation tick (update_bucket_entry_capacities).
133+
conn.execute(
134+
sa.text(
135+
f"""
136+
DELETE FROM usage_bucket_entries e
137+
USING {table_name} b
138+
WHERE e.bucket_id = b.id
139+
AND e.bucket_type = :bucket_type
140+
AND b.period_start BETWEEN :rebuild_from AND :rebuild_to
141+
"""
142+
),
143+
{**params, "bucket_type": bucket_type},
144+
)
145+
conn.execute(
146+
sa.text(
147+
f"""
148+
{agg_cte}
149+
INSERT INTO usage_bucket_entries
150+
(bucket_id, bucket_type, slot_name, resource_seconds, duration_seconds, capacity)
151+
SELECT b.id, :bucket_type, agg.slot_name, agg.resource_seconds, 0, 0
152+
FROM agg
153+
JOIN {table_name} b
154+
ON {join_on}
155+
AND b.period_start = agg.period_date
156+
ON CONFLICT (bucket_id, slot_name) DO UPDATE
157+
SET resource_seconds = EXCLUDED.resource_seconds
158+
"""
159+
),
160+
{**params, "bucket_type": bucket_type},
161+
)
162+
163+
# Rebuild the JSONB mirror on the parent bucket rows. Buckets in the window
164+
# with no matching kernel records collapse to an empty slot map.
165+
conn.execute(
166+
sa.text(
167+
f"""
168+
{agg_cte},
169+
per_bucket AS (
170+
SELECT {key_list}, period_date,
171+
jsonb_object_agg(slot_name, resource_seconds) AS usage
172+
FROM agg
173+
GROUP BY {key_list}, period_date
174+
)
175+
UPDATE {table_name} b
176+
SET resource_usage = COALESCE(agg.usage, '{{}}'::jsonb)
177+
FROM per_bucket agg
178+
WHERE {join_on}
179+
AND b.period_start = agg.period_date
180+
AND b.period_start BETWEEN :rebuild_from AND :rebuild_to
181+
"""
182+
),
183+
params,
184+
)
185+
conn.execute(
186+
sa.text(
187+
f"""
188+
UPDATE {table_name} b
189+
SET resource_usage = '{{}}'::jsonb
190+
WHERE b.period_start BETWEEN :rebuild_from AND :rebuild_to
191+
AND NOT EXISTS (
192+
SELECT 1 FROM usage_bucket_entries e
193+
WHERE e.bucket_id = b.id AND e.bucket_type = :bucket_type
194+
)
195+
"""
196+
),
197+
{**params, "bucket_type": bucket_type},
198+
)

src/ai/backend/manager/models/resource_usage_history/row.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -442,10 +442,11 @@ class UserUsageBucketRow(LifecycleTimestampsMixin, Base): # type: ignore[misc]
442442
class UsageBucketEntryRow(Base): # type: ignore[misc]
443443
"""Per-slot normalized entry for usage bucket aggregation (Phase 3).
444444
445-
Stores amount and duration separately instead of pre-multiplied resource-seconds,
446-
eliminating overflow risk for large memory values.
447-
The product ``amount * duration_seconds`` is computed at SQL query time
448-
where PostgreSQL auto-extends NUMERIC precision.
445+
``resource_seconds`` is the time-weighted total ``sum(amount_k * duration_k)``
446+
over every observation slice folded into this bucket, and is what the read
447+
paths sum. ``duration_seconds`` is the total observed duration, kept for
448+
reporting only -- the two columns are never multiplied together, since both
449+
are already sums and their product would be a cross product.
449450
450451
One entry per (bucket_id, slot_name). ``bucket_type`` is a discriminator
451452
indicating which parent table (domain/project/user_usage_buckets) owns
@@ -457,8 +458,8 @@ class UsageBucketEntryRow(Base): # type: ignore[misc]
457458
bucket_id: Mapped[uuid.UUID] = mapped_column("bucket_id", GUID(), nullable=False)
458459
bucket_type: Mapped[str] = mapped_column("bucket_type", sa.String(length=16), nullable=False)
459460
slot_name: Mapped[str] = mapped_column("slot_name", sa.String(length=64), nullable=False)
460-
amount: Mapped[Decimal] = mapped_column(
461-
"amount", sa.Numeric(precision=24, scale=6), nullable=False
461+
resource_seconds: Mapped[Decimal] = mapped_column(
462+
"resource_seconds", sa.Numeric(precision=32, scale=6), nullable=False
462463
)
463464
duration_seconds: Mapped[int] = mapped_column("duration_seconds", sa.Integer(), nullable=False)
464465
capacity: Mapped[Decimal] = mapped_column(

src/ai/backend/manager/repositories/fair_share/db_source/db_source.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,7 +1585,7 @@ async def _fetch_raw_usage_buckets(
15851585
UserUsageBucketRow.project_id,
15861586
UserUsageBucketRow.period_start,
15871587
ube.c.slot_name,
1588-
ube.c.amount,
1588+
ube.c.resource_seconds,
15891589
)
15901590
.select_from(
15911591
sa.join(
@@ -1612,7 +1612,7 @@ async def _fetch_raw_usage_buckets(
16121612
ProjectUsageBucketRow.project_id,
16131613
ProjectUsageBucketRow.period_start,
16141614
ube.c.slot_name,
1615-
ube.c.amount,
1615+
ube.c.resource_seconds,
16161616
)
16171617
.select_from(
16181618
sa.join(
@@ -1639,7 +1639,7 @@ async def _fetch_raw_usage_buckets(
16391639
DomainUsageBucketRow.domain_name,
16401640
DomainUsageBucketRow.period_start,
16411641
ube.c.slot_name,
1642-
ube.c.amount,
1642+
ube.c.resource_seconds,
16431643
)
16441644
.select_from(
16451645
sa.join(
@@ -1668,7 +1668,7 @@ async def _fetch_raw_usage_buckets(
16681668
user_buckets[key] = {}
16691669
if row.period_start not in user_buckets[key]:
16701670
user_buckets[key][row.period_start] = ResourceSlot()
1671-
user_buckets[key][row.period_start][row.slot_name] = Decimal(str(row.amount))
1671+
user_buckets[key][row.period_start][row.slot_name] = Decimal(str(row.resource_seconds))
16721672

16731673
project_buckets: dict[uuid.UUID, dict[date, ResourceSlot]] = {}
16741674
for row in project_rows:
@@ -1677,7 +1677,7 @@ async def _fetch_raw_usage_buckets(
16771677
if row.period_start not in project_buckets[row.project_id]:
16781678
project_buckets[row.project_id][row.period_start] = ResourceSlot()
16791679
project_buckets[row.project_id][row.period_start][row.slot_name] = Decimal(
1680-
str(row.amount)
1680+
str(row.resource_seconds)
16811681
)
16821682

16831683
domain_buckets: dict[str, dict[date, ResourceSlot]] = {}
@@ -1687,7 +1687,7 @@ async def _fetch_raw_usage_buckets(
16871687
if row.period_start not in domain_buckets[row.domain_name]:
16881688
domain_buckets[row.domain_name][row.period_start] = ResourceSlot()
16891689
domain_buckets[row.domain_name][row.period_start][row.slot_name] = Decimal(
1690-
str(row.amount)
1690+
str(row.resource_seconds)
16911691
)
16921692

16931693
return RawUsageBucketsByLevel(

0 commit comments

Comments
 (0)