multica/server/migrations/082_rollup_runtime_timezone...

244 lines
10 KiB
PL/PgSQL

-- Re-bucket the rollup pipeline by the owning runtime's IANA timezone
-- (added in 081), instead of the Postgres session timezone (which is
-- effectively UTC in production).
--
-- This affects three places that all derive a bucket_date from
-- `task_usage.created_at`:
--
-- 1. enqueue_task_usage_daily_dirty_for_atq() — trigger that enqueues
-- dirty buckets when a task_usage's atq row changes runtime or
-- gets deleted.
-- 2. enqueue_task_usage_daily_dirty_for_tu() — trigger that enqueues
-- dirty buckets when a task_usage row is deleted directly.
-- 3. rollup_task_usage_daily_window() — the cron-driven window
-- function that drains dirty keys and recomputes their buckets.
--
-- All three previously called bare `DATE(tu.created_at)`. They now call
-- `DATE(tu.created_at AT TIME ZONE rt.timezone)`, joining `agent_runtime
-- rt` along the existing path to atq.runtime_id. Skipping any one of
-- them would split the bucket key between writers (triggers / window)
-- and the rollup table itself.
--
-- *** NO HISTORICAL BACKFILL ***
-- This migration changes only the function bodies, not any data. Rows
-- already present in `task_usage_daily` keep their previously computed
-- (UTC) `bucket_date` values. From the moment this migration ships,
-- *future* writes / re-touches of any bucket recompute the date under
-- the runtime's tz. For runtimes that stay on `'UTC'` (the column
-- default) this is a no-op; for runtimes whose operators set a non-UTC
-- tz, dates only converge as their underlying raw rows get re-touched
-- by new events. The product decision (MUL-1950) was "guarantee future
-- correctness, do not backfill history".
CREATE OR REPLACE FUNCTION enqueue_task_usage_daily_dirty_for_atq()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
IF OLD.runtime_id IS DISTINCT FROM NEW.runtime_id THEN
IF OLD.runtime_id IS NOT NULL THEN
INSERT INTO task_usage_daily_dirty (bucket_date, workspace_id, runtime_id, provider, model)
SELECT DISTINCT
DATE(tu.created_at AT TIME ZONE rt.timezone),
a.workspace_id,
OLD.runtime_id,
tu.provider,
tu.model
FROM task_usage tu
JOIN agent a ON a.id = OLD.agent_id
JOIN agent_runtime rt ON rt.id = OLD.runtime_id
WHERE tu.task_id = OLD.id
ON CONFLICT (bucket_date, workspace_id, runtime_id, provider, model) DO UPDATE
SET enqueued_at = GREATEST(task_usage_daily_dirty.enqueued_at, EXCLUDED.enqueued_at);
END IF;
IF NEW.runtime_id IS NOT NULL THEN
INSERT INTO task_usage_daily_dirty (bucket_date, workspace_id, runtime_id, provider, model)
SELECT DISTINCT
DATE(tu.created_at AT TIME ZONE rt.timezone),
a.workspace_id,
NEW.runtime_id,
tu.provider,
tu.model
FROM task_usage tu
JOIN agent a ON a.id = NEW.agent_id
JOIN agent_runtime rt ON rt.id = NEW.runtime_id
WHERE tu.task_id = NEW.id
ON CONFLICT (bucket_date, workspace_id, runtime_id, provider, model) DO UPDATE
SET enqueued_at = GREATEST(task_usage_daily_dirty.enqueued_at, EXCLUDED.enqueued_at);
END IF;
END IF;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
IF OLD.runtime_id IS NOT NULL THEN
INSERT INTO task_usage_daily_dirty (bucket_date, workspace_id, runtime_id, provider, model)
SELECT DISTINCT
DATE(tu.created_at AT TIME ZONE rt.timezone),
a.workspace_id,
OLD.runtime_id,
tu.provider,
tu.model
FROM task_usage tu
JOIN agent a ON a.id = OLD.agent_id
JOIN agent_runtime rt ON rt.id = OLD.runtime_id
WHERE tu.task_id = OLD.id
ON CONFLICT (bucket_date, workspace_id, runtime_id, provider, model) DO UPDATE
SET enqueued_at = GREATEST(task_usage_daily_dirty.enqueued_at, EXCLUDED.enqueued_at);
END IF;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION enqueue_task_usage_daily_dirty_for_tu()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO task_usage_daily_dirty (bucket_date, workspace_id, runtime_id, provider, model)
SELECT DATE(OLD.created_at AT TIME ZONE rt.timezone),
a.workspace_id,
atq.runtime_id,
OLD.provider,
OLD.model
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
JOIN agent_runtime rt ON rt.id = atq.runtime_id
WHERE atq.id = OLD.task_id
AND atq.runtime_id IS NOT NULL
ON CONFLICT (bucket_date, workspace_id, runtime_id, provider, model) DO UPDATE
SET enqueued_at = GREATEST(task_usage_daily_dirty.enqueued_at, EXCLUDED.enqueued_at);
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION rollup_task_usage_daily_window(
p_from TIMESTAMPTZ,
p_to TIMESTAMPTZ
)
RETURNS BIGINT
LANGUAGE plpgsql
AS $$
DECLARE
v_rows BIGINT;
BEGIN
IF p_from >= p_to THEN
RETURN 0;
END IF;
WITH
-- Source 1: rows with updated_at in this window (steady state) plus
-- the legacy-row OR branch for NULL updated_at (covered by partial
-- index idx_task_usage_created_at_legacy from migration 078).
--
-- bucket_date is now derived under each runtime's IANA timezone via
-- AT TIME ZONE rt.timezone. agent_runtime joins through atq.runtime_id
-- which is already required NOT NULL in the same WHERE clause.
dirty_from_updates AS (
SELECT DISTINCT
DATE(tu.created_at AT TIME ZONE rt.timezone) AS bucket_date,
a.workspace_id AS workspace_id,
atq.runtime_id AS runtime_id,
tu.provider AS provider,
tu.model AS model
FROM task_usage tu
JOIN agent_task_queue atq ON atq.id = tu.task_id
JOIN agent a ON a.id = atq.agent_id
JOIN agent_runtime rt ON rt.id = atq.runtime_id
WHERE atq.runtime_id IS NOT NULL
AND (
(tu.updated_at >= p_from AND tu.updated_at < p_to)
OR (tu.updated_at IS NULL
AND tu.created_at >= p_from
AND tu.created_at < p_to)
)
),
-- Source 2: explicit invalidation queue (deletes + reassignments).
-- The queue rows already carry bucket_date computed under each
-- runtime's tz at trigger time, so we don't translate again here.
dirty_from_queue AS (
SELECT bucket_date, workspace_id, runtime_id, provider, model
FROM task_usage_daily_dirty
WHERE enqueued_at < p_to
),
dirty_keys AS (
SELECT * FROM dirty_from_updates
UNION
SELECT * FROM dirty_from_queue
),
-- Recompute each dirty bucket from ground truth. The bucket_date
-- predicate uses the runtime's tz so it matches dirty_from_updates
-- and the trigger functions byte-for-byte.
recomputed AS (
SELECT
dk.bucket_date,
dk.workspace_id,
dk.runtime_id,
dk.provider,
dk.model,
SUM(tu.input_tokens)::bigint AS input_tokens,
SUM(tu.output_tokens)::bigint AS output_tokens,
SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens,
SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens,
COUNT(*)::bigint AS event_count
FROM dirty_keys dk
JOIN agent_runtime rt ON rt.id = dk.runtime_id
JOIN agent_task_queue atq ON atq.runtime_id = dk.runtime_id
JOIN agent a ON a.id = atq.agent_id
AND a.workspace_id = dk.workspace_id
JOIN task_usage tu ON tu.task_id = atq.id
AND tu.provider = dk.provider
AND tu.model = dk.model
AND DATE(tu.created_at AT TIME ZONE rt.timezone) = dk.bucket_date
GROUP BY 1, 2, 3, 4, 5
),
-- REPLACE present buckets.
upserted AS (
INSERT INTO task_usage_daily AS d (
bucket_date, workspace_id, runtime_id, provider, model,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
event_count
)
SELECT
bucket_date, workspace_id, runtime_id, provider, model,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
event_count
FROM recomputed
ON CONFLICT (bucket_date, workspace_id, runtime_id, provider, model) DO UPDATE
SET input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
cache_write_tokens = EXCLUDED.cache_write_tokens,
event_count = EXCLUDED.event_count,
updated_at = now()
RETURNING 1
),
-- DELETE buckets that are dirty but have no source rows anymore.
deleted_empty AS (
DELETE FROM task_usage_daily d
USING dirty_keys dk
WHERE d.bucket_date = dk.bucket_date
AND d.workspace_id = dk.workspace_id
AND d.runtime_id = dk.runtime_id
AND d.provider = dk.provider
AND d.model = dk.model
AND NOT EXISTS (
SELECT 1 FROM recomputed r
WHERE r.bucket_date = dk.bucket_date
AND r.workspace_id = dk.workspace_id
AND r.runtime_id = dk.runtime_id
AND r.provider = dk.provider
AND r.model = dk.model
)
RETURNING 1
)
SELECT (SELECT COUNT(*) FROM upserted) + (SELECT COUNT(*) FROM deleted_empty)
INTO v_rows;
DELETE FROM task_usage_daily_dirty WHERE enqueued_at < p_to;
RETURN v_rows;
END;
$$;