mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 09:27:46 +08:00
feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend. Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users. Include the current documentation cleanup and regression coverage.
This commit is contained in:
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
description = "PostgreSQL repositories, pools, transactions, and migrations for Aether"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
aether-ai-formats.workspace = true
|
||||
aether-data-contracts.workspace = true
|
||||
aether-data-query.workspace = true
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
-- Runs before 20260403000000_baseline.sql so that fresh databases have a
|
||||
-- complete schema by the time baseline (a no-op handoff point) and all
|
||||
-- later ADD COLUMN IF NOT EXISTS migrations execute.
|
||||
SET statement_timeout = 0;
|
||||
|
||||
SET lock_timeout = 0;
|
||||
|
||||
-- Keep the migration runner's statement and lock deadlines in effect.
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
|
||||
SET client_encoding = 'UTF8';
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
-- Final overview schema and account attribution; no historical usage is backfilled.
|
||||
-- Install transaction-owned dirty events before any later concurrent index build.
|
||||
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_origin text;
|
||||
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_stage text;
|
||||
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_reason text;
|
||||
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_schema_version integer;
|
||||
ALTER TABLE public.usage_settlement_snapshots
|
||||
ADD COLUMN IF NOT EXISTS quota_covered_amount_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS wallet_consumed_amount_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS wallet_debit_amount_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS wallet_recharge_debit_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS wallet_gift_debit_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS wallet_overdraft_usd numeric(20,8),
|
||||
ADD COLUMN IF NOT EXISTS allocation_schema_version integer,
|
||||
ADD COLUMN IF NOT EXISTS allocation_status text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots (
|
||||
request_id text PRIMARY KEY,
|
||||
actor_user_id text,
|
||||
credential_owner_id text,
|
||||
attribution_kind text NOT NULL DEFAULT 'unknown',
|
||||
attribution_source text NOT NULL DEFAULT 'unknown',
|
||||
record_kind text NOT NULL DEFAULT 'request',
|
||||
parent_request_id text,
|
||||
schema_version integer NOT NULL DEFAULT 1,
|
||||
attribution_revision bigint NOT NULL DEFAULT 1,
|
||||
recorded_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request
|
||||
ON public.usage_attribution_snapshots(actor_user_id, request_id);
|
||||
-- This attribution table is new and empty; build both lookup indexes here.
|
||||
CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request
|
||||
ON public.usage_attribution_snapshots(credential_owner_id, request_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.stats_bucket_state (
|
||||
projection_version text NOT NULL,
|
||||
granularity text NOT NULL,
|
||||
bucket_start timestamptz NOT NULL,
|
||||
source_revision bigint NOT NULL DEFAULT 0,
|
||||
built_revision bigint NOT NULL DEFAULT -1,
|
||||
coverage_status text NOT NULL DEFAULT 'unbuilt',
|
||||
built_at timestamptz,
|
||||
last_error text,
|
||||
last_failed_at timestamptz,
|
||||
PRIMARY KEY (projection_version, granularity, bucket_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_stats_bucket_state_dirty
|
||||
ON public.stats_bucket_state(bucket_start)
|
||||
WHERE source_revision > built_revision;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.stats_overview_hourly (
|
||||
projection_version text NOT NULL,
|
||||
bucket_start timestamptz NOT NULL,
|
||||
dimensions jsonb NOT NULL,
|
||||
metrics jsonb NOT NULL,
|
||||
PRIMARY KEY(projection_version, bucket_start, dimensions)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS public.stats_overview_daily (
|
||||
projection_version text NOT NULL,
|
||||
bucket_start timestamptz NOT NULL,
|
||||
dimensions jsonb NOT NULL,
|
||||
metrics jsonb NOT NULL,
|
||||
PRIMARY KEY(projection_version, bucket_start, dimensions)
|
||||
);
|
||||
|
||||
-- These triggers cover old writers, delayed settlement, and maintenance in the fact transaction.
|
||||
-- Only future fact mutations enqueue work; no historical rows are backfilled.
|
||||
-- Each writer owns its transaction's keys, so unrelated requests never contend
|
||||
-- on the current hour/day's stats_bucket_state row.
|
||||
CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events (
|
||||
transaction_id bigint NOT NULL,
|
||||
projection_version text NOT NULL,
|
||||
granularity text NOT NULL,
|
||||
bucket_start timestamptz NOT NULL,
|
||||
unrecoverable boolean NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket
|
||||
ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE old_time timestamptz; new_time timestamptz; bucket record;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'usage' THEN
|
||||
IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF;
|
||||
IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF;
|
||||
ELSE
|
||||
IF TG_OP <> 'INSERT' THEN
|
||||
SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id;
|
||||
END IF;
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id;
|
||||
END IF;
|
||||
END IF;
|
||||
FOR bucket IN
|
||||
SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts
|
||||
FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g
|
||||
WHERE t IS NOT NULL ORDER BY g, starts
|
||||
LOOP
|
||||
INSERT INTO public.stats_overview_dirty_events
|
||||
(transaction_id, projection_version, granularity, bucket_start, unrecoverable)
|
||||
VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts,
|
||||
TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE')
|
||||
ON CONFLICT (transaction_id, projection_version, granularity, bucket_start)
|
||||
DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable;
|
||||
END LOOP;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
|
||||
DROP TRIGGER IF EXISTS overview_usage_dirty ON public.usage;
|
||||
CREATE TRIGGER overview_usage_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
|
||||
DROP TRIGGER IF EXISTS overview_settlement_dirty ON public.usage_settlement_snapshots;
|
||||
CREATE TRIGGER overview_settlement_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
|
||||
DROP TRIGGER IF EXISTS overview_attribution_dirty ON public.usage_attribution_snapshots;
|
||||
CREATE TRIGGER overview_attribution_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_attribution_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE actor text; owner text; kind text; source text; standalone boolean;
|
||||
BEGIN
|
||||
SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted;
|
||||
SELECT COALESCE(k.is_standalone,
|
||||
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
|
||||
THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
|
||||
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean'
|
||||
THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END,
|
||||
CASE WHEN a.attribution_source='user_account' THEN false
|
||||
WHEN a.attribution_source='standalone_key' THEN true END,
|
||||
CASE WHEN NEW.api_key_id IS NULL THEN false END)
|
||||
INTO standalone FROM (SELECT 1) seed
|
||||
LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id
|
||||
LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id;
|
||||
kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone'
|
||||
WHEN NOT standalone THEN 'employee' ELSE 'unknown' END;
|
||||
actor := CASE WHEN kind='employee' THEN owner END;
|
||||
source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END;
|
||||
INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id,
|
||||
attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision)
|
||||
VALUES (NEW.request_id, actor, owner, kind, source,
|
||||
COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'),
|
||||
NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2)
|
||||
ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id,
|
||||
credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind,
|
||||
attribution_source = EXCLUDED.attribution_source,
|
||||
record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind),
|
||||
parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id),
|
||||
attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW()
|
||||
WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS overview_usage_identity ON public.usage;
|
||||
CREATE TRIGGER overview_usage_identity AFTER INSERT OR UPDATE OF user_id, api_key_id, request_metadata ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_capture_usage_identity();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_capture_failure() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'completed' THEN
|
||||
NEW.failure_origin := NULL; NEW.failure_stage := NULL; NEW.failure_reason := NULL;
|
||||
NEW.failure_schema_version := NULL; RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.request_metadata #>> '{analytics_failure,origin}' IN ('client','gateway','upstream','transport','unknown') THEN
|
||||
NEW.failure_origin := NEW.request_metadata #>> '{analytics_failure,origin}';
|
||||
NEW.failure_stage := NEW.request_metadata #>> '{analytics_failure,stage}';
|
||||
NEW.failure_reason := NEW.request_metadata #>> '{analytics_failure,reason}';
|
||||
NEW.failure_schema_version := 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS overview_usage_failure ON public.usage;
|
||||
CREATE TRIGGER overview_usage_failure BEFORE INSERT OR UPDATE OF request_metadata, status ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_capture_failure();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_anonymize_user() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' OR NEW.is_deleted THEN
|
||||
UPDATE public.usage_attribution_snapshots SET actor_user_id = NULL, credential_owner_id = NULL,
|
||||
attribution_kind = 'unknown', attribution_source = 'unknown', attribution_revision = attribution_revision + 10
|
||||
WHERE actor_user_id = OLD.id OR credential_owner_id = OLD.id;
|
||||
UPDATE public.usage SET request_metadata = (request_metadata::jsonb #- '{analytics_attribution,actor_user_id}')::json
|
||||
WHERE request_metadata #>> '{analytics_attribution,actor_user_id}' = OLD.id;
|
||||
DELETE FROM public.stats_overview_hourly WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id;
|
||||
DELETE FROM public.stats_overview_daily WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS overview_user_anonymize ON public.users;
|
||||
CREATE TRIGGER overview_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_anonymize_user();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_delete_attribution() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
DELETE FROM public.usage_attribution_snapshots WHERE request_id = OLD.request_id;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS overview_usage_delete_attribution ON public.usage;
|
||||
CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.overview_delete_attribution();
|
||||
|
||||
CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS
|
||||
SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
|
||||
CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id,
|
||||
identity.owner_id AS credential_owner_id,
|
||||
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone'
|
||||
WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind,
|
||||
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key'
|
||||
WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source,
|
||||
COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id,
|
||||
u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name,
|
||||
u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion,
|
||||
u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason,
|
||||
u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms,
|
||||
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
|
||||
COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
|
||||
COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.input_tokens END AS input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.output_tokens END AS output_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.total_tokens END AS total_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.cache_read_input_tokens END AS cache_read_input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
|
||||
THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
|
||||
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
|
||||
s.quota_covered_amount_usd AS quota_covered_amount,
|
||||
s.wallet_consumed_amount_usd AS wallet_consumed_amount,
|
||||
s.wallet_debit_amount_usd AS wallet_debit_amount,
|
||||
s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount,
|
||||
s.wallet_gift_debit_usd AS wallet_gift_debit_amount,
|
||||
s.wallet_overdraft_usd AS wallet_overdraft_amount,
|
||||
s.allocation_status, s.finalized_at AS settled_at,
|
||||
CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source,
|
||||
b.upstream_is_stream,
|
||||
CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
|
||||
THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
|
||||
THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
|
||||
THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL
|
||||
THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount
|
||||
FROM public.usage u
|
||||
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
|
||||
LEFT JOIN public.usage_attribution_snapshots a USING (request_id)
|
||||
JOIN public.usage_billing_facts b USING (request_id)
|
||||
LEFT JOIN public.api_keys k ON k.id=u.api_key_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id
|
||||
WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id,
|
||||
COALESCE(k.is_standalone,
|
||||
CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
|
||||
THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
|
||||
CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean'
|
||||
THEN (u.request_metadata->>'api_key_is_standalone')::boolean END,
|
||||
CASE WHEN a.attribution_source='user_account' THEN false
|
||||
WHEN a.attribution_source='standalone_key' THEN true END,
|
||||
CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone
|
||||
) identity;
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
-- Retain existing migration checksums and historical records; only future writes create v2 buckets.
|
||||
DROP VIEW IF EXISTS public.usage_analytics_facts_v1;
|
||||
ALTER TABLE public.api_keys DROP COLUMN IF EXISTS credential_kind;
|
||||
ALTER TABLE public.usage_attribution_snapshots DROP COLUMN IF EXISTS credential_kind;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE old_time timestamptz; new_time timestamptz; bucket record;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'usage' THEN
|
||||
IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF;
|
||||
IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF;
|
||||
ELSE
|
||||
IF TG_OP <> 'INSERT' THEN
|
||||
SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id;
|
||||
END IF;
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id;
|
||||
END IF;
|
||||
END IF;
|
||||
FOR bucket IN
|
||||
SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts
|
||||
FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g
|
||||
WHERE t IS NOT NULL ORDER BY g, starts
|
||||
LOOP
|
||||
INSERT INTO public.stats_bucket_state(projection_version, granularity, bucket_start, source_revision)
|
||||
VALUES ('overview-v2', bucket.g, bucket.starts, 1)
|
||||
ON CONFLICT (projection_version, granularity, bucket_start)
|
||||
DO UPDATE SET source_revision = stats_bucket_state.source_revision + 1;
|
||||
IF TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE' THEN
|
||||
UPDATE public.stats_bucket_state SET coverage_status='unrecoverable',
|
||||
last_error='retained usage facts were deleted'
|
||||
WHERE projection_version='overview-v2' AND granularity=bucket.g AND bucket_start=bucket.starts;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE actor text; owner text; kind text; source text; standalone boolean;
|
||||
BEGIN
|
||||
SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted;
|
||||
SELECT COALESCE(k.is_standalone,
|
||||
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
|
||||
THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
|
||||
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean'
|
||||
THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END,
|
||||
CASE WHEN a.attribution_source='user_account' THEN false
|
||||
WHEN a.attribution_source='standalone_key' THEN true END,
|
||||
CASE WHEN NEW.api_key_id IS NULL THEN false END)
|
||||
INTO standalone FROM (SELECT 1) seed
|
||||
LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id
|
||||
LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id;
|
||||
kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone'
|
||||
WHEN NOT standalone THEN 'employee' ELSE 'unknown' END;
|
||||
actor := CASE WHEN kind='employee' THEN owner END;
|
||||
source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END;
|
||||
INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id,
|
||||
attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision)
|
||||
VALUES (NEW.request_id, actor, owner, kind, source,
|
||||
COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'),
|
||||
NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2)
|
||||
ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id,
|
||||
credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind,
|
||||
attribution_source = EXCLUDED.attribution_source,
|
||||
record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind),
|
||||
parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id),
|
||||
attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW()
|
||||
WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS
|
||||
SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
|
||||
CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id,
|
||||
identity.owner_id AS credential_owner_id,
|
||||
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone'
|
||||
WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind,
|
||||
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key'
|
||||
WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source,
|
||||
COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id,
|
||||
u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name,
|
||||
u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion,
|
||||
u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason,
|
||||
u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms,
|
||||
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
|
||||
COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
|
||||
COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.input_tokens END AS input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.output_tokens END AS output_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.total_tokens END AS total_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.cache_read_input_tokens END AS cache_read_input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
|
||||
THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
|
||||
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
|
||||
s.quota_covered_amount_usd AS quota_covered_amount,
|
||||
s.wallet_consumed_amount_usd AS wallet_consumed_amount,
|
||||
s.wallet_debit_amount_usd AS wallet_debit_amount,
|
||||
s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount,
|
||||
s.wallet_gift_debit_usd AS wallet_gift_debit_amount,
|
||||
s.wallet_overdraft_usd AS wallet_overdraft_amount,
|
||||
s.allocation_status, s.finalized_at AS settled_at,
|
||||
CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source,
|
||||
b.upstream_is_stream,
|
||||
CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
|
||||
THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
|
||||
THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
|
||||
THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount,
|
||||
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
|
||||
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL
|
||||
THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount
|
||||
FROM public.usage u
|
||||
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
|
||||
LEFT JOIN public.usage_attribution_snapshots a USING (request_id)
|
||||
JOIN public.usage_billing_facts b USING (request_id)
|
||||
LEFT JOIN public.api_keys k ON k.id=u.api_key_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id
|
||||
WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id,
|
||||
COALESCE(k.is_standalone,
|
||||
CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
|
||||
THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
|
||||
CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean'
|
||||
THEN (u.request_metadata->>'api_key_is_standalone')::boolean END,
|
||||
CASE WHEN a.attribution_source='user_account' THEN false
|
||||
WHEN a.attribution_source='standalone_key' THEN true END,
|
||||
CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone
|
||||
) identity;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
-- Upgrade databases that already applied the original account-attribution migration.
|
||||
-- Keep this before concurrent index builds so their failure cannot leave writers
|
||||
-- contending on shared hourly/daily aggregate rows. Fresh databases are safe too.
|
||||
-- Only future fact mutations enqueue work; no historical rows are backfilled.
|
||||
-- Each writer owns its transaction's keys, so unrelated requests never contend
|
||||
-- on the current hour/day's stats_bucket_state row.
|
||||
CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events (
|
||||
transaction_id bigint NOT NULL,
|
||||
projection_version text NOT NULL,
|
||||
granularity text NOT NULL,
|
||||
bucket_start timestamptz NOT NULL,
|
||||
unrecoverable boolean NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket
|
||||
ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE old_time timestamptz; new_time timestamptz; bucket record;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'usage' THEN
|
||||
IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF;
|
||||
IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF;
|
||||
ELSE
|
||||
IF TG_OP <> 'INSERT' THEN
|
||||
SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id;
|
||||
END IF;
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id;
|
||||
END IF;
|
||||
END IF;
|
||||
FOR bucket IN
|
||||
SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts
|
||||
FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g
|
||||
WHERE t IS NOT NULL ORDER BY g, starts
|
||||
LOOP
|
||||
INSERT INTO public.stats_overview_dirty_events
|
||||
(transaction_id, projection_version, granularity, bucket_start, unrecoverable)
|
||||
VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts,
|
||||
TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE')
|
||||
ON CONFLICT (transaction_id, projection_version, granularity, bucket_start)
|
||||
DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable;
|
||||
END LOOP;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
-- no-transaction
|
||||
-- Keep the existing billing cover while building its replacement. Overview
|
||||
-- totals also read settlement/allocation status; omitting those columns forces
|
||||
-- a scan of the wide snapshot heap even when every monetary field is covered.
|
||||
-- The following migration retires the old index only after this build succeeds.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_settlement_dashboard_cover_v2
|
||||
ON public.usage_settlement_snapshots (request_id)
|
||||
INCLUDE (
|
||||
billing_input_tokens,
|
||||
billing_effective_input_tokens,
|
||||
billing_output_tokens,
|
||||
billing_cache_creation_tokens,
|
||||
billing_cache_creation_5m_tokens,
|
||||
billing_cache_creation_1h_tokens,
|
||||
billing_cache_read_tokens,
|
||||
billing_total_input_context,
|
||||
billing_cache_creation_cost_usd,
|
||||
billing_cache_read_cost_usd,
|
||||
billing_total_cost_usd,
|
||||
billing_actual_total_cost_usd,
|
||||
input_price_per_1m,
|
||||
billing_status,
|
||||
allocation_status
|
||||
);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- no-transaction
|
||||
-- The preceding migration has published the superset index. Do not keep two
|
||||
-- permanent covering indexes with the same key and almost identical payloads.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_settlement_dashboard_cover;
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
-- Future-only dashboard projection. No usage history is read or backfilled.
|
||||
-- Background maintenance bounds detailed minutes to 35 days; lifetime totals
|
||||
-- and narrow activity counters remain available after detail or usage cleanup.
|
||||
CREATE TABLE public.dashboard_stats_state (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
stats_since timestamptz NOT NULL,
|
||||
contributions_cleanup_cursor varchar(100)
|
||||
);
|
||||
CREATE TABLE public.dashboard_request_contributions (
|
||||
request_id varchar(100) PRIMARY KEY,
|
||||
created_at timestamptz NOT NULL,
|
||||
actor_user_id varchar(255),
|
||||
metrics jsonb NOT NULL
|
||||
);
|
||||
CREATE TABLE public.dashboard_stats_total (
|
||||
shard smallint PRIMARY KEY CHECK (shard >= 0 AND shard < 16),
|
||||
metrics jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
INSERT INTO public.dashboard_stats_total(shard) SELECT generate_series(0,15);
|
||||
CREATE TABLE public.dashboard_stats_minute (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (bucket_start, shard)
|
||||
);
|
||||
CREATE TABLE public.dashboard_activity_minute (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
request_count bigint NOT NULL,
|
||||
PRIMARY KEY (bucket_start,shard)
|
||||
);
|
||||
CREATE TABLE public.dashboard_activity_hour (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
request_count bigint NOT NULL,
|
||||
PRIMARY KEY (bucket_start, shard)
|
||||
);
|
||||
CREATE TABLE public.dashboard_actor_minute (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
actor_user_id varchar(255) NOT NULL,
|
||||
request_count bigint NOT NULL,
|
||||
PRIMARY KEY (bucket_start, shard, actor_user_id)
|
||||
);
|
||||
-- Rows only exist within a writing transaction. Deferred processing coalesces
|
||||
-- usage, settlement and identity mutations into one final contribution.
|
||||
CREATE TABLE public.dashboard_stats_pending (
|
||||
transaction_id bigint NOT NULL,
|
||||
request_id varchar(100) NOT NULL,
|
||||
deleted_fact jsonb,
|
||||
PRIMARY KEY (transaction_id, request_id)
|
||||
);
|
||||
CREATE TABLE public.dashboard_user_events_minute (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
created_count bigint NOT NULL DEFAULT 0,
|
||||
deleted_count bigint NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (bucket_start, shard)
|
||||
);
|
||||
|
||||
CREATE FUNCTION public.dashboard_metrics_add(a jsonb, b jsonb, direction integer DEFAULT 1)
|
||||
RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb) FROM (
|
||||
SELECT key, sum(value) AS value FROM (
|
||||
SELECT key, value::numeric FROM jsonb_each_text(COALESCE(a, '{}'::jsonb))
|
||||
UNION ALL
|
||||
SELECT key, value::numeric * direction FROM jsonb_each_text(COALESCE(b, '{}'::jsonb))
|
||||
) entries GROUP BY key
|
||||
) sums
|
||||
$$;
|
||||
|
||||
-- Canonical views are evaluated for ONE indexed request, never for old history.
|
||||
CREATE FUNCTION public.dashboard_request_fact(p_request_id text)
|
||||
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
||||
SELECT jsonb_build_object('created_at', f.created_at,
|
||||
'actor_user_id', f.actor_user_id,
|
||||
'metrics', CASE WHEN f.record_kind = 'session' THEN '{}'::jsonb ELSE
|
||||
jsonb_build_object(
|
||||
'request_count', 1,
|
||||
'input_tokens', COALESCE(f.input_tokens,0),
|
||||
'output_tokens', COALESCE(f.output_tokens,0),
|
||||
'total_tokens', COALESCE(f.total_tokens,0),
|
||||
'usage_available_count', f.usage_available::integer,
|
||||
'pricing_available_count', (f.billable_amount IS NOT NULL)::integer,
|
||||
'billable_amount', COALESCE(f.billable_amount,0),
|
||||
'cache_read_tokens', COALESCE(f.cache_read_input_tokens,0),
|
||||
'cache_creation_tokens', COALESCE(f.cache_creation_input_tokens,0),
|
||||
'cache_input_tokens', CASE WHEN f.usage_available THEN COALESCE(b.total_input_context,0) ELSE 0 END,
|
||||
'first_byte_sum_ms', COALESCE(f.first_byte_time_ms,0),
|
||||
'first_byte_sample_count', (f.first_byte_time_ms IS NOT NULL)::integer,
|
||||
'response_sum_ms', COALESCE(f.response_time_ms,0),
|
||||
'response_sample_count', (f.response_time_ms IS NOT NULL)::integer,
|
||||
'stream_requests', COALESCE(f.upstream_is_stream,false)::integer,
|
||||
'standard_requests', (NOT COALESCE(f.upstream_is_stream,false))::integer
|
||||
) END)
|
||||
FROM public.usage_analytics_facts_v1 f
|
||||
JOIN public.usage_billing_facts b USING (request_id)
|
||||
WHERE f.request_id = p_request_id
|
||||
AND f.created_at >= (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton)
|
||||
$$;
|
||||
|
||||
-- Callers hold the aggregate shard lock. Seed a missing narrow activity counter
|
||||
-- from the detailed minute before applying a correction.
|
||||
CREATE FUNCTION public.dashboard_ensure_activity_minute(p_bucket timestamptz,p_shard smallint)
|
||||
RETURNS void LANGUAGE sql AS $$
|
||||
INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0)
|
||||
FROM public.dashboard_stats_minute WHERE bucket_start=p_bucket AND shard=p_shard
|
||||
ON CONFLICT(bucket_start,shard) DO NOTHING
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.dashboard_enqueue_request() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE rid text;
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
rid := CASE WHEN TG_OP = 'DELETE' THEN OLD.request_id ELSE NEW.request_id END;
|
||||
-- Fast path: an update to a pre-activation request must never scan/replay it.
|
||||
IF TG_TABLE_NAME = 'usage' THEN
|
||||
IF (CASE WHEN TG_OP = 'DELETE' THEN OLD.created_at ELSE NEW.created_at END)
|
||||
< (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton) THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
END IF;
|
||||
INSERT INTO public.dashboard_stats_pending(transaction_id, request_id, deleted_fact)
|
||||
VALUES(txid_current(), rid,
|
||||
CASE WHEN TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE'
|
||||
THEN public.dashboard_request_fact(rid) END)
|
||||
ON CONFLICT (transaction_id, request_id) DO UPDATE
|
||||
SET deleted_fact = COALESCE(EXCLUDED.deleted_fact, dashboard_stats_pending.deleted_fact);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p record; old_fact public.dashboard_request_contributions%ROWTYPE;
|
||||
fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text;
|
||||
shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean;
|
||||
detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days');
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
-- Lock all touched shards in one order before touching any ledger or minute.
|
||||
-- This also serializes concurrent usage/settlement updates for the same request.
|
||||
PERFORM t.shard FROM public.dashboard_stats_total t
|
||||
WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint
|
||||
FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id)
|
||||
ORDER BY t.shard FOR UPDATE;
|
||||
FOR p IN SELECT * FROM public.dashboard_stats_pending
|
||||
WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP
|
||||
shard_id := (hashtextextended(p.request_id,0) & 15)::smallint;
|
||||
fact := public.dashboard_request_fact(p.request_id);
|
||||
purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id);
|
||||
-- A purge preserves the last contribution, including an update in this tx.
|
||||
fact := COALESCE(fact,p.deleted_fact);
|
||||
IF fact IS NULL THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_metrics := fact->'metrics';
|
||||
next_at := (fact->>'created_at')::timestamptz;
|
||||
next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0
|
||||
THEN fact->>'actor_user_id' END;
|
||||
SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
IF FOUND AND old_fact.created_at=next_at
|
||||
AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor
|
||||
AND old_fact.metrics=next_metrics THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
IF old_fact.request_id IS NOT NULL THEN
|
||||
old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id);
|
||||
UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id;
|
||||
IF old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
END IF;
|
||||
UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id;
|
||||
IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_actor_minute SET request_count=request_count-1
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id;
|
||||
END IF;
|
||||
END IF;
|
||||
PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id);
|
||||
INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id;
|
||||
IF next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics)
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics);
|
||||
END IF;
|
||||
INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count)
|
||||
VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count;
|
||||
IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count)
|
||||
VALUES(next_bucket,shard_id,next_actor,1)
|
||||
ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1;
|
||||
END IF;
|
||||
IF purged THEN
|
||||
DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
ELSE
|
||||
INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics)
|
||||
VALUES(p.request_id,next_at,next_actor,next_metrics)
|
||||
ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at,
|
||||
actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics;
|
||||
END IF;
|
||||
END LOOP;
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER dashboard_flush_pending AFTER INSERT ON public.dashboard_stats_pending
|
||||
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dashboard_apply_pending();
|
||||
|
||||
CREATE FUNCTION public.dashboard_record_user_event() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE added bigint := 0; removed bigint := 0; rid text; at timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
IF TG_OP='INSERT' THEN added:=1; rid:=NEW.id;
|
||||
ELSIF TG_OP='DELETE' THEN removed:=(NOT OLD.is_deleted)::integer; rid:=OLD.id;
|
||||
ELSE removed:=(NOT OLD.is_deleted AND NEW.is_deleted)::integer; rid:=NEW.id;
|
||||
END IF;
|
||||
IF added=0 AND removed=0 THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM public.dashboard_stats_state WHERE singleton AND stats_since<=at) THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
INSERT INTO public.dashboard_user_events_minute(bucket_start,shard,created_count,deleted_count)
|
||||
VALUES(date_trunc('minute',at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',
|
||||
(hashtextextended(rid,0) & 15)::smallint,added,removed)
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE SET
|
||||
created_count=dashboard_user_events_minute.created_count+EXCLUDED.created_count,
|
||||
deleted_count=dashboard_user_events_minute.deleted_count+EXCLUDED.deleted_count;
|
||||
RETURN COALESCE(NEW,OLD);
|
||||
END $$;
|
||||
|
||||
-- Acquire source DDL locks before recording activation. This excludes transactions
|
||||
-- that could commit writes without observing the new triggers.
|
||||
CREATE TRIGGER dashboard_usage_changed AFTER INSERT OR UPDATE ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request();
|
||||
CREATE TRIGGER aaa_dashboard_usage_deleted BEFORE DELETE ON public.usage
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request();
|
||||
CREATE TRIGGER dashboard_settlement_changed AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request();
|
||||
CREATE TRIGGER dashboard_attribution_changed AFTER INSERT OR UPDATE ON public.usage_attribution_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request();
|
||||
CREATE TRIGGER dashboard_user_changed AFTER INSERT OR DELETE OR UPDATE OF is_deleted ON public.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_record_user_event();
|
||||
INSERT INTO public.dashboard_stats_state(singleton,stats_since) VALUES(true,clock_timestamp());
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- no-transaction
|
||||
-- User finance reports use the actual credited period, not order creation time.
|
||||
-- Keep the index build separate from transactional schema changes.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payment_orders_status_credited_user
|
||||
ON public.payment_orders USING btree (status, credited_at, user_id);
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- Manual purchasing ledger; no foreign-key cascade may erase historical expenditures.
|
||||
CREATE TABLE IF NOT EXISTS public.provider_expenses (
|
||||
id text PRIMARY KEY,
|
||||
client_request_id text NOT NULL UNIQUE,
|
||||
provider_id text NOT NULL,
|
||||
provider_name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('recharge', 'subscription', 'other')),
|
||||
amount numeric(20,8) NOT NULL CHECK (amount > 0),
|
||||
currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
|
||||
paid_at timestamptz NOT NULL,
|
||||
period_start timestamptz,
|
||||
period_end timestamptz,
|
||||
note text,
|
||||
external_reference text,
|
||||
created_by text,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
voided_at timestamptz,
|
||||
voided_by text,
|
||||
CONSTRAINT provider_expenses_period_check CHECK (
|
||||
(period_start IS NULL AND period_end IS NULL) OR
|
||||
(period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses(paid_at, id);
|
||||
CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses(provider_id, paid_at);
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
-- No raw usage history is scanned. Existing dashboard minutes are compacted by
|
||||
-- bounded background batches; lifetime totals and local-calendar activity survive.
|
||||
-- Fresh installations already contain these definitions in the dashboard baseline.
|
||||
ALTER TABLE public.dashboard_stats_state ADD COLUMN IF NOT EXISTS contributions_cleanup_cursor varchar(100);
|
||||
CREATE TABLE IF NOT EXISTS public.dashboard_activity_minute (
|
||||
bucket_start timestamptz NOT NULL,
|
||||
shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16),
|
||||
request_count bigint NOT NULL,
|
||||
PRIMARY KEY (bucket_start,shard)
|
||||
);
|
||||
|
||||
-- Callers hold the aggregate shard lock. Before the first post-upgrade correction
|
||||
-- of a minute, seed its narrow counter from the existing wide aggregate.
|
||||
CREATE OR REPLACE FUNCTION public.dashboard_ensure_activity_minute(p_bucket timestamptz,p_shard smallint)
|
||||
RETURNS void LANGUAGE sql AS $$
|
||||
INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0)
|
||||
FROM public.dashboard_stats_minute WHERE bucket_start=p_bucket AND shard=p_shard
|
||||
ON CONFLICT(bucket_start,shard) DO NOTHING
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.dashboard_enqueue_request() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE rid text;
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
rid := CASE WHEN TG_OP = 'DELETE' THEN OLD.request_id ELSE NEW.request_id END;
|
||||
-- Fast path: an update to a pre-activation request must never scan/replay it.
|
||||
IF TG_TABLE_NAME = 'usage' THEN
|
||||
IF (CASE WHEN TG_OP = 'DELETE' THEN OLD.created_at ELSE NEW.created_at END)
|
||||
< (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton) THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
END IF;
|
||||
INSERT INTO public.dashboard_stats_pending(transaction_id, request_id, deleted_fact)
|
||||
VALUES(txid_current(), rid,
|
||||
CASE WHEN TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE'
|
||||
THEN public.dashboard_request_fact(rid) END)
|
||||
ON CONFLICT (transaction_id, request_id) DO UPDATE
|
||||
SET deleted_fact = COALESCE(EXCLUDED.deleted_fact, dashboard_stats_pending.deleted_fact);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p record; old_fact public.dashboard_request_contributions%ROWTYPE;
|
||||
fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text;
|
||||
shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean;
|
||||
detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days');
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
-- Lock all touched shards in one order before touching any ledger or minute.
|
||||
-- This also serializes concurrent usage/settlement updates for the same request.
|
||||
PERFORM t.shard FROM public.dashboard_stats_total t
|
||||
WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint
|
||||
FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id)
|
||||
ORDER BY t.shard FOR UPDATE;
|
||||
FOR p IN SELECT * FROM public.dashboard_stats_pending
|
||||
WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP
|
||||
shard_id := (hashtextextended(p.request_id,0) & 15)::smallint;
|
||||
fact := public.dashboard_request_fact(p.request_id);
|
||||
purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id);
|
||||
-- A purge preserves the last contribution, including an update in this tx.
|
||||
fact := COALESCE(fact,p.deleted_fact);
|
||||
IF fact IS NULL THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_metrics := fact->'metrics';
|
||||
next_at := (fact->>'created_at')::timestamptz;
|
||||
next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0
|
||||
THEN fact->>'actor_user_id' END;
|
||||
SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
IF FOUND AND old_fact.created_at=next_at
|
||||
AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor
|
||||
AND old_fact.metrics=next_metrics THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
IF old_fact.request_id IS NOT NULL THEN
|
||||
old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id);
|
||||
UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id;
|
||||
IF old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
END IF;
|
||||
UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id;
|
||||
IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_actor_minute SET request_count=request_count-1
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id;
|
||||
END IF;
|
||||
END IF;
|
||||
PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id);
|
||||
INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id;
|
||||
IF next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics)
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics);
|
||||
END IF;
|
||||
INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count)
|
||||
VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count;
|
||||
IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count)
|
||||
VALUES(next_bucket,shard_id,next_actor,1)
|
||||
ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1;
|
||||
END IF;
|
||||
IF purged THEN
|
||||
DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
ELSE
|
||||
INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics)
|
||||
VALUES(p.request_id,next_at,next_actor,next_metrics)
|
||||
ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at,
|
||||
actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics;
|
||||
END IF;
|
||||
END LOOP;
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE OR REPLACE FUNCTION public.dashboard_record_user_event() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE added bigint := 0; removed bigint := 0; rid text; at timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
IF TG_OP='INSERT' THEN added:=1; rid:=NEW.id;
|
||||
ELSIF TG_OP='DELETE' THEN removed:=(NOT OLD.is_deleted)::integer; rid:=OLD.id;
|
||||
ELSE removed:=(NOT OLD.is_deleted AND NEW.is_deleted)::integer; rid:=NEW.id;
|
||||
END IF;
|
||||
IF added=0 AND removed=0 THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM public.dashboard_stats_state WHERE singleton AND stats_since<=at) THEN RETURN COALESCE(NEW,OLD); END IF;
|
||||
INSERT INTO public.dashboard_user_events_minute(bucket_start,shard,created_count,deleted_count)
|
||||
VALUES(date_trunc('minute',at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',
|
||||
(hashtextextended(rid,0) & 15)::smallint,added,removed)
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE SET
|
||||
created_count=dashboard_user_events_minute.created_count+EXCLUDED.created_count,
|
||||
deleted_count=dashboard_user_events_minute.deleted_count+EXCLUDED.deleted_count;
|
||||
RETURN COALESCE(NEW,OLD);
|
||||
END $$;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- no-transaction
|
||||
-- Anonymization looks up either side of the account attribution. Keep both
|
||||
-- branches indexed so deleting an unrelated user never scans all snapshots.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_usage_attribution_owner_request
|
||||
ON public.usage_attribution_snapshots (credential_owner_id, request_id);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- no-transaction
|
||||
-- Historical trusted-identity metadata must also be anonymized. Index that
|
||||
-- exact predicate without storing the much larger metadata document.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_usage_analytics_actor_metadata
|
||||
ON public.usage ((request_metadata #>> '{analytics_attribution,actor_user_id}'))
|
||||
WHERE (request_metadata #>> '{analytics_attribution,actor_user_id}') IS NOT NULL;
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
-- Install future user anonymization without scanning or rewriting historical rows.
|
||||
-- Legacy orphan actors are excluded by the dashboard read path. User deletion
|
||||
-- cleans the retained actor window and contribution identities at transaction end.
|
||||
CREATE TABLE public.dashboard_user_anonymization_pending (
|
||||
transaction_id bigint NOT NULL,
|
||||
user_id varchar(255) NOT NULL,
|
||||
PRIMARY KEY (transaction_id,user_id)
|
||||
);
|
||||
|
||||
CREATE FUNCTION public.dashboard_enqueue_user_anonymization() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN NULL; END IF;
|
||||
IF TG_OP='DELETE' OR NEW.is_deleted THEN
|
||||
INSERT INTO public.dashboard_user_anonymization_pending(transaction_id,user_id)
|
||||
VALUES(txid_current(),OLD.id) ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE TRIGGER dashboard_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_user_anonymization();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p record; anonymous_request record; old_fact public.dashboard_request_contributions%ROWTYPE;
|
||||
fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text;
|
||||
shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean; anonymize boolean;
|
||||
detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days');
|
||||
BEGIN
|
||||
IF current_setting('aether.dashboard_restore',true)='on' THEN
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
DELETE FROM public.dashboard_user_anonymization_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
SELECT EXISTS (SELECT 1 FROM public.dashboard_user_anonymization_pending
|
||||
WHERE transaction_id=NEW.transaction_id) INTO anonymize;
|
||||
-- Lock all touched shards in one order before touching any ledger or minute.
|
||||
-- This also serializes concurrent usage/settlement updates for the same request.
|
||||
PERFORM t.shard FROM public.dashboard_stats_total t
|
||||
WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint
|
||||
FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id)
|
||||
OR anonymize
|
||||
ORDER BY t.shard FOR UPDATE;
|
||||
FOR p IN SELECT * FROM public.dashboard_stats_pending
|
||||
WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP
|
||||
shard_id := (hashtextextended(p.request_id,0) & 15)::smallint;
|
||||
fact := public.dashboard_request_fact(p.request_id);
|
||||
purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id);
|
||||
-- A purge preserves the last contribution, including an update in this tx.
|
||||
fact := COALESCE(fact,p.deleted_fact);
|
||||
IF fact IS NULL THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_metrics := fact->'metrics';
|
||||
next_at := (fact->>'created_at')::timestamptz;
|
||||
next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0
|
||||
THEN (SELECT id FROM public.users WHERE id=fact->>'actor_user_id' AND NOT is_deleted) END;
|
||||
SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
IF FOUND AND old_fact.created_at=next_at
|
||||
AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor
|
||||
AND old_fact.metrics=next_metrics THEN
|
||||
IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
IF old_fact.request_id IS NOT NULL THEN
|
||||
old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id);
|
||||
UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id;
|
||||
IF old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1)
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id;
|
||||
END IF;
|
||||
UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0)
|
||||
WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id;
|
||||
IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN
|
||||
UPDATE public.dashboard_actor_minute SET request_count=request_count-1
|
||||
WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id;
|
||||
END IF;
|
||||
END IF;
|
||||
PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id);
|
||||
INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count;
|
||||
UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id;
|
||||
IF next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics)
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE
|
||||
SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics);
|
||||
END IF;
|
||||
INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count)
|
||||
VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0))
|
||||
ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count;
|
||||
IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN
|
||||
INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count)
|
||||
VALUES(next_bucket,shard_id,next_actor,1)
|
||||
ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1;
|
||||
END IF;
|
||||
IF purged THEN
|
||||
DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id;
|
||||
ELSE
|
||||
INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics)
|
||||
VALUES(p.request_id,next_at,next_actor,next_metrics)
|
||||
ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at,
|
||||
actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics;
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- Flush identity removal only after request corrections, under the same shard
|
||||
-- locks. This also covers requests whose source usage was already purged.
|
||||
-- Surviving contribution identities were corrected above by request_id; do
|
||||
-- not scan the lifetime contribution table while holding the shard locks.
|
||||
IF anonymize THEN
|
||||
-- A writer can commit between the user's deletion statement and this flush.
|
||||
-- Its new attribution was invisible to the user's earlier trigger. Reuse
|
||||
-- the actor index to find only those requests, then clear each ledger by PK.
|
||||
-- Do not lock attribution rows here: writers lock them before their shard.
|
||||
FOR anonymous_request IN
|
||||
SELECT a.request_id, pending_user.user_id
|
||||
FROM public.dashboard_user_anonymization_pending pending_user
|
||||
JOIN public.usage_attribution_snapshots a ON a.actor_user_id=pending_user.user_id
|
||||
WHERE pending_user.transaction_id=NEW.transaction_id
|
||||
LOOP
|
||||
UPDATE public.dashboard_request_contributions SET actor_user_id=NULL
|
||||
WHERE request_id=anonymous_request.request_id AND actor_user_id=anonymous_request.user_id;
|
||||
END LOOP;
|
||||
DELETE FROM public.dashboard_actor_minute a USING public.dashboard_user_anonymization_pending pending_user
|
||||
WHERE pending_user.transaction_id=NEW.transaction_id AND a.actor_user_id=pending_user.user_id;
|
||||
END IF;
|
||||
DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id;
|
||||
DELETE FROM public.dashboard_user_anonymization_pending WHERE transaction_id=NEW.transaction_id;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
-- Both request and user events share one deferred flush. It locks every touched
|
||||
-- shard in order before corrections or deletion, regardless of trigger order.
|
||||
CREATE CONSTRAINT TRIGGER dashboard_flush_user_anonymization
|
||||
AFTER INSERT ON public.dashboard_user_anonymization_pending
|
||||
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dashboard_apply_pending();
|
||||
@@ -60,6 +60,48 @@ ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
|
||||
LIMIT $3
|
||||
"#;
|
||||
|
||||
const LIST_USER_ANNOUNCEMENTS_SQL: &str = r#"
|
||||
WITH visible AS MATERIALIZED (
|
||||
SELECT a.*, EXISTS (
|
||||
SELECT 1 FROM announcement_reads r WHERE r.user_id = $1 AND r.announcement_id = a.id
|
||||
) AS is_read
|
||||
FROM announcements a
|
||||
WHERE a.is_active = TRUE
|
||||
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
|
||||
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
|
||||
), counts AS (
|
||||
SELECT count(*) FILTER (WHERE NOT $3 OR NOT is_read)::bigint AS total,
|
||||
count(*) FILTER (WHERE NOT is_read)::bigint AS unread_count
|
||||
FROM visible
|
||||
)
|
||||
SELECT
|
||||
counts.total,
|
||||
counts.unread_count,
|
||||
a.id,
|
||||
a.title,
|
||||
a.content,
|
||||
a.type,
|
||||
a.priority,
|
||||
a.is_active,
|
||||
a.is_pinned,
|
||||
a.requires_ack,
|
||||
a.is_read,
|
||||
a.author_id,
|
||||
u.username AS author_username,
|
||||
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_ms,
|
||||
EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM counts
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM visible WHERE NOT $3 OR NOT is_read
|
||||
ORDER BY is_pinned DESC, priority DESC, created_at DESC, id ASC
|
||||
LIMIT $4 OFFSET $5
|
||||
) a ON TRUE
|
||||
LEFT JOIN users u ON u.id = a.author_id
|
||||
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
|
||||
"#;
|
||||
|
||||
const CREATE_ANNOUNCEMENT_SQL: &str = r#"
|
||||
INSERT INTO announcements (
|
||||
id,
|
||||
@@ -262,6 +304,40 @@ impl AnnouncementReadRepository for SqlxAnnouncementReadRepository {
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn list_user_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
query: &UserAnnouncementListQuery,
|
||||
) -> Result<StoredUserAnnouncementPage, DataLayerError> {
|
||||
query.validate()?;
|
||||
// A single statement preserves counts even when the selected page is empty.
|
||||
let rows = sqlx::query(LIST_USER_ANNOUNCEMENTS_SQL)
|
||||
.bind(user_id)
|
||||
.bind(query.now_unix_secs as f64)
|
||||
.bind(query.unread_only)
|
||||
.bind(query.limit as i64)
|
||||
.bind(query.offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let mut page = StoredUserAnnouncementPage::default();
|
||||
for row in rows {
|
||||
page.total = row.try_get::<i64, _>("total").map_postgres_err()? as u64;
|
||||
page.unread_count = row.try_get::<i64, _>("unread_count").map_postgres_err()? as u64;
|
||||
if row
|
||||
.try_get::<Option<String>, _>("id")
|
||||
.map_postgres_err()?
|
||||
.is_some()
|
||||
{
|
||||
page.items.push(StoredUserAnnouncement {
|
||||
announcement: map_announcement_row(&row)?,
|
||||
is_read: row.try_get("is_read").map_postgres_err()?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -421,6 +497,103 @@ mod tests {
|
||||
use super::SqlxAnnouncementReadRepository;
|
||||
use crate::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires AETHER_TEST_DATABASE_URL; uses connection-local temporary tables"]
|
||||
async fn live_personal_announcement_page_preserves_global_unread_and_visibility() {
|
||||
use aether_data_contracts::repository::announcements::{
|
||||
AnnouncementReadRepository, AnnouncementWriteRepository, UserAnnouncementListQuery,
|
||||
};
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"CREATE TEMP TABLE announcements (LIKE public.announcements INCLUDING ALL);
|
||||
CREATE TEMP TABLE announcement_reads (LIKE public.announcement_reads INCLUDING ALL);
|
||||
CREATE TEMP TABLE users (id varchar(36) PRIMARY KEY, username varchar(255));",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let repository = SqlxAnnouncementReadRepository::new(pool.clone());
|
||||
let now = 1_800_000_000_i64;
|
||||
for (id, active, pinned, priority, start, end) in [
|
||||
("pinned", true, true, 0, None, None),
|
||||
("normal-a", true, false, 20, Some(now), Some(now)),
|
||||
("normal-b", true, false, 20, None, None),
|
||||
("draft", false, false, 99, None, None),
|
||||
("future", true, false, 99, Some(now + 1), None),
|
||||
("expired", true, false, 99, None, Some(now - 1)),
|
||||
] {
|
||||
sqlx::query("INSERT INTO announcements(id,title,content,type,priority,is_active,is_pinned,created_at,updated_at,start_time,end_time) VALUES($1,$1,'content','info',$2,$3,$4,TO_TIMESTAMP($5::double precision),TO_TIMESTAMP($5::double precision),TO_TIMESTAMP($6::double precision),TO_TIMESTAMP($7::double precision))")
|
||||
.bind(id).bind(priority).bind(active).bind(pinned).bind(now as f64)
|
||||
.bind(start.map(|value| value as f64)).bind(end.map(|value| value as f64))
|
||||
.execute(&pool).await.unwrap();
|
||||
}
|
||||
repository
|
||||
.mark_announcement_as_read("reader", "pinned", now as u64)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut query = UserAnnouncementListQuery {
|
||||
unread_only: false,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
now_unix_secs: now as u64,
|
||||
};
|
||||
let first = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((first.total, first.unread_count), (3, 2));
|
||||
assert_eq!(first.items[0].announcement.id, "pinned");
|
||||
assert!(first.items[0].is_read);
|
||||
query.unread_only = true;
|
||||
query.offset = 1;
|
||||
let second = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((second.total, second.unread_count), (2, 2));
|
||||
assert_eq!(second.items[0].announcement.id, "normal-b");
|
||||
assert!(!second.items[0].is_read);
|
||||
query.offset = i64::MAX as usize;
|
||||
let empty = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(empty.items.is_empty());
|
||||
assert_eq!((empty.total, empty.unread_count), (2, 2));
|
||||
let other = repository
|
||||
.list_user_announcements("other", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((other.total, other.unread_count), (3, 3));
|
||||
repository
|
||||
.mark_announcement_as_read("reader", "normal-a", now as u64)
|
||||
.await
|
||||
.unwrap();
|
||||
query.offset = 0;
|
||||
let after_read = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((after_read.total, after_read.unread_count), (1, 1));
|
||||
assert_eq!(
|
||||
repository
|
||||
.count_unread_active_announcements("reader", now as u64)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
query.limit = 101;
|
||||
assert!(repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.is_err());
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod provider_expenses;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
@@ -154,6 +155,38 @@ impl SqlxBillingReadRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for SqlxBillingReadRepository {
|
||||
async fn list_provider_expenses(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::billing::ProviderExpenseQuery,
|
||||
) -> Result<
|
||||
Option<aether_data_contracts::repository::billing::ProviderExpensePage>,
|
||||
DataLayerError,
|
||||
> {
|
||||
self.expense_page(query).await
|
||||
}
|
||||
async fn create_provider_expense(
|
||||
&self,
|
||||
input: &aether_data_contracts::repository::billing::ProviderExpenseInput,
|
||||
) -> Result<
|
||||
AdminBillingMutationOutcome<
|
||||
aether_data_contracts::repository::billing::ProviderExpenseRecord,
|
||||
>,
|
||||
DataLayerError,
|
||||
> {
|
||||
self.insert_expense(input).await
|
||||
}
|
||||
async fn void_provider_expense(
|
||||
&self,
|
||||
id: &str,
|
||||
operator: Option<&str>,
|
||||
) -> Result<
|
||||
AdminBillingMutationOutcome<
|
||||
aether_data_contracts::repository::billing::ProviderExpenseRecord,
|
||||
>,
|
||||
DataLayerError,
|
||||
> {
|
||||
self.void_expense(id, operator).await
|
||||
}
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
@@ -1086,6 +1119,15 @@ WHERE product_id = $1
|
||||
async fn list_user_plan_entitlements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
self.list_user_plan_entitlements_with_history(user_id, false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_user_plan_entitlements_with_history(
|
||||
&self,
|
||||
user_id: &str,
|
||||
include_inactive: bool,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
@@ -1098,12 +1140,12 @@ SELECT
|
||||
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||
FROM user_plan_entitlements
|
||||
WHERE user_id = $1
|
||||
AND status = 'active'
|
||||
AND expires_at > NOW()
|
||||
AND ($2 OR (status = 'active' AND expires_at > NOW()))
|
||||
ORDER BY expires_at ASC, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(include_inactive)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use super::SqlxBillingReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::billing::*, DataLayerError};
|
||||
use sqlx::Row;
|
||||
|
||||
const SELECT_FIELDS: &str = "id, client_request_id, provider_id, provider_name, kind, amount::text AS amount, currency, (EXTRACT(EPOCH FROM paid_at)*1000)::bigint AS paid_ms, (EXTRACT(EPOCH FROM period_start)*1000)::bigint AS period_start_ms, (EXTRACT(EPOCH FROM period_end)*1000)::bigint AS period_end_ms, note, external_reference, created_by, (EXTRACT(EPOCH FROM created_at)*1000)::bigint AS created_ms, (EXTRACT(EPOCH FROM voided_at)*1000)::bigint AS voided_ms, voided_by";
|
||||
fn row_record(row: &sqlx::postgres::PgRow) -> Result<ProviderExpenseRecord, DataLayerError> {
|
||||
Ok(ProviderExpenseRecord {
|
||||
id: row.try_get("id").map_postgres_err()?,
|
||||
entry: ProviderExpenseInput {
|
||||
client_request_id: row.try_get("client_request_id").map_postgres_err()?,
|
||||
provider_id: row.try_get("provider_id").map_postgres_err()?,
|
||||
provider_name: row.try_get("provider_name").map_postgres_err()?,
|
||||
kind: row.try_get("kind").map_postgres_err()?,
|
||||
amount: row.try_get("amount").map_postgres_err()?,
|
||||
currency: row.try_get("currency").map_postgres_err()?,
|
||||
paid_at_unix_ms: row.try_get::<i64, _>("paid_ms").map_postgres_err()? as u64,
|
||||
period_start_unix_ms: row
|
||||
.try_get::<Option<i64>, _>("period_start_ms")
|
||||
.map_postgres_err()?
|
||||
.map(|v| v as u64),
|
||||
period_end_unix_ms: row
|
||||
.try_get::<Option<i64>, _>("period_end_ms")
|
||||
.map_postgres_err()?
|
||||
.map(|v| v as u64),
|
||||
note: row.try_get("note").map_postgres_err()?,
|
||||
external_reference: row.try_get("external_reference").map_postgres_err()?,
|
||||
created_by: row.try_get("created_by").map_postgres_err()?,
|
||||
},
|
||||
created_at_unix_ms: row.try_get::<i64, _>("created_ms").map_postgres_err()? as u64,
|
||||
voided_at_unix_ms: row
|
||||
.try_get::<Option<i64>, _>("voided_ms")
|
||||
.map_postgres_err()?
|
||||
.map(|v| v as u64),
|
||||
voided_by: row.try_get("voided_by").map_postgres_err()?,
|
||||
})
|
||||
}
|
||||
fn datetime(ms: u64) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::from_timestamp_millis(ms as i64).expect("validated expense timestamp")
|
||||
}
|
||||
impl SqlxBillingReadRepository {
|
||||
pub(super) async fn expense_page(
|
||||
&self,
|
||||
query: &ProviderExpenseQuery,
|
||||
) -> Result<Option<ProviderExpensePage>, DataLayerError> {
|
||||
query.validate()?;
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let from = datetime(query.from_unix_ms);
|
||||
let to = datetime(query.to_unix_ms);
|
||||
let totals = sqlx::query(r#"
|
||||
SELECT currency, sum(amount)::text AS amount,
|
||||
COALESCE(sum(amount) FILTER (WHERE kind='recharge'),0)::text AS recharge_amount,
|
||||
COALESCE(sum(amount) FILTER (WHERE kind='subscription'),0)::text AS subscription_amount,
|
||||
COALESCE(sum(amount) FILTER (WHERE kind='other'),0)::text AS other_amount,
|
||||
count(*)::bigint AS entry_count
|
||||
FROM provider_expenses
|
||||
WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2
|
||||
GROUP BY currency ORDER BY currency
|
||||
"#)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.map_postgres_err()?
|
||||
.iter()
|
||||
.map(|r| Ok(ProviderExpenseTotals {
|
||||
currency: r.try_get("currency").map_postgres_err()?,
|
||||
amount: r.try_get("amount").map_postgres_err()?,
|
||||
recharge_amount: r.try_get("recharge_amount").map_postgres_err()?,
|
||||
subscription_amount: r.try_get("subscription_amount").map_postgres_err()?,
|
||||
other_amount: r.try_get("other_amount").map_postgres_err()?,
|
||||
entry_count: r.try_get::<i64,_>("entry_count").map_postgres_err()? as u64,
|
||||
}))
|
||||
.collect::<Result<Vec<_>,DataLayerError>>()?;
|
||||
let providers = sqlx::query(
|
||||
r#"
|
||||
SELECT provider_id,
|
||||
(array_agg(provider_name ORDER BY paid_at DESC,id DESC))[1] AS provider_name,
|
||||
currency, sum(amount)::text AS amount, count(*)::bigint AS entry_count
|
||||
FROM provider_expenses
|
||||
WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2
|
||||
GROUP BY provider_id,currency ORDER BY provider_id,currency
|
||||
"#,
|
||||
)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(ProviderExpenseProviderTotal {
|
||||
provider_id: r.try_get("provider_id").map_postgres_err()?,
|
||||
provider_name: r.try_get("provider_name").map_postgres_err()?,
|
||||
currency: r.try_get("currency").map_postgres_err()?,
|
||||
amount: r.try_get("amount").map_postgres_err()?,
|
||||
entry_count: r.try_get::<i64, _>("entry_count").map_postgres_err()? as u64,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, DataLayerError>>()?;
|
||||
let items = sqlx::query(&format!(
|
||||
r#"
|
||||
SELECT {SELECT_FIELDS} FROM provider_expenses
|
||||
WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2
|
||||
ORDER BY paid_at DESC,id DESC LIMIT $3 OFFSET $4
|
||||
"#
|
||||
))
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.bind(i64::from(query.limit))
|
||||
.bind(query.offset as i64)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.iter()
|
||||
.map(row_record)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(Some(ProviderExpensePage {
|
||||
items,
|
||||
total: totals.iter().map(|r| r.entry_count).sum(),
|
||||
totals,
|
||||
providers,
|
||||
}))
|
||||
}
|
||||
pub(super) async fn insert_expense(
|
||||
&self,
|
||||
input: &ProviderExpenseInput,
|
||||
) -> Result<AdminBillingMutationOutcome<ProviderExpenseRecord>, DataLayerError> {
|
||||
if let Err(detail) = input.validate() {
|
||||
return Ok(AdminBillingMutationOutcome::Invalid(detail));
|
||||
}
|
||||
let row = sqlx::query(&format!(
|
||||
r#"
|
||||
INSERT INTO provider_expenses (
|
||||
id,client_request_id,provider_id,provider_name,kind,amount,currency,
|
||||
paid_at,period_start,period_end,note,external_reference,created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6::text::numeric,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT(client_request_id) DO NOTHING RETURNING {SELECT_FIELDS}
|
||||
"#
|
||||
))
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&input.client_request_id)
|
||||
.bind(&input.provider_id)
|
||||
.bind(&input.provider_name)
|
||||
.bind(&input.kind)
|
||||
.bind(&input.amount)
|
||||
.bind(&input.currency)
|
||||
.bind(datetime(input.paid_at_unix_ms))
|
||||
.bind(input.period_start_unix_ms.map(datetime))
|
||||
.bind(input.period_end_unix_ms.map(datetime))
|
||||
.bind(&input.note)
|
||||
.bind(&input.external_reference)
|
||||
.bind(&input.created_by)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
if let Some(row) = row {
|
||||
return Ok(AdminBillingMutationOutcome::Applied(row_record(&row)?));
|
||||
}
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {SELECT_FIELDS} FROM provider_expenses WHERE client_request_id=$1"
|
||||
))
|
||||
.bind(&input.client_request_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let record = row_record(&row)?;
|
||||
Ok(if record.entry.same_request_as(input) {
|
||||
AdminBillingMutationOutcome::Applied(record)
|
||||
} else {
|
||||
AdminBillingMutationOutcome::Invalid(
|
||||
"client_request_id was already used for another expense".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub(super) async fn void_expense(
|
||||
&self,
|
||||
id: &str,
|
||||
operator: Option<&str>,
|
||||
) -> Result<AdminBillingMutationOutcome<ProviderExpenseRecord>, DataLayerError> {
|
||||
let row = sqlx::query(&format!(
|
||||
r#"
|
||||
UPDATE provider_expenses
|
||||
SET voided_by = CASE WHEN voided_at IS NULL THEN $2 ELSE voided_by END,
|
||||
voided_at = COALESCE(voided_at,NOW())
|
||||
WHERE id=$1 RETURNING {SELECT_FIELDS}
|
||||
"#
|
||||
))
|
||||
.bind(id)
|
||||
.bind(operator)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
row.as_ref().map(row_record).transpose().map(|r| {
|
||||
r.map(AdminBillingMutationOutcome::Applied)
|
||||
.unwrap_or(AdminBillingMutationOutcome::NotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,21 @@ use std::pin::Pin;
|
||||
|
||||
use sqlx::{
|
||||
migrate::{AppliedMigration, Migrate, MigrateError, Migrator},
|
||||
query, query_scalar, PgConnection, PgPool,
|
||||
query, query_scalar, Connection, PgConnection, PgPool,
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use aether_data_contracts::PendingMigrationInfo;
|
||||
|
||||
mod cancellation;
|
||||
mod timeouts;
|
||||
use cancellation::MigrationAbortGuard;
|
||||
use timeouts::{with_deadline, MigrationTimeouts};
|
||||
|
||||
pub static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||
const USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_MIGRATION_VERSION: i64 = 20260715000000;
|
||||
const USAGE_SETTLEMENT_DASHBOARD_INDEX_MIGRATION_VERSION: i64 = 20260715130000;
|
||||
const USAGE_STALE_PENDING_CLEANUP_INDEX_MIGRATION_VERSION: i64 = 20260720000000;
|
||||
const INVALID_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_EXISTS_SQL: &str = r#"
|
||||
const INVALID_CONCURRENT_INDEX_EXISTS_SQL: &str = r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class AS index_relation
|
||||
@@ -25,42 +27,10 @@ SELECT EXISTS (
|
||||
JOIN pg_catalog.pg_index AS index_state
|
||||
ON index_state.indexrelid = index_relation.oid
|
||||
WHERE index_namespace.nspname = 'public'
|
||||
AND index_relation.relname = 'idx_usage_legacy_body_ref_cleanup_created_at'
|
||||
AND index_relation.relname = $1
|
||||
AND NOT index_state.indisvalid
|
||||
)
|
||||
"#;
|
||||
const DROP_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_SQL: &str =
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_legacy_body_ref_cleanup_created_at";
|
||||
const INVALID_USAGE_SETTLEMENT_DASHBOARD_INDEX_EXISTS_SQL: &str = r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class AS index_relation
|
||||
JOIN pg_catalog.pg_namespace AS index_namespace
|
||||
ON index_namespace.oid = index_relation.relnamespace
|
||||
JOIN pg_catalog.pg_index AS index_state
|
||||
ON index_state.indexrelid = index_relation.oid
|
||||
WHERE index_namespace.nspname = 'public'
|
||||
AND index_relation.relname = 'idx_usage_settlement_dashboard_cover'
|
||||
AND NOT index_state.indisvalid
|
||||
)
|
||||
"#;
|
||||
const DROP_USAGE_SETTLEMENT_DASHBOARD_INDEX_SQL: &str =
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_settlement_dashboard_cover";
|
||||
const INVALID_USAGE_STALE_PENDING_CLEANUP_INDEX_EXISTS_SQL: &str = r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class AS index_relation
|
||||
JOIN pg_catalog.pg_namespace AS index_namespace
|
||||
ON index_namespace.oid = index_relation.relnamespace
|
||||
JOIN pg_catalog.pg_index AS index_state
|
||||
ON index_state.indexrelid = index_relation.oid
|
||||
WHERE index_namespace.nspname = 'public'
|
||||
AND index_relation.relname = 'idx_usage_stale_pending_created_request'
|
||||
AND NOT index_state.indisvalid
|
||||
)
|
||||
"#;
|
||||
const DROP_USAGE_STALE_PENDING_CLEANUP_INDEX_SQL: &str =
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_stale_pending_created_request";
|
||||
|
||||
pub type BootstrapFuture<'a> = Pin<Box<dyn Future<Output = Result<(), MigrateError>> + 'a>>;
|
||||
|
||||
@@ -93,27 +63,38 @@ pub async fn run_migrations_with_bootstrap(
|
||||
pool: &PgPool,
|
||||
bootstrap: &dyn PostgresMigrationBootstrap,
|
||||
) -> Result<(), MigrateError> {
|
||||
let timeouts = MigrationTimeouts::from_env()?;
|
||||
let mut conn = crate::pool::acquire_postgres_migration_connection(pool).await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = run_migrations_locked(&mut conn, bootstrap).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after migration error"
|
||||
);
|
||||
let mut abort_guard = MigrationAbortGuard::new(pool, &mut conn).await?;
|
||||
let result = async {
|
||||
with_deadline(timeouts.transaction_ms, None, async {
|
||||
timeouts.apply(&mut conn, false).await?;
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
prepare_database_for_startup_locked(&mut conn, bootstrap).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
run_migrations_locked(&mut conn, timeouts).await?;
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.unlock().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
// A dropped SQLx future can leave an active/aborted transaction behind. Do
|
||||
// not issue more SQL or return this connection to the pool on any error.
|
||||
if result.is_err() {
|
||||
abort_guard.abort().await;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
conn.detach().close_hard(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
abort_guard.disarm();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -132,37 +113,41 @@ pub async fn prepare_database_for_startup_with_bootstrap(
|
||||
pool: &PgPool,
|
||||
bootstrap: &dyn PostgresMigrationBootstrap,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let timeouts = MigrationTimeouts::from_env()?;
|
||||
let mut conn = crate::pool::acquire_postgres_migration_connection(pool).await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = prepare_database_for_startup_locked(&mut conn, bootstrap).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after startup preparation error"
|
||||
);
|
||||
}
|
||||
let mut abort_guard = MigrationAbortGuard::new(pool, &mut conn).await?;
|
||||
let result = with_deadline(timeouts.transaction_ms, None, async {
|
||||
timeouts.apply(&mut conn, false).await?;
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
let pending = prepare_database_for_startup_locked(&mut conn, bootstrap).await?;
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.unlock().await?;
|
||||
}
|
||||
Ok(pending)
|
||||
})
|
||||
.await;
|
||||
if result.is_err() {
|
||||
abort_guard.abort().await;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
conn.detach().close_hard(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
abort_guard.disarm();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_migrations_locked(
|
||||
conn: &mut PgConnection,
|
||||
bootstrap: &dyn PostgresMigrationBootstrap,
|
||||
timeouts: MigrationTimeouts,
|
||||
) -> Result<(), MigrateError> {
|
||||
conn.ensure_migrations_table().await?;
|
||||
bootstrap.apply_snapshot(conn, &POSTGRES_MIGRATOR).await?;
|
||||
|
||||
// Snapshot SQL and legacy migrations may change session settings. Restore
|
||||
// the limits before inspecting or applying the next migration.
|
||||
timeouts.apply(conn, false).await?;
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
@@ -208,10 +193,20 @@ async fn run_migrations_locked(
|
||||
total = pending_migrations.len(),
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
lock_timeout_ms = timeouts.lock_ms,
|
||||
migration_timeout_ms = timeouts.execution_ms(migration.no_tx),
|
||||
"applying database migration"
|
||||
);
|
||||
repair_invalid_concurrent_index(conn, migration.version).await?;
|
||||
let elapsed = conn.apply(migration).await?;
|
||||
let elapsed = with_deadline(
|
||||
timeouts.execution_ms(migration.no_tx),
|
||||
Some(migration.version),
|
||||
async {
|
||||
timeouts.apply(conn, migration.no_tx).await?;
|
||||
repair_invalid_concurrent_index(conn, migration.version).await?;
|
||||
conn.apply(migration).await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
info!(
|
||||
current,
|
||||
total = pending_migrations.len(),
|
||||
@@ -234,26 +229,20 @@ async fn repair_invalid_concurrent_index(
|
||||
conn: &mut PgConnection,
|
||||
migration_version: i64,
|
||||
) -> Result<(), MigrateError> {
|
||||
let (index_name, invalid_index_exists_sql, drop_index_sql) = match migration_version {
|
||||
USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_MIGRATION_VERSION => (
|
||||
"idx_usage_legacy_body_ref_cleanup_created_at",
|
||||
INVALID_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_EXISTS_SQL,
|
||||
DROP_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_SQL,
|
||||
),
|
||||
USAGE_SETTLEMENT_DASHBOARD_INDEX_MIGRATION_VERSION => (
|
||||
"idx_usage_settlement_dashboard_cover",
|
||||
INVALID_USAGE_SETTLEMENT_DASHBOARD_INDEX_EXISTS_SQL,
|
||||
DROP_USAGE_SETTLEMENT_DASHBOARD_INDEX_SQL,
|
||||
),
|
||||
USAGE_STALE_PENDING_CLEANUP_INDEX_MIGRATION_VERSION => (
|
||||
"idx_usage_stale_pending_created_request",
|
||||
INVALID_USAGE_STALE_PENDING_CLEANUP_INDEX_EXISTS_SQL,
|
||||
DROP_USAGE_STALE_PENDING_CLEANUP_INDEX_SQL,
|
||||
),
|
||||
// Only these fixed, trusted identifiers may be interpolated into DROP INDEX.
|
||||
let index_name = match migration_version {
|
||||
20260715000000 => "idx_usage_legacy_body_ref_cleanup_created_at",
|
||||
20260715130000 => "idx_usage_settlement_dashboard_cover",
|
||||
20260720000000 => "idx_usage_stale_pending_created_request",
|
||||
20260918000000 => "idx_usage_settlement_dashboard_cover_v2",
|
||||
20260920000000 => "idx_payment_orders_status_credited_user",
|
||||
20260921020000 => "ix_usage_attribution_owner_request",
|
||||
20260921020100 => "ix_usage_analytics_actor_metadata",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let invalid_index_exists: bool = query_scalar(invalid_index_exists_sql)
|
||||
let invalid_index_exists: bool = query_scalar(INVALID_CONCURRENT_INDEX_EXISTS_SQL)
|
||||
.bind(index_name)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if !invalid_index_exists {
|
||||
@@ -265,7 +254,11 @@ async fn repair_invalid_concurrent_index(
|
||||
index = index_name,
|
||||
"dropping invalid index left by an interrupted concurrent migration"
|
||||
);
|
||||
query(drop_index_sql).execute(&mut *conn).await?;
|
||||
query(&format!(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS public.{index_name}"
|
||||
))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -369,7 +362,44 @@ fn validate_applied_migrations(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{all_up_migrations, pending_migrations_from_applied, POSTGRES_MIGRATOR};
|
||||
use super::{
|
||||
all_up_migrations, pending_migrations_from_applied, validate_applied_migrations,
|
||||
POSTGRES_MIGRATOR,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn historical_account_attribution_migration_remains_valid() {
|
||||
let checksum = "16210b169c8fc1e428de0336b836170652014a39a53e966bc3a25d5080c5b7e2532c7aed085b7e186c4c9a0d7ea0ea2f";
|
||||
let historical = sqlx::migrate::AppliedMigration {
|
||||
version: 20260917000000,
|
||||
checksum: (0..checksum.len())
|
||||
.step_by(2)
|
||||
.map(|offset| u8::from_str_radix(&checksum[offset..offset + 2], 16).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
};
|
||||
validate_applied_migrations(std::slice::from_ref(&historical)).unwrap();
|
||||
let embedded = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == historical.version)
|
||||
.unwrap();
|
||||
assert_eq!(embedded.checksum, historical.checksum);
|
||||
assert!(!pending_migrations_from_applied(&[historical])
|
||||
.iter()
|
||||
.any(|migration| migration.version == 20260917000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_applied_migrations_still_block_startup() {
|
||||
let unknown = sqlx::migrate::AppliedMigration {
|
||||
version: 20990101000000,
|
||||
checksum: Vec::new().into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_applied_migrations(&[unknown]),
|
||||
Err(sqlx::migrate::MigrateError::VersionMissing(20990101000000))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_ordered_postgres_migration_sources() {
|
||||
@@ -382,6 +412,21 @@ mod tests {
|
||||
assert_eq!(pending_migrations_from_applied(&[]), all_up_migrations());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_migrations_preserve_gaps_and_do_not_repeat_completed_index_builds() {
|
||||
let applied = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.version != 20260918000000)
|
||||
.map(|migration| sqlx::migrate::AppliedMigration {
|
||||
version: migration.version,
|
||||
checksum: migration.checksum.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pending = pending_migrations_from_applied(&applied);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].version, 20260918000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_scoped_codex_live_permission_migration() {
|
||||
let migration = POSTGRES_MIGRATOR
|
||||
@@ -408,7 +453,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn concurrent_index_migrations_opt_out_of_transactions() {
|
||||
for version in [20260715000000, 20260715130000, 20260720000000] {
|
||||
for version in [
|
||||
20260715000000,
|
||||
20260715130000,
|
||||
20260720000000,
|
||||
20260918000000,
|
||||
20260918000100,
|
||||
20260920000000,
|
||||
20260921020000,
|
||||
20260921020100,
|
||||
] {
|
||||
let migration = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == version)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{migrate::MigrateError, Connection, PgConnection, PgPool};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
use super::timeouts::with_deadline;
|
||||
|
||||
// A client-side timeout does not cancel PostgreSQL's running statement. Use a
|
||||
// separate connection to terminate only our own migration session, including
|
||||
// when the caller drops the migration future. Never borrow from the pool here:
|
||||
// it can have a single connection, currently held by the migration itself.
|
||||
pub(super) struct MigrationAbortGuard {
|
||||
control: Option<PgConnection>,
|
||||
backend: Option<(i32, String)>,
|
||||
}
|
||||
|
||||
impl MigrationAbortGuard {
|
||||
pub async fn new(pool: &PgPool, conn: &mut PgConnection) -> Result<Self, MigrateError> {
|
||||
let (backend, control) = with_deadline(3_000, None, async {
|
||||
let backend = sqlx::query_as::<_, (i32, String)>(
|
||||
"SELECT pid, backend_start::text FROM pg_stat_activity WHERE pid = pg_backend_pid()",
|
||||
)
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
// Reserve the cancellation connection before taking any migration
|
||||
// locks. A full server must fail preparation, not prevent cleanup.
|
||||
let control = PgConnection::connect_with(pool.connect_options().as_ref()).await?;
|
||||
Ok((backend, control))
|
||||
})
|
||||
.await?;
|
||||
Ok(Self {
|
||||
control: Some(control),
|
||||
backend: Some(backend),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disarm(&mut self) {
|
||||
self.backend = None;
|
||||
self.control = None;
|
||||
}
|
||||
|
||||
pub async fn abort(&mut self) {
|
||||
if let Some(task) = self.start_abort() {
|
||||
// Dropping this await leaves the cleanup task running.
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn start_abort(&mut self) -> Option<JoinHandle<()>> {
|
||||
let (pid, started_at) = self.backend.take()?;
|
||||
let mut control = self.control.take()?;
|
||||
let runtime = match tokio::runtime::Handle::try_current() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
warn!(pid, %error, "runtime unavailable to terminate migration session");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(runtime.spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
// backend_start guards PID reuse. Same role + same database
|
||||
// guards against ever signalling an unrelated database user.
|
||||
// The second argument waits at most one second for termination.
|
||||
let terminated = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT pg_terminate_backend(pid, 1000) FROM pg_stat_activity \
|
||||
WHERE pid = $1 AND backend_start = $2::text::timestamptz \
|
||||
AND usename = current_user AND datname = current_database()",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(started_at)
|
||||
.fetch_optional(&mut control)
|
||||
.await?;
|
||||
if terminated == Some(false) {
|
||||
warn!(
|
||||
pid,
|
||||
"migration session did not terminate within the cleanup deadline"
|
||||
);
|
||||
}
|
||||
control.close_hard().await
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => warn!(pid, %error, "failed to terminate migration session"),
|
||||
Err(error) => warn!(pid, %error, "migration session cleanup timed out"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MigrationAbortGuard {
|
||||
fn drop(&mut self) {
|
||||
// SQLx close_on_drop still discards the data connection; the independent
|
||||
// task makes cancellation release server-side DDL locks promptly too.
|
||||
let _ = self.start_abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::{future::Future, io, time::Duration};
|
||||
|
||||
use sqlx::{migrate::MigrateError, PgConnection};
|
||||
|
||||
const LOCK_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_LOCK_TIMEOUT_MS";
|
||||
const TRANSACTION_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_TIMEOUT_MS";
|
||||
const CONCURRENT_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_CONCURRENT_TIMEOUT_MS";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct MigrationTimeouts {
|
||||
pub lock_ms: u32,
|
||||
pub transaction_ms: u32,
|
||||
concurrent_ms: u32,
|
||||
}
|
||||
|
||||
impl MigrationTimeouts {
|
||||
pub fn from_env() -> Result<Self, MigrateError> {
|
||||
Ok(Self {
|
||||
lock_ms: read_timeout(LOCK_TIMEOUT_ENV, 1_000)?,
|
||||
transaction_ms: read_timeout(TRANSACTION_TIMEOUT_ENV, 10_000)?,
|
||||
concurrent_ms: read_timeout(CONCURRENT_TIMEOUT_ENV, 900_000)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execution_ms(self, no_transaction: bool) -> u32 {
|
||||
if no_transaction {
|
||||
self.concurrent_ms
|
||||
} else {
|
||||
self.transaction_ms
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply(
|
||||
self,
|
||||
conn: &mut PgConnection,
|
||||
no_transaction: bool,
|
||||
) -> Result<(), MigrateError> {
|
||||
sqlx::query(
|
||||
"SELECT set_config('lock_timeout', $1, false), \
|
||||
set_config('statement_timeout', $2, false)",
|
||||
)
|
||||
.bind(format!("{}ms", self.lock_ms))
|
||||
.bind(format!("{}ms", self.execution_ms(no_transaction)))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_timeout(name: &str, default: u32) -> Result<u32, MigrateError> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => parse_timeout(name, &value),
|
||||
Err(std::env::VarError::NotPresent) => Ok(default),
|
||||
Err(error) => Err(MigrateError::Source(Box::new(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{name}: {error}"),
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_timeout(name: &str, value: &str) -> Result<u32, MigrateError> {
|
||||
value
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|value| (1..=i32::MAX as u32).contains(value))
|
||||
.ok_or_else(|| {
|
||||
MigrateError::Source(Box::new(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{name} must be milliseconds in 1..=2147483647; migration timeouts cannot be disabled"),
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn with_deadline<T>(
|
||||
milliseconds: u32,
|
||||
version: Option<i64>,
|
||||
operation: impl Future<Output = Result<T, MigrateError>>,
|
||||
) -> Result<T, MigrateError> {
|
||||
match tokio::time::timeout(Duration::from_millis(u64::from(milliseconds)), operation).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
let error = sqlx::Error::Io(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!("database migration exceeded {milliseconds}ms; connection will be closed; retry during a quieter period"),
|
||||
));
|
||||
Err(match version {
|
||||
Some(version) => MigrateError::ExecuteMigration(error, version),
|
||||
None => MigrateError::Execute(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_timeout;
|
||||
|
||||
#[test]
|
||||
fn migration_limits_cannot_be_disabled_or_overflow_postgres() {
|
||||
for invalid in ["0", "-1", "2147483648", "500ms", "", " "] {
|
||||
assert!(parse_timeout("TEST_TIMEOUT", invalid).is_err(), "{invalid}");
|
||||
}
|
||||
assert_eq!(parse_timeout("TEST_TIMEOUT", " 1000 ").unwrap(), 1_000);
|
||||
assert_eq!(
|
||||
parse_timeout("TEST_TIMEOUT", "2147483647").unwrap(),
|
||||
i32::MAX as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -635,7 +635,7 @@ WHERE user_entitlement_id = $1
|
||||
let mut remaining_cost = total_cost_usd;
|
||||
let mut debited = 0.0;
|
||||
for (grant, balance_before) in grants_with_remaining {
|
||||
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
|
||||
if remaining_cost <= 0.0 || balance_before <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let amount = remaining_cost.min(balance_before);
|
||||
@@ -1163,6 +1163,12 @@ WHERE retain_until <= TO_TIMESTAMP($1::double precision)
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: Some(finalized_at as u64),
|
||||
};
|
||||
let mut quota_covered = 0.0_f64;
|
||||
let mut wallet_consumed = 0.0_f64;
|
||||
let mut wallet_debit = 0.0_f64;
|
||||
let mut recharge_debit = 0.0_f64;
|
||||
let mut gift_debit = 0.0_f64;
|
||||
let mut overdraft = 0.0_f64;
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
let api_key_id = input
|
||||
@@ -1305,6 +1311,7 @@ LIMIT 1
|
||||
settlement.billing_status = final_billing_status.clone();
|
||||
0.0
|
||||
} else {
|
||||
quota_covered = quota.debited_usd;
|
||||
(billable_cost_usd - quota.debited_usd).max(0.0)
|
||||
}
|
||||
} else {
|
||||
@@ -1325,7 +1332,7 @@ LIMIT 1
|
||||
return Ok(Some(settlement));
|
||||
}
|
||||
|
||||
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
|
||||
if wallet_debit_cost_usd > 0.0 {
|
||||
if let Some(wallet_row) = wallet_row {
|
||||
let wallet_id: String =
|
||||
wallet_row.try_get("id").map_postgres_err()?;
|
||||
@@ -1346,10 +1353,15 @@ LIMIT 1
|
||||
before_gift,
|
||||
wallet_debit_cost_usd,
|
||||
);
|
||||
recharge_debit = debit_plan.recharge_deduction;
|
||||
gift_debit = debit_plan.gift_deduction;
|
||||
overdraft = debit_plan.recharge_overdraft;
|
||||
wallet_debit = recharge_debit + gift_debit + overdraft;
|
||||
(after_recharge, after_gift) =
|
||||
debit_plan.after_balances(before_recharge, before_gift);
|
||||
}
|
||||
let total_consumed_after = total_consumed + wallet_debit_cost_usd;
|
||||
wallet_consumed = wallet_debit_cost_usd;
|
||||
validate_wallet_settlement_values(
|
||||
after_recharge,
|
||||
after_gift,
|
||||
@@ -1418,6 +1430,29 @@ WHERE id = $1
|
||||
}
|
||||
|
||||
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
|
||||
if final_billing_status == "settled" {
|
||||
sqlx::query(
|
||||
r#"UPDATE usage_settlement_snapshots SET
|
||||
quota_covered_amount_usd = $2::text::numeric(20,8),
|
||||
wallet_consumed_amount_usd = $3::text::numeric(20,8),
|
||||
wallet_debit_amount_usd = $4::text::numeric(20,8),
|
||||
wallet_recharge_debit_usd = $5::text::numeric(20,8),
|
||||
wallet_gift_debit_usd = $6::text::numeric(20,8),
|
||||
wallet_overdraft_usd = $7::text::numeric(20,8),
|
||||
allocation_schema_version = 1, allocation_status = 'complete'
|
||||
WHERE request_id = $1"#,
|
||||
)
|
||||
.bind(&input.request_id)
|
||||
.bind(format!("{quota_covered:.8}"))
|
||||
.bind(format!("{wallet_consumed:.8}"))
|
||||
.bind(format!("{wallet_debit:.8}"))
|
||||
.bind(format!("{recharge_debit:.8}"))
|
||||
.bind(format!("{gift_debit:.8}"))
|
||||
.bind(format!("{overdraft:.8}"))
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
}
|
||||
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
|
||||
.bind(&input.request_id)
|
||||
.bind(&final_billing_status)
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use sqlx::{Postgres, QueryBuilder, Row};
|
||||
|
||||
pub(super) fn analytics_metrics_sql(slow_threshold_ms: u64) -> String {
|
||||
format!(
|
||||
"{},{}",
|
||||
analytics_additive_metrics_sql(slow_threshold_ms),
|
||||
ANALYTICS_NON_ADDITIVE_METRICS_SQL
|
||||
)
|
||||
}
|
||||
|
||||
const ANALYTICS_NON_ADDITIVE_METRICS_SQL: &str = r#"
|
||||
count(DISTINCT actor_user_id)::bigint AS usage_active_users,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p50_ms,
|
||||
percentile_cont(0.90) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p90_ms,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p95_ms,
|
||||
percentile_cont(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p99_ms,
|
||||
percentile_cont(0.90) WITHIN GROUP (ORDER BY first_byte_time_ms) AS first_byte_p90_ms,
|
||||
percentile_cont(0.99) WITHIN GROUP (ORDER BY first_byte_time_ms) AS first_byte_p99_ms
|
||||
"#;
|
||||
|
||||
// Full analytics also describe attribution and token provenance. Lifetime cards
|
||||
// only need usage, pricing, settlement and allocation coverage below.
|
||||
const ANALYTICS_COVERAGE_METRICS_SQL: &str = r#"
|
||||
count(*) FILTER (WHERE usage_available)::bigint AS usage_available_count,
|
||||
count(*) FILTER (WHERE usage_available AND token_source='reported')::bigint AS reported_usage_count,
|
||||
count(*) FILTER (WHERE usage_available AND token_source='estimated')::bigint AS estimated_usage_count,
|
||||
count(*) FILTER (WHERE usage_available AND token_source='mixed')::bigint AS mixed_usage_count,
|
||||
count(*) FILTER (WHERE NOT usage_available OR token_source='unknown')::bigint AS unknown_usage_count,
|
||||
count(*) FILTER (WHERE pricing_available)::bigint AS pricing_available_count,
|
||||
count(*) FILTER (WHERE settlement_status = 'settled')::bigint AS settled_count,
|
||||
count(*) FILTER (WHERE allocation_status = 'complete')::bigint AS allocation_available_count,
|
||||
count(*) FILTER (WHERE actor_user_id IS NOT NULL)::bigint AS trusted_attribution_count,
|
||||
count(*) FILTER (WHERE status = 'failed' AND failure_origin IS NOT NULL AND failure_origin <> 'unknown')::bigint AS classified_failure_count
|
||||
"#;
|
||||
|
||||
pub(super) fn dashboard_total_metrics_sql() -> &'static str {
|
||||
r#"count(*)::bigint AS request_count,
|
||||
COALESCE(sum(total_tokens),0)::bigint AS total_tokens,
|
||||
sum(billable_amount)::text AS billable_amount,
|
||||
count(*) FILTER (WHERE usage_available)::bigint AS usage_available_count,
|
||||
count(*) FILTER (WHERE pricing_available)::bigint AS pricing_available_count,
|
||||
count(*) FILTER (WHERE settlement_status='settled')::bigint AS settled_count,
|
||||
count(*) FILTER (WHERE allocation_status='complete')::bigint AS allocation_available_count"#
|
||||
}
|
||||
|
||||
pub(super) fn analytics_additive_metrics_sql(slow_threshold_ms: u64) -> String {
|
||||
format!(
|
||||
r#"
|
||||
count(*)::bigint AS request_count,
|
||||
count(*) FILTER (WHERE status = 'completed')::bigint AS successful_request_count,
|
||||
count(*) FILTER (WHERE status = 'failed')::bigint AS failed_request_count,
|
||||
count(*) FILTER (WHERE status = 'cancelled')::bigint AS cancelled_request_count,
|
||||
count(*) FILTER (WHERE status NOT IN ('completed','failed','cancelled'))::bigint AS in_flight_request_count,
|
||||
COALESCE(sum(input_tokens),0)::bigint AS input_tokens,
|
||||
COALESCE(sum(output_tokens),0)::bigint AS output_tokens,
|
||||
COALESCE(sum(total_tokens),0)::bigint AS total_tokens,
|
||||
COALESCE(sum(cache_read_input_tokens),0)::bigint AS cache_read_input_tokens,
|
||||
COALESCE(sum(cache_creation_input_tokens),0)::bigint AS cache_creation_input_tokens,
|
||||
count(cache_estimated_full_cost_amount)::bigint AS cache_pricing_available_count,
|
||||
sum(cache_read_cost_amount)::text AS cache_read_cost_amount,
|
||||
sum(cache_creation_cost_amount)::text AS cache_creation_cost_amount,
|
||||
sum(cache_estimated_full_cost_amount)::text AS cache_estimated_full_cost_amount,
|
||||
{ANALYTICS_COVERAGE_METRICS_SQL},
|
||||
count(response_time_ms)::bigint AS latency_sample_count,
|
||||
count(*) FILTER (WHERE response_time_ms >= {slow_threshold_ms})::bigint AS slow_request_count,
|
||||
COALESCE(sum(response_time_ms),0)::double precision AS latency_sum_ms,
|
||||
count(first_byte_time_ms)::bigint AS first_byte_sample_count,
|
||||
COALESCE(sum(first_byte_time_ms),0)::double precision AS first_byte_sum_ms,
|
||||
count(*) FILTER (WHERE upstream_is_stream AND output_tokens > 0 AND response_time_ms > first_byte_time_ms)::bigint AS output_tps_sample_count,
|
||||
COALESCE(sum(output_tokens::double precision * 1000 / NULLIF(response_time_ms - first_byte_time_ms, 0)) FILTER (WHERE upstream_is_stream AND output_tokens > 0 AND response_time_ms > first_byte_time_ms),0)::double precision AS output_tps_sum,
|
||||
sum(rated_amount)::text AS rated_amount,
|
||||
sum(billable_amount)::text AS billable_amount,
|
||||
sum(quota_covered_amount)::text AS quota_covered_amount,
|
||||
sum(wallet_consumed_amount)::text AS wallet_consumed_amount,
|
||||
sum(wallet_debit_amount)::text AS wallet_debit_amount,
|
||||
sum(wallet_recharge_debit_amount)::text AS wallet_recharge_debit_amount,
|
||||
sum(wallet_gift_debit_amount)::text AS wallet_gift_debit_amount,
|
||||
sum(wallet_overdraft_amount)::text AS wallet_overdraft_amount
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(value: Value) -> Result<T, DataLayerError> {
|
||||
serde_json::from_value(value).map_err(|error| {
|
||||
DataLayerError::UnexpectedValue(format!("invalid analytics query result: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn push_analytics_filter(
|
||||
builder: &mut QueryBuilder<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
) {
|
||||
push_analytics_source_filter(builder, query, "public.usage_analytics_facts_v1");
|
||||
}
|
||||
|
||||
pub(super) fn push_analytics_source_filter(
|
||||
builder: &mut QueryBuilder<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
source: &str,
|
||||
) {
|
||||
builder
|
||||
.push(" FROM ")
|
||||
.push(source)
|
||||
.push(" WHERE created_at >= ")
|
||||
.push_bind(
|
||||
DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64)
|
||||
.expect("validated timestamp"),
|
||||
)
|
||||
.push(" AND created_at < ")
|
||||
.push_bind(
|
||||
DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64)
|
||||
.expect("validated timestamp"),
|
||||
)
|
||||
.push(" AND record_kind <> 'session'");
|
||||
for (column, value) in [
|
||||
("actor_user_id", &query.actor_user_id),
|
||||
("credential_owner_id", &query.credential_owner_id),
|
||||
("attribution_kind", &query.attribution_kind),
|
||||
("api_key_id", &query.api_key_id),
|
||||
("model", &query.model),
|
||||
("provider_id", &query.provider_id),
|
||||
("api_format", &query.api_format),
|
||||
("endpoint_kind", &query.endpoint_kind),
|
||||
("request_type", &query.request_type),
|
||||
("status", &query.status),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
builder
|
||||
.push(" AND ")
|
||||
.push(column)
|
||||
.push(" = ")
|
||||
.push_bind(value.clone());
|
||||
}
|
||||
}
|
||||
for (column, value) in [
|
||||
("is_stream", query.is_stream),
|
||||
("has_format_conversion", query.has_format_conversion),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
builder
|
||||
.push(" AND ")
|
||||
.push(column)
|
||||
.push(" = ")
|
||||
.push_bind(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user_filter(builder: &mut QueryBuilder<'_, Postgres>, query: &UsageAnalyticsQuery) {
|
||||
builder.push(" WHERE NOT u.is_deleted");
|
||||
if let Some(user_id) = query
|
||||
.actor_user_id
|
||||
.as_ref()
|
||||
.or(query.credential_owner_id.as_ref())
|
||||
{
|
||||
builder.push(" AND u.id = ").push_bind(user_id.clone());
|
||||
}
|
||||
if let Some(value) = query.user_is_active {
|
||||
builder.push(" AND u.is_active = ").push_bind(value);
|
||||
}
|
||||
if let Some(search) = query.search.as_ref().filter(|value| !value.is_empty()) {
|
||||
let pattern = format!(
|
||||
"%{}%",
|
||||
search
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
builder
|
||||
.push(" AND (u.username ILIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR u.email ILIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
if let Some(value) = query.has_usage {
|
||||
builder.push(if value {
|
||||
" AND a.user_id IS NOT NULL"
|
||||
} else {
|
||||
" AND a.user_id IS NULL"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user_finance_ctes(builder: &mut QueryBuilder<'_, Postgres>, query: &UsageAnalyticsQuery) {
|
||||
// Aggregate each financial source before joining the roster. Joining orders
|
||||
// directly to request facts would multiply both usage and payment amounts.
|
||||
builder.push(r#", wallet_finance AS (
|
||||
SELECT w.user_id,
|
||||
CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.balance::numeric + w.gift_balance::numeric), 8)::text END AS wallet_balance,
|
||||
CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.balance::numeric), 8)::text END AS recharge_balance,
|
||||
CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.gift_balance::numeric), 8)::text END AS gift_balance
|
||||
FROM wallets w JOIN roster r ON r.user_id = w.user_id GROUP BY w.user_id
|
||||
), credited_orders AS MATERIALIZED (
|
||||
SELECT o.id, o.user_id, o.order_no, o.amount_usd, o.payment_method, o.credited_at,
|
||||
CASE WHEN o.payment_method IN ('gift_code', 'admin_grant') THEN 'gift_credit'
|
||||
WHEN o.order_kind = 'plan_purchase' THEN 'plan_purchase'
|
||||
ELSE 'wallet_recharge' END AS kind
|
||||
FROM payment_orders o JOIN roster r ON r.user_id = o.user_id
|
||||
WHERE o.status = 'credited' AND o.credited_at IS NOT NULL
|
||||
AND o.order_kind IN ('wallet_recharge', 'plan_purchase') AND o.credited_at >= "#)
|
||||
.push_bind(DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64).expect("validated"))
|
||||
.push(" AND o.credited_at < ")
|
||||
.push_bind(DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64).expect("validated"))
|
||||
.push(r#"), payment_finance AS (
|
||||
SELECT user_id,
|
||||
round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'wallet_recharge'), 0), 8)::text AS recharge_amount,
|
||||
count(*) FILTER (WHERE kind = 'wallet_recharge')::bigint AS recharge_count,
|
||||
round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'plan_purchase'), 0), 8)::text AS plan_purchase_amount,
|
||||
count(*) FILTER (WHERE kind = 'plan_purchase')::bigint AS plan_purchase_count,
|
||||
round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'gift_credit'), 0), 8)::text AS gift_credit_amount,
|
||||
count(*) FILTER (WHERE kind = 'gift_credit')::bigint AS gift_credit_count
|
||||
FROM credited_orders GROUP BY user_id
|
||||
), enriched AS (
|
||||
SELECT r.*, jsonb_build_object(
|
||||
'wallet_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.wallet_balance END,
|
||||
'recharge_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.recharge_balance END,
|
||||
'gift_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.gift_balance END,
|
||||
'recharge_amount', COALESCE(p.recharge_amount, '0.00000000'),
|
||||
'recharge_count', COALESCE(p.recharge_count, 0),
|
||||
'plan_purchase_amount', COALESCE(p.plan_purchase_amount, '0.00000000'),
|
||||
'plan_purchase_count', COALESCE(p.plan_purchase_count, 0),
|
||||
'gift_credit_amount', COALESCE(p.gift_credit_amount, '0.00000000'),
|
||||
'gift_credit_count', COALESCE(p.gift_credit_count, 0)
|
||||
) AS finance
|
||||
FROM roster r LEFT JOIN wallet_finance w ON w.user_id = r.user_id
|
||||
LEFT JOIN payment_finance p ON p.user_id = r.user_id
|
||||
)"#);
|
||||
}
|
||||
|
||||
fn user_finance_summary_sql() -> String {
|
||||
let mut fields = Vec::new();
|
||||
for field in [
|
||||
"wallet_balance",
|
||||
"recharge_balance",
|
||||
"gift_balance",
|
||||
"recharge_amount",
|
||||
"plan_purchase_amount",
|
||||
"gift_credit_amount",
|
||||
] {
|
||||
// If a wallet has an unsupported currency, never disguise the known USD
|
||||
// subtotal as a complete balance. An empty supported roster is zero.
|
||||
fields.push(format!("'{field}', CASE WHEN count(*) FILTER (WHERE finance->>'{field}' IS NULL) = 0 THEN round(COALESCE(sum((finance->>'{field}')::numeric), 0), 8)::text END"));
|
||||
}
|
||||
for field in ["recharge_count", "plan_purchase_count", "gift_credit_count"] {
|
||||
fields.push(format!(
|
||||
"'{field}', COALESCE(sum((finance->>'{field}')::bigint), 0)::bigint"
|
||||
));
|
||||
}
|
||||
format!("jsonb_build_object({})", fields.join(", "))
|
||||
}
|
||||
|
||||
pub(super) async fn read_analytics_metrics(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
projection_reads: bool,
|
||||
) -> Result<UsageAnalyticsMetrics, DataLayerError> {
|
||||
let metrics_sql = analytics_metrics_sql(query.slow_threshold_ms.unwrap_or(5000));
|
||||
let projected = projection_reads && super::projection_reader::supports_projection(query);
|
||||
let mut builder = QueryBuilder::<Postgres>::new("SELECT to_jsonb(m) AS metrics FROM (SELECT ");
|
||||
builder.push(if projected {
|
||||
ANALYTICS_NON_ADDITIVE_METRICS_SQL
|
||||
} else {
|
||||
&metrics_sql
|
||||
});
|
||||
push_analytics_filter(&mut builder, query);
|
||||
builder.push(") m");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let mut summary: UsageAnalyticsMetrics = decode(row.try_get("metrics").map_postgres_err()?)?;
|
||||
if projected {
|
||||
let mut additive = super::projection_reader::read_additive_summary(tx, query).await?;
|
||||
additive.usage_active_users = summary.usage_active_users;
|
||||
additive.latency_p50_ms = summary.latency_p50_ms;
|
||||
additive.latency_p90_ms = summary.latency_p90_ms;
|
||||
additive.latency_p95_ms = summary.latency_p95_ms;
|
||||
additive.latency_p99_ms = summary.latency_p99_ms;
|
||||
additive.first_byte_p90_ms = summary.first_byte_p90_ms;
|
||||
additive.first_byte_p99_ms = summary.first_byte_p99_ms;
|
||||
summary = additive;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn query_usage_analytics(
|
||||
&self,
|
||||
query: &UsageAnalyticsQuery,
|
||||
) -> Result<StoredUsageAnalytics, DataLayerError> {
|
||||
query.validate()?;
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout = '15s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let state = sqlx::query("SELECT pg_current_snapshot()::text AS revision, NOW() AS generated_at, (SELECT count(*) FROM users WHERE is_active AND NOT is_deleted) AS enabled_users")
|
||||
.fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
let metrics_sql = analytics_metrics_sql(query.slow_threshold_ms.unwrap_or(5000));
|
||||
// The users branch computes a summary over its full filtered roster in
|
||||
// the same statement as the page. An installation-wide summary here
|
||||
// would be discarded and unnecessarily scan request facts again.
|
||||
let mut summary = if query.view == UsageAnalyticsView::Users {
|
||||
UsageAnalyticsMetrics::default()
|
||||
} else {
|
||||
read_analytics_metrics(&mut tx, query, self.overview_projection_reads).await?
|
||||
};
|
||||
summary.enabled_users = state
|
||||
.try_get::<i64, _>("enabled_users")
|
||||
.map_postgres_err()? as u64;
|
||||
let mut result = StoredUsageAnalytics {
|
||||
total: summary.request_count,
|
||||
summary,
|
||||
read_revision: state.try_get("revision").map_postgres_err()?,
|
||||
generated_at: state
|
||||
.try_get::<DateTime<Utc>, _>("generated_at")
|
||||
.map_postgres_err()?
|
||||
.to_rfc3339(),
|
||||
// Source freshness is unknown until a collector watermark is available.
|
||||
data_through: None,
|
||||
..Default::default()
|
||||
};
|
||||
result.unrecoverable_bucket_count = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT bucket_start) FROM (SELECT bucket_start FROM stats_bucket_state WHERE projection_version IN ('overview-v1','overview-v2') AND granularity='hour' AND coverage_status='unrecoverable' UNION ALL SELECT bucket_start FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour' AND unrecoverable) lost WHERE bucket_start < $2 AND bucket_start + INTERVAL '1 hour' > $1")
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64).expect("validated"))
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64).expect("validated"))
|
||||
.fetch_one(&mut *tx).await.map_postgres_err()? as u64;
|
||||
result.coverage = super::projection_reader::read_projection_coverage(
|
||||
&mut tx,
|
||||
query,
|
||||
self.overview_projection_reads,
|
||||
)
|
||||
.await?;
|
||||
if query.view != UsageAnalyticsView::Summary {
|
||||
let mut builder =
|
||||
QueryBuilder::<Postgres>::new("WITH filtered AS MATERIALIZED (SELECT *");
|
||||
push_analytics_filter(&mut builder, query);
|
||||
builder.push(")");
|
||||
let order = if query.descending {
|
||||
" DESC NULLS LAST"
|
||||
} else {
|
||||
" ASC NULLS LAST"
|
||||
};
|
||||
match query.view {
|
||||
UsageAnalyticsView::Timeseries
|
||||
| UsageAnalyticsView::Performance
|
||||
| UsageAnalyticsView::DashboardCharts
|
||||
| UsageAnalyticsView::Breakdown => {
|
||||
let timeseries = query.view != UsageAnalyticsView::Breakdown;
|
||||
let group = if timeseries {
|
||||
let granularity = match query.granularity {
|
||||
UsageAnalyticsGranularity::Hour => "hour",
|
||||
UsageAnalyticsGranularity::Day => "day",
|
||||
};
|
||||
// The IANA name was parsed above; it is still bound as a SQL value.
|
||||
let timezone = if query.granularity == UsageAnalyticsGranularity::Hour {
|
||||
"UTC".into()
|
||||
} else {
|
||||
query.timezone.clone()
|
||||
};
|
||||
builder
|
||||
.push(", dated AS (SELECT *, date_trunc('")
|
||||
.push(granularity)
|
||||
.push("', created_at AT TIME ZONE ")
|
||||
.push_bind(timezone.clone())
|
||||
.push(") AT TIME ZONE ")
|
||||
.push_bind(timezone)
|
||||
.push(" AS bucket FROM filtered)");
|
||||
"bucket"
|
||||
} else {
|
||||
match query.group_by {
|
||||
UsageAnalyticsGroupBy::Model => "model",
|
||||
UsageAnalyticsGroupBy::Provider => "provider_id",
|
||||
UsageAnalyticsGroupBy::ApiKey => "api_key_id",
|
||||
UsageAnalyticsGroupBy::Attribution => "attribution_kind",
|
||||
UsageAnalyticsGroupBy::ApiFormat => "api_format",
|
||||
UsageAnalyticsGroupBy::RequestType => "request_type",
|
||||
}
|
||||
};
|
||||
builder
|
||||
.push(", grouped AS (SELECT ")
|
||||
.push(group)
|
||||
.push(" AS group_id, ")
|
||||
.push(&metrics_sql)
|
||||
.push(if timeseries {
|
||||
" FROM dated GROUP BY "
|
||||
} else {
|
||||
" FROM filtered GROUP BY "
|
||||
})
|
||||
.push(group)
|
||||
.push("), page AS (SELECT * FROM grouped ORDER BY ");
|
||||
if timeseries {
|
||||
builder.push("group_id ASC");
|
||||
} else {
|
||||
builder
|
||||
.push(match query.sort {
|
||||
UsageAnalyticsSort::BillableAmount => "billable_amount::numeric",
|
||||
UsageAnalyticsSort::Tokens => "total_tokens",
|
||||
_ => "request_count",
|
||||
})
|
||||
.push(order)
|
||||
.push(", group_id ASC NULLS LAST");
|
||||
}
|
||||
builder.push(" LIMIT ").push_bind(if timeseries { 10_001 } else { i64::from(query.limit) }).push(" OFFSET ").push_bind(if timeseries { 0 } else { query.offset as i64 })
|
||||
.push(") SELECT (SELECT count(*) FROM grouped) AS total, COALESCE(jsonb_agg(jsonb_build_object('id', group_id::text, 'label', group_id::text, 'bucket_start', ")
|
||||
.push(if timeseries { "to_char(group_id AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')" } else { "NULL" })
|
||||
.push(", 'metrics', to_jsonb(page) - 'group_id')), '[]'::jsonb) AS items FROM page");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.total = row.try_get::<i64, _>("total").map_postgres_err()? as u64;
|
||||
result.rows = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
}
|
||||
UsageAnalyticsView::Users => {
|
||||
let scoped_payments =
|
||||
query.actor_user_id.is_some() || query.credential_owner_id.is_some();
|
||||
let subject = if query.actor_user_id.is_some()
|
||||
|| query.attribution_kind.as_deref() == Some("employee")
|
||||
{
|
||||
"actor_user_id"
|
||||
} else {
|
||||
"credential_owner_id"
|
||||
};
|
||||
builder.push(", aggregated AS (SELECT ").push(subject).push(" AS user_id, max(created_at) AS last_used_at, count(DISTINCT (created_at AT TIME ZONE ")
|
||||
.push_bind(query.timezone.clone()).push(")::date)::bigint AS active_days, ").push(&metrics_sql)
|
||||
.push(" FROM filtered GROUP BY ").push(subject).push("), roster AS (SELECT u.id AS user_id, u.username, u.email, u.is_active, a.last_used_at,")
|
||||
.push(" COALESCE(a.active_days, 0) AS active_days, COALESCE(to_jsonb(a) - 'user_id' - 'last_used_at' - 'active_days', '{}'::jsonb) AS metrics, COALESCE(a.request_count, 0) AS requests, a.billable_amount::numeric AS billable FROM users u LEFT JOIN aggregated a ON a.user_id = u.id");
|
||||
push_user_filter(&mut builder, query);
|
||||
builder.push(")");
|
||||
push_user_finance_ctes(&mut builder, query);
|
||||
builder.push(", page AS (SELECT * FROM enriched ORDER BY ")
|
||||
.push(match query.sort { UsageAnalyticsSort::Requests => "requests", UsageAnalyticsSort::BillableAmount => "billable", UsageAnalyticsSort::LastUsed | UsageAnalyticsSort::StartedAt => "last_used_at", UsageAnalyticsSort::Username => "username", UsageAnalyticsSort::Tokens => "(metrics->>'total_tokens')::bigint", UsageAnalyticsSort::ActiveDays => "active_days" })
|
||||
.push(order).push(", user_id ASC LIMIT ").push_bind(i64::from(query.limit)).push(" OFFSET ").push_bind(query.offset as i64)
|
||||
.push(") SELECT (SELECT count(*) FROM roster) AS total, (SELECT count(*) FROM roster WHERE requests > 0) AS active_user_count, (SELECT count(*) FROM roster WHERE is_active) AS enabled_user_count, ")
|
||||
.push("(SELECT to_jsonb(m) FROM (SELECT ").push(&metrics_sql)
|
||||
.push(" FROM filtered WHERE ").push(subject).push(" IN (SELECT user_id FROM roster)) m) AS summary, ")
|
||||
.push("(SELECT ").push(user_finance_summary_sql()).push(" FROM enriched) AS finance_summary, ")
|
||||
.push("(SELECT COALESCE(jsonb_agg(to_jsonb(p)), '[]'::jsonb) FROM (SELECT id, order_no, kind, round(amount_usd::numeric, 8)::text AS amount, payment_method, to_char(credited_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS credited_at FROM credited_orders ORDER BY credited_at DESC, id ASC LIMIT ")
|
||||
.push_bind(if scoped_payments { i64::from(query.payment_limit.unwrap_or(10)) } else { 0 })
|
||||
.push(" OFFSET ").push_bind(query.payment_offset.unwrap_or(0) as i64)
|
||||
.push(") p) AS payments, (SELECT count(*) FROM credited_orders) AS payment_total, ")
|
||||
.push("COALESCE(jsonb_agg(to_jsonb(page) - 'requests' - 'billable'), '[]'::jsonb) AS items FROM page");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.total = row.try_get::<i64, _>("total").map_postgres_err()? as u64;
|
||||
result.users = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
result.summary = decode(row.try_get("summary").map_postgres_err()?)?;
|
||||
result.summary.enabled_users =
|
||||
row.try_get::<i64, _>("enabled_user_count")
|
||||
.map_postgres_err()? as u64;
|
||||
result.user_summary = Some(UsageAnalyticsUserSummary {
|
||||
user_count: result.total,
|
||||
active_user_count: row
|
||||
.try_get::<i64, _>("active_user_count")
|
||||
.map_postgres_err()? as u64,
|
||||
metrics: result.summary.clone(),
|
||||
});
|
||||
result.user_finance_summary =
|
||||
Some(decode(row.try_get("finance_summary").map_postgres_err()?)?);
|
||||
if scoped_payments {
|
||||
result.user_payments = Some(UsageAnalyticsUserPayments {
|
||||
items: decode(row.try_get("payments").map_postgres_err()?)?,
|
||||
total: row.try_get::<i64, _>("payment_total").map_postgres_err()?
|
||||
as u64,
|
||||
limit: query.payment_limit.unwrap_or(10),
|
||||
offset: query.payment_offset.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
UsageAnalyticsView::Consumption => {
|
||||
builder.push(", page AS (SELECT id, request_id, created_at AS started_at, actor_user_id AS user_id, credential_owner_id, model, provider_name AS provider, provider_id, api_key_id, status, settlement_status, attribution_kind, attribution_source, rated_amount::text, billable_amount::text, quota_covered_amount::text, wallet_consumed_amount::text, wallet_debit_amount::text FROM filtered ORDER BY created_at ").push(order).push(", request_id ASC LIMIT ")
|
||||
.push_bind(i64::from(query.limit)).push(" OFFSET ").push_bind(query.offset as i64)
|
||||
.push(") SELECT COALESCE(jsonb_agg(to_jsonb(page)), '[]'::jsonb) AS items FROM page");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.consumption = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
}
|
||||
UsageAnalyticsView::Summary => unreachable!(),
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
query.view,
|
||||
UsageAnalyticsView::Timeseries
|
||||
| UsageAnalyticsView::Performance
|
||||
| UsageAnalyticsView::DashboardCharts
|
||||
) {
|
||||
fill_usage_analytics_timeseries(query, &mut result.rows);
|
||||
result.total = result.rows.len() as u64;
|
||||
}
|
||||
if matches!(
|
||||
query.view,
|
||||
UsageAnalyticsView::Performance | UsageAnalyticsView::DashboardCharts
|
||||
) {
|
||||
let mut providers = QueryBuilder::<Postgres>::new("SELECT COALESCE(jsonb_agg(jsonb_build_object('id',provider_id,'label',provider_label,'bucket_start',NULL,'metrics',to_jsonb(m)-'provider_id'-'provider_label')), '[]'::jsonb) AS items FROM (SELECT provider_id, max(provider_name) AS provider_label, ");
|
||||
providers.push(&metrics_sql);
|
||||
push_analytics_filter(&mut providers, query);
|
||||
providers.push(" GROUP BY provider_id ORDER BY count(*) DESC,provider_id");
|
||||
if query.view == UsageAnalyticsView::DashboardCharts {
|
||||
providers.push(" LIMIT 10001");
|
||||
}
|
||||
providers.push(") m");
|
||||
let row = providers
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.provider_rows = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
if result.provider_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT
|
||||
&& query.view == UsageAnalyticsView::DashboardCharts
|
||||
{
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"dashboard provider chart exceeds 10000 groups".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if query.view == UsageAnalyticsView::DashboardCharts {
|
||||
let timezone = if query.granularity == UsageAnalyticsGranularity::Hour {
|
||||
"UTC".into()
|
||||
} else {
|
||||
query.timezone.clone()
|
||||
};
|
||||
let mut models = QueryBuilder::<Postgres>::new("WITH filtered AS (SELECT *");
|
||||
push_analytics_filter(&mut models, query);
|
||||
models.push("), dated AS (SELECT *,date_trunc(")
|
||||
.push_bind(if query.granularity==UsageAnalyticsGranularity::Hour {"hour"}else{"day"})
|
||||
.push(",created_at AT TIME ZONE ").push_bind(timezone.clone())
|
||||
.push(") AT TIME ZONE ").push_bind(timezone).push(" AS bucket FROM filtered) SELECT COALESCE(jsonb_agg(jsonb_build_object('id',model,'label',model,'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'),'metrics',to_jsonb(m)-'model'-'bucket')),'[]'::jsonb) AS items FROM (SELECT model,bucket,")
|
||||
.push(&metrics_sql).push(" FROM dated GROUP BY model,bucket ORDER BY bucket,model LIMIT 10001) m");
|
||||
let row = models
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.model_rows = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
if result.model_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"dashboard model chart exceeds 10000 groups; narrow the range".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if query.view == UsageAnalyticsView::Performance {
|
||||
// Aggregate requested models across providers in the same read snapshot.
|
||||
// Like provider rows, retain raw samples for exact percentiles even when
|
||||
// the summary combines verified hourly projections and raw gaps.
|
||||
let mut models = QueryBuilder::<Postgres>::new("SELECT COALESCE(jsonb_agg(jsonb_build_object('id',model,'label',model,'bucket_start',NULL,'metrics',to_jsonb(m)-'model')), '[]'::jsonb) AS items FROM (SELECT model, ");
|
||||
models.push(&metrics_sql);
|
||||
push_analytics_filter(&mut models, query);
|
||||
models.push(" GROUP BY model ORDER BY count(*) DESC,model NULLS LAST) m");
|
||||
let row = models
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.model_rows = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
|
||||
let mut timeline = QueryBuilder::<Postgres>::new("WITH filtered AS (SELECT *");
|
||||
push_analytics_filter(&mut timeline, query);
|
||||
timeline.push("), dated AS (SELECT *, date_trunc(")
|
||||
.push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "hour" } else { "day" })
|
||||
.push(", created_at AT TIME ZONE ").push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "UTC".into() } else { query.timezone.clone() })
|
||||
.push(") AT TIME ZONE ").push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "UTC".into() } else { query.timezone.clone() })
|
||||
.push(" AS bucket FROM filtered) SELECT COALESCE(jsonb_agg(jsonb_build_object('id',provider_id,'label',provider_label,'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'),'metrics',to_jsonb(m)-'provider_id'-'provider_label'-'bucket')),'[]'::jsonb) AS items FROM (SELECT provider_id,max(provider_name) AS provider_label,bucket,")
|
||||
.push(&metrics_sql).push(" FROM dated GROUP BY provider_id,bucket ORDER BY bucket,provider_id) m");
|
||||
let row = timeline
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.provider_timeline_rows = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
let mut errors = QueryBuilder::<Postgres>::new("SELECT COALESCE(jsonb_agg(to_jsonb(m)), '[]'::jsonb) AS items FROM (SELECT COALESCE(failure_reason,error_category,'unknown') AS reason,count(*)::bigint AS count");
|
||||
push_analytics_filter(&mut errors, query);
|
||||
errors.push(" AND status='failed' GROUP BY COALESCE(failure_reason,error_category,'unknown') ORDER BY count(*) DESC) m");
|
||||
let row = errors
|
||||
.build()
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
result.errors = decode(row.try_get("items").map_postgres_err()?)?;
|
||||
}
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::usage::UsageAttributionSnapshot, DataLayerError};
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn correct_usage_attribution(
|
||||
&self,
|
||||
snapshot: &UsageAttributionSnapshot,
|
||||
expected_revision: u64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
snapshot.validate()?;
|
||||
if snapshot.attribution_revision <= expected_revision {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"attribution revision must increase".into(),
|
||||
));
|
||||
}
|
||||
let result = sqlx::query(r#"UPDATE usage_attribution_snapshots SET actor_user_id=$2,
|
||||
credential_owner_id=$3,attribution_kind=$4,attribution_source=$5,
|
||||
record_kind=$6,parent_request_id=$7,schema_version=$8,attribution_revision=$9,recorded_at=NOW()
|
||||
WHERE request_id=$1 AND attribution_revision=$10
|
||||
AND ($2::text IS NULL OR EXISTS (SELECT 1 FROM users WHERE id=$2 AND NOT is_deleted))
|
||||
AND ($3::text IS NULL OR EXISTS (SELECT 1 FROM users WHERE id=$3 AND NOT is_deleted))"#)
|
||||
.bind(&snapshot.request_id).bind(&snapshot.actor_user_id).bind(&snapshot.credential_owner_id)
|
||||
.bind(&snapshot.attribution_kind).bind(&snapshot.attribution_source)
|
||||
.bind(&snapshot.record_kind).bind(&snapshot.parent_request_id).bind(snapshot.schema_version as i32)
|
||||
.bind(snapshot.attribution_revision as i64).bind(expected_revision as i64)
|
||||
.execute(&self.pool).await.map_postgres_err()?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::{
|
||||
analytics::{dashboard_total_metrics_sql, read_analytics_metrics},
|
||||
projection_reader::{read_dashboard_total_summary, read_projection_coverage},
|
||||
SqlxUsageReadRepository,
|
||||
};
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Postgres, QueryBuilder, Row};
|
||||
|
||||
// This projection keeps the canonical billing token precedence while reading usage
|
||||
// once. Joining the two public fact views reads and hashes the entire usage table
|
||||
// twice. Extract both availability flags together so large JSON metadata is parsed
|
||||
// once per row. Live database regressions compare these fields with the canonical view.
|
||||
const DASHBOARD_TOTAL_FACTS_SQL: &str = r#"(
|
||||
SELECT u.created_at, u.api_key_id, u.model, u.provider_id, u.api_format, u.endpoint_kind,
|
||||
u.request_type, u.status, u.is_stream, u.has_format_conversion, u.failure_origin,
|
||||
'request'::text AS record_kind,
|
||||
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
|
||||
COALESCE(availability.usage_available, 'true'::jsonb) <> 'false'::jsonb AS usage_available,
|
||||
COALESCE(availability.usage_pricing_available, 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
|
||||
CASE WHEN COALESCE(availability.usage_available, 'true'::jsonb) <> 'false'::jsonb THEN
|
||||
GREATEST(
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN s.billing_effective_input_tokens IS NOT NULL
|
||||
THEN GREATEST(s.billing_effective_input_tokens, 0)
|
||||
+ GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0)
|
||||
+ GREATEST(
|
||||
COALESCE(
|
||||
s.billing_cache_creation_tokens,
|
||||
CASE
|
||||
WHEN s.billing_cache_creation_5m_tokens IS NOT NULL
|
||||
OR s.billing_cache_creation_1h_tokens IS NOT NULL
|
||||
THEN COALESCE(s.billing_cache_creation_5m_tokens, 0)
|
||||
+ COALESCE(s.billing_cache_creation_1h_tokens, 0)
|
||||
END,
|
||||
CASE
|
||||
WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0
|
||||
AND (
|
||||
COALESCE(u.cache_creation_input_tokens_5m, 0)
|
||||
+ COALESCE(u.cache_creation_input_tokens_1h, 0)
|
||||
) > 0
|
||||
THEN COALESCE(u.cache_creation_input_tokens_5m, 0)
|
||||
+ COALESCE(u.cache_creation_input_tokens_1h, 0)
|
||||
ELSE COALESCE(u.cache_creation_input_tokens, 0)
|
||||
END,
|
||||
0
|
||||
),
|
||||
0
|
||||
)
|
||||
+ GREATEST(
|
||||
COALESCE(
|
||||
s.billing_cache_read_tokens,
|
||||
u.cache_read_input_tokens,
|
||||
0
|
||||
),
|
||||
0
|
||||
)
|
||||
WHEN s.billing_total_input_context IS NOT NULL
|
||||
THEN GREATEST(s.billing_total_input_context, 0)
|
||||
+ GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0)
|
||||
END,
|
||||
NULLIF(GREATEST(COALESCE(u.total_tokens, 0), 0), 0),
|
||||
CASE
|
||||
WHEN split_part(lower(COALESCE(COALESCE(u.endpoint_api_format, u.api_format), '')), ':', 1)
|
||||
IN ('openai', 'gemini', 'google')
|
||||
THEN GREATEST(COALESCE(u.input_tokens, 0), 0)
|
||||
+ GREATEST(COALESCE(u.output_tokens, 0), 0)
|
||||
ELSE GREATEST(COALESCE(u.input_tokens, 0), 0)
|
||||
+ GREATEST(COALESCE(u.output_tokens, 0), 0)
|
||||
+ GREATEST(
|
||||
CASE
|
||||
WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0
|
||||
AND (
|
||||
COALESCE(u.cache_creation_input_tokens_5m, 0)
|
||||
+ COALESCE(u.cache_creation_input_tokens_1h, 0)
|
||||
) > 0
|
||||
THEN COALESCE(u.cache_creation_input_tokens_5m, 0)
|
||||
+ COALESCE(u.cache_creation_input_tokens_1h, 0)
|
||||
ELSE COALESCE(u.cache_creation_input_tokens, 0)
|
||||
END,
|
||||
0
|
||||
)
|
||||
+ GREATEST(COALESCE(u.cache_read_input_tokens, 0), 0)
|
||||
END,
|
||||
0
|
||||
),
|
||||
0
|
||||
)::bigint END AS total_tokens,
|
||||
CASE WHEN COALESCE(availability.usage_pricing_available, 'true'::jsonb) <> 'false'::jsonb
|
||||
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
|
||||
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
|
||||
s.allocation_status
|
||||
FROM public.usage u
|
||||
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
|
||||
CROSS JOIN LATERAL json_to_record(
|
||||
CASE WHEN json_typeof(u.request_metadata)='object' THEN u.request_metadata ELSE '{}'::json END
|
||||
) AS availability(usage_available jsonb, usage_pricing_available jsonb)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.usage_attribution_snapshots a
|
||||
WHERE a.request_id=u.request_id AND a.record_kind='session')
|
||||
) AS usage_analytics_facts_v1"#;
|
||||
|
||||
pub(super) fn push_dashboard_total_filter(
|
||||
builder: &mut QueryBuilder<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
) {
|
||||
super::analytics::push_analytics_source_filter(builder, query, DASHBOARD_TOTAL_FACTS_SQL);
|
||||
}
|
||||
|
||||
/// Lifetime cards only need additive totals and their coverage. Performance and
|
||||
/// active-user metrics remain exact in the separate today snapshot.
|
||||
pub(super) async fn read_dashboard_total_metrics(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
projection_reads: bool,
|
||||
) -> Result<UsageAnalyticsMetrics, DataLayerError> {
|
||||
// A materialized valid-bucket CTE prevents parallel aggregation of raw gaps.
|
||||
// Upgraded installations can have years of raw history and no built buckets;
|
||||
// use the parallel raw query directly until there is a bucket to combine.
|
||||
let has_projection = if projection_reads {
|
||||
sqlx::query_scalar::<_, bool>(r#"SELECT EXISTS (
|
||||
SELECT 1 FROM stats_bucket_state WHERE projection_version='overview-v2'
|
||||
AND granularity='hour' AND source_revision=built_revision AND coverage_status='complete'
|
||||
AND NOT EXISTS (SELECT 1 FROM stats_overview_dirty_events e
|
||||
WHERE e.projection_version=stats_bucket_state.projection_version
|
||||
AND e.granularity=stats_bucket_state.granularity
|
||||
AND e.bucket_start=stats_bucket_state.bucket_start)
|
||||
AND bucket_start = date_trunc('hour', bucket_start AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
AND bucket_start >= $1 AND bucket_start + INTERVAL '1 hour' <= $2
|
||||
AND bucket_start + INTERVAL '1 hour' <= NOW())"#)
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64).expect("validated"))
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64).expect("validated"))
|
||||
.fetch_one(&mut **tx).await.map_postgres_err()?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if has_projection {
|
||||
return read_dashboard_total_summary(tx, query).await;
|
||||
}
|
||||
let mut builder = QueryBuilder::<Postgres>::new("SELECT to_jsonb(m) AS metrics FROM (SELECT ");
|
||||
builder.push(dashboard_total_metrics_sql());
|
||||
push_dashboard_total_filter(&mut builder, query);
|
||||
builder.push(") m");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
serde_json::from_value(row.try_get("metrics").map_postgres_err()?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))
|
||||
}
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn query_dashboard_analytics(
|
||||
&self,
|
||||
query: &UsageDashboardAnalyticsQuery,
|
||||
) -> Result<StoredUsageDashboardAnalytics, DataLayerError> {
|
||||
query.validate()?;
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout='180s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let state=sqlx::query(r#"SELECT date_trunc('milliseconds',NOW()) AS now,pg_current_snapshot()::text AS revision,
|
||||
(SELECT min(created_at) FROM usage WHERE created_at<NOW()) AS raw_from,
|
||||
(SELECT min(bucket_start) FROM (SELECT bucket_start FROM stats_bucket_state WHERE projection_version IN ('overview-v1','overview-v2') AND granularity='hour' UNION ALL SELECT bucket_start FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour') buckets WHERE bucket_start<NOW()) AS state_from,
|
||||
(SELECT CASE WHEN pg_typeof(min(date)) IN ('bigint'::regtype,'integer'::regtype)
|
||||
THEN to_timestamp(min(date)::text::double precision) ELSE min(date)::text::timestamptz END
|
||||
FROM stats_daily WHERE total_requests>0) AS legacy_from,
|
||||
(SELECT count(*) FROM users WHERE is_active AND NOT is_deleted) AS enabled_users"#)
|
||||
.fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
let now: DateTime<Utc> = state.try_get("now").map_postgres_err()?;
|
||||
let raw_from: Option<DateTime<Utc>> = state.try_get("raw_from").map_postgres_err()?;
|
||||
let state_from: Option<DateTime<Utc>> = state.try_get("state_from").map_postgres_err()?;
|
||||
let legacy_from: Option<DateTime<Utc>> = state.try_get("legacy_from").map_postgres_err()?;
|
||||
let total_from = raw_from.into_iter().chain(state_from).min();
|
||||
let today_from = query.today_start(now)?;
|
||||
let revision: String = state.try_get("revision").map_postgres_err()?;
|
||||
let enabled_users = state
|
||||
.try_get::<i64, _>("enabled_users")
|
||||
.map_postgres_err()? as u64;
|
||||
let mut snapshots = Vec::with_capacity(2);
|
||||
for (from, lifetime) in [(today_from, false), (total_from.unwrap_or(now), true)] {
|
||||
let range = UsageAnalyticsQuery {
|
||||
from_unix_ms: from.timestamp_millis().max(0) as u64,
|
||||
to_unix_ms: now.timestamp_millis().max(0) as u64,
|
||||
timezone: query.timezone.clone(),
|
||||
limit: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut summary = if lifetime {
|
||||
read_dashboard_total_metrics(&mut tx, &range, self.overview_projection_reads)
|
||||
.await?
|
||||
} else {
|
||||
read_analytics_metrics(&mut tx, &range, self.overview_projection_reads).await?
|
||||
};
|
||||
summary.enabled_users = enabled_users;
|
||||
let unrecoverable=sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT bucket_start) FROM (SELECT bucket_start FROM stats_bucket_state WHERE projection_version IN ('overview-v1','overview-v2') AND granularity='hour' AND coverage_status='unrecoverable' UNION SELECT bucket_start FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour' AND unrecoverable) pending WHERE bucket_start<$2 AND bucket_start+INTERVAL '1 hour'>$1")
|
||||
.bind(from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64;
|
||||
if summary.request_count == 0 && unrecoverable == 0 {
|
||||
summary.rated_amount = Some("0.00000000".into());
|
||||
summary.billable_amount = Some("0.00000000".into());
|
||||
}
|
||||
let coverage =
|
||||
read_projection_coverage(&mut tx, &range, self.overview_projection_reads).await?;
|
||||
snapshots.push(StoredUsageAnalytics {
|
||||
total: summary.request_count,
|
||||
summary,
|
||||
read_revision: revision.clone(),
|
||||
generated_at: now.to_rfc3339(),
|
||||
unrecoverable_bucket_count: unrecoverable,
|
||||
coverage,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let mut total = snapshots.pop().expect("total");
|
||||
let today = snapshots.pop().expect("today");
|
||||
let known_gap = total.unrecoverable_bucket_count > 0
|
||||
|| legacy_from.is_some_and(|legacy| {
|
||||
raw_from.is_none_or(|raw| legacy.date_naive() < raw.date_naive())
|
||||
});
|
||||
if known_gap && total.summary.request_count == 0 {
|
||||
total.summary.rated_amount = None;
|
||||
total.summary.billable_amount = None;
|
||||
}
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(StoredUsageDashboardAnalytics {
|
||||
today,
|
||||
total,
|
||||
today_from: today_from.to_rfc3339(),
|
||||
total_from: total_from.map(|value| value.to_rfc3339()),
|
||||
to: now.to_rfc3339(),
|
||||
history_complete: known_gap.then_some(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::DataLayerError;
|
||||
use chrono::{DateTime, Timelike, Utc};
|
||||
|
||||
use super::SqlxUsageReadRepository;
|
||||
|
||||
/// Moves expired wide dashboard buckets into the narrow lifetime activity
|
||||
/// projection and removes data that is no longer needed by the dashboard.
|
||||
/// Every invocation is bounded; it never scans or rewrites usage history.
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn maintain_dashboard_projection(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
batch_size: i64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let batch_size = batch_size.clamp(1, 5_000);
|
||||
let cutoff = (now - chrono::Duration::days(35))
|
||||
.with_second(0)
|
||||
.and_then(|value| value.with_nanosecond(0))
|
||||
.expect("valid minute");
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout='5s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL lock_timeout='2s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
// Always acquire aggregate shards before minute rows, like the deferred
|
||||
// correction trigger. Cutoff decisions can straddle a minute boundary;
|
||||
// relying on old/new buckets alone is insufficient to prevent inversion.
|
||||
// Skip busy shards and keep these locks only for the short compaction tx.
|
||||
let shards: Vec<i16> = sqlx::query_scalar(
|
||||
"SELECT shard FROM dashboard_stats_total ORDER BY shard FOR UPDATE SKIP LOCKED",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let compacted = sqlx::query(
|
||||
r#"
|
||||
WITH selected AS MATERIALIZED (
|
||||
SELECT bucket_start, shard, metrics
|
||||
FROM dashboard_stats_minute WHERE bucket_start < $1 AND shard=ANY($3)
|
||||
ORDER BY bucket_start, shard LIMIT $2 FOR UPDATE SKIP LOCKED
|
||||
), preserved AS (
|
||||
INSERT INTO dashboard_activity_minute(bucket_start,shard,request_count)
|
||||
SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0)
|
||||
FROM selected
|
||||
ON CONFLICT(bucket_start,shard) DO NOTHING
|
||||
RETURNING bucket_start
|
||||
)
|
||||
DELETE FROM dashboard_stats_minute wide USING selected
|
||||
WHERE wide.bucket_start=selected.bucket_start AND wide.shard=selected.shard
|
||||
AND (SELECT count(*) FROM preserved)>=0
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff)
|
||||
.bind(batch_size)
|
||||
.bind(&shards)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let mut changed = compacted.rows_affected() > 0;
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout='5s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL lock_timeout='2s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
// Actor counts, user event deltas and per-minute metric details are
|
||||
// only used by the rolling dashboard window. Their retention is
|
||||
// intentionally the same as the wide metric buckets.
|
||||
for table in ["dashboard_actor_minute", "dashboard_user_events_minute"] {
|
||||
// PostgreSQL has no DELETE ... ORDER BY ... LIMIT syntax. Use a
|
||||
// bounded key subquery so each pass remains small.
|
||||
let statement = format!(
|
||||
"DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table} WHERE bucket_start < $1 ORDER BY bucket_start LIMIT $2)"
|
||||
);
|
||||
let deleted = sqlx::query(&statement)
|
||||
.bind(cutoff)
|
||||
.bind(batch_size)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
changed |= deleted.rows_affected() > 0;
|
||||
}
|
||||
|
||||
// Walk the contribution primary key, deleting only rows whose source
|
||||
// usage no longer exists. Advancing the cursor over live rows avoids
|
||||
// repeatedly rescanning a large healthy contribution table.
|
||||
let cursor = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT contributions_cleanup_cursor FROM dashboard_stats_state WHERE singleton FOR UPDATE",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let ids: Vec<String> = match cursor {
|
||||
Some(cursor) => sqlx::query_scalar(
|
||||
"SELECT request_id FROM dashboard_request_contributions WHERE request_id > $1 ORDER BY request_id LIMIT $2",
|
||||
).bind(cursor).bind(batch_size).fetch_all(&mut *tx).await.map_postgres_err()?,
|
||||
None => sqlx::query_scalar(
|
||||
"SELECT request_id FROM dashboard_request_contributions ORDER BY request_id LIMIT $1",
|
||||
).bind(batch_size).fetch_all(&mut *tx).await.map_postgres_err()?,
|
||||
};
|
||||
let next_cursor = ids.last().cloned();
|
||||
if !ids.is_empty() {
|
||||
let deleted = sqlx::query(
|
||||
"DELETE FROM dashboard_request_contributions c\n WHERE c.request_id = ANY($1)\n AND NOT EXISTS (SELECT 1 FROM usage u WHERE u.request_id = c.request_id)",
|
||||
)
|
||||
.bind(&ids)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
changed |= deleted.rows_affected() > 0;
|
||||
}
|
||||
let cursor_value = if ids.len() < batch_size as usize {
|
||||
None
|
||||
} else {
|
||||
next_cursor
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE dashboard_stats_state SET contributions_cleanup_cursor=$1 WHERE singleton",
|
||||
)
|
||||
.bind(cursor_value)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||||
use chrono::{DateTime, NaiveDate, Timelike, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
|
||||
fn decode_metrics(mut value: Value) -> Result<DashboardSummaryMetrics, DataLayerError> {
|
||||
let count = value
|
||||
.get("request_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let priced = value
|
||||
.get("pricing_available_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
value["billable_amount"] = if count == 0 {
|
||||
Value::String("0.00000000".into())
|
||||
} else if priced == 0 {
|
||||
Value::Null
|
||||
} else {
|
||||
// The SQL converts NUMERIC to text before JSON serialization; no f64 step.
|
||||
value.get("billable_amount").cloned().unwrap_or(Value::Null)
|
||||
};
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))
|
||||
}
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn query_dashboard_summary(
|
||||
&self,
|
||||
query: &UsageDashboardAnalyticsQuery,
|
||||
) -> Result<StoredDashboardSummary, DataLayerError> {
|
||||
query.validate()?;
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout='5s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let state = sqlx::query("SELECT stats_since, CURRENT_TIMESTAMP AS now, (SELECT count(*) FROM users WHERE NOT is_deleted) AS users FROM dashboard_stats_state WHERE singleton")
|
||||
.fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
let since: DateTime<Utc> = state.try_get("stats_since").map_postgres_err()?;
|
||||
let now: DateTime<Utc> = state.try_get("now").map_postgres_err()?;
|
||||
let today_start = query.today_start(now)?;
|
||||
let today_from = today_start.max(since);
|
||||
let minute_from = today_from
|
||||
.with_second(0)
|
||||
.and_then(|v| v.with_nanosecond(0))
|
||||
.expect("valid minute");
|
||||
let metrics_rows = sqlx::query(r#"
|
||||
WITH selected AS (
|
||||
SELECT 'total' AS period, metrics FROM dashboard_stats_total
|
||||
UNION ALL
|
||||
SELECT 'today', metrics FROM dashboard_stats_minute WHERE bucket_start >= $1 AND bucket_start <= $2
|
||||
), sums AS (
|
||||
SELECT period, key, sum(value::numeric) AS amount FROM selected
|
||||
CROSS JOIN LATERAL jsonb_each_text(metrics) GROUP BY period,key
|
||||
) SELECT period, jsonb_object_agg(key, CASE WHEN key='billable_amount'
|
||||
THEN to_jsonb(round(amount,8)::text) ELSE to_jsonb(amount) END) AS metrics
|
||||
FROM sums GROUP BY period"#)
|
||||
.bind(minute_from).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?;
|
||||
let mut today = decode_metrics(serde_json::json!({}))?;
|
||||
let mut total = today.clone();
|
||||
for row in metrics_rows {
|
||||
let metrics = decode_metrics(row.try_get("metrics").map_postgres_err()?)?;
|
||||
match row
|
||||
.try_get::<String, _>("period")
|
||||
.map_postgres_err()?
|
||||
.as_str()
|
||||
{
|
||||
"today" => today = metrics,
|
||||
_ => total = metrics,
|
||||
}
|
||||
}
|
||||
today.active_users = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT a.actor_user_id) FROM dashboard_actor_minute a JOIN users u ON u.id=a.actor_user_id AND NOT u.is_deleted WHERE a.bucket_start >= $1 AND a.bucket_start <= $2 AND a.request_count > 0")
|
||||
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64;
|
||||
// Most UTC hours belong to one local day. Only hours straddling a local
|
||||
// midnight (e.g. Kathmandu) need their minute counts read. Lifetime
|
||||
// activity therefore scans narrow hourly counters, not metric JSON.
|
||||
let days = sqlx::query(r#"WITH hours AS MATERIALIZED (
|
||||
SELECT bucket_start,shard,request_count,
|
||||
(bucket_start AT TIME ZONE $1)::date AS day,
|
||||
((bucket_start+INTERVAL '1 hour'-INTERVAL '1 microsecond') AT TIME ZONE $1)::date AS last_day
|
||||
FROM dashboard_activity_hour WHERE request_count>0 AND bucket_start <= $2
|
||||
), daily AS (
|
||||
SELECT day, request_count AS requests FROM hours WHERE day=last_day
|
||||
UNION ALL
|
||||
SELECT (m.bucket_start AT TIME ZONE $1)::date, m.request_count
|
||||
FROM hours h JOIN LATERAL (
|
||||
SELECT bucket_start,request_count FROM dashboard_activity_minute
|
||||
WHERE shard=h.shard AND bucket_start>=h.bucket_start
|
||||
AND bucket_start<h.bucket_start+INTERVAL '1 hour' AND bucket_start<=$2
|
||||
UNION ALL
|
||||
SELECT w.bucket_start,COALESCE((w.metrics->>'request_count')::bigint,0)
|
||||
FROM dashboard_stats_minute w
|
||||
WHERE w.shard=h.shard AND w.bucket_start>=h.bucket_start
|
||||
AND w.bucket_start<h.bucket_start+INTERVAL '1 hour' AND w.bucket_start<=$2
|
||||
AND NOT EXISTS(SELECT 1 FROM dashboard_activity_minute n
|
||||
WHERE n.bucket_start=w.bucket_start AND n.shard=w.shard)
|
||||
OFFSET 0
|
||||
) m ON TRUE WHERE h.day<>h.last_day
|
||||
) SELECT day AS date, sum(requests)::bigint AS requests FROM daily
|
||||
GROUP BY day HAVING sum(requests)>0 ORDER BY day"#)
|
||||
.bind(&query.timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?;
|
||||
let local_today = now
|
||||
.with_timezone(
|
||||
&query
|
||||
.timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?,
|
||||
)
|
||||
.date_naive();
|
||||
let first_heatmap_day = local_today - chrono::Duration::days(364);
|
||||
let active_days = days.len() as u64;
|
||||
let dates: Vec<NaiveDate> = days
|
||||
.iter()
|
||||
.map(|row| row.try_get("date").map_postgres_err())
|
||||
.collect::<Result<_, _>>()?;
|
||||
let consecutive_active_days =
|
||||
dashboard_consecutive_active_days(dates.iter().copied(), local_today);
|
||||
let mut activity_days = Vec::new();
|
||||
for (row, date) in days.into_iter().zip(dates) {
|
||||
if date >= first_heatmap_day {
|
||||
activity_days.push(DashboardActivityDay {
|
||||
date: date.to_string(),
|
||||
requests: row.try_get::<i64, _>("requests").map_postgres_err()?.max(0) as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
let events = sqlx::query("SELECT COALESCE(sum(created_count),0)::bigint AS created, COALESCE(sum(deleted_count),0)::bigint AS deleted FROM dashboard_user_events_minute WHERE bucket_start >= $1 AND bucket_start <= $2")
|
||||
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
let summary = StoredDashboardSummary {
|
||||
stats_since: since.to_rfc3339(),
|
||||
generated_at: now.to_rfc3339(),
|
||||
timezone: query.timezone.clone(),
|
||||
today_from: today_from.to_rfc3339(),
|
||||
window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0,
|
||||
today,
|
||||
total,
|
||||
users: DashboardUserCounts {
|
||||
total: state.try_get::<i64, _>("users").map_postgres_err()?.max(0) as u64,
|
||||
created_today: events
|
||||
.try_get::<i64, _>("created")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
deleted_today: events
|
||||
.try_get::<i64, _>("deleted")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
},
|
||||
active_days,
|
||||
consecutive_active_days,
|
||||
activity_days,
|
||||
};
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(summary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"]
|
||||
async fn live_future_dashboard_is_incremental_idempotent_and_survives_retention() {
|
||||
use futures_util::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
let options = std::env::var("AETHER_TEST_DATABASE_URL")
|
||||
.unwrap()
|
||||
.parse::<sqlx::postgres::PgConnectOptions>()
|
||||
.unwrap();
|
||||
let admin = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let database = format!("future_dashboard_{}", uuid::Uuid::new_v4().simple());
|
||||
sqlx::query(&format!("CREATE DATABASE {database}"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options.database(&database))
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = AssertUnwindSafe(async {
|
||||
crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap();
|
||||
let repo = SqlxUsageReadRepository::new(pool.clone());
|
||||
let query = UsageDashboardAnalyticsQuery { timezone: "Asia/Kathmandu".into() };
|
||||
let empty = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(empty.total.request_count,0);
|
||||
assert_eq!(empty.consecutive_active_days,0);
|
||||
assert_eq!(empty.total.billable_amount.as_deref(),Some("0.00000000"));
|
||||
let since = chrono::DateTime::parse_from_rfc3339(&empty.stats_since).unwrap().with_timezone(&Utc);
|
||||
sqlx::query("INSERT INTO users(id,username,email_verified,is_active) VALUES('future-user','future-user',false,false)").execute(&pool).await.unwrap();
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
for (id,at) in [("old",since-Duration::days(500)),("new",Utc::now()),("second",Utc::now())] {
|
||||
sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,total_cost_usd,created_at,request_metadata,first_byte_time_ms,response_time_ms,upstream_is_stream) VALUES($1,$1,'future-user','m','p','completed','settled',100,20,120,0.25,0.25,$2,'{\"analytics_attribution\":{\"is_standalone\":false},\"upstream_is_stream\":true}',100,800,true)")
|
||||
.bind(id).bind(at).execute(&mut *tx).await.unwrap();
|
||||
}
|
||||
tx.commit().await.unwrap();
|
||||
let first = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(first.total.request_count,2);
|
||||
assert_eq!(first.total.total_tokens,240);
|
||||
assert_eq!(first.today.active_users,1);
|
||||
assert_eq!(first.today.first_byte_sample_count,2);
|
||||
assert_eq!(first.today.response_sample_count,2);
|
||||
assert_eq!(first.today.stream_requests,2);
|
||||
assert_eq!(first.users.total,1,"inactive but nondeleted accounts count");
|
||||
assert_eq!(first.users.created_today,1);
|
||||
assert_eq!(first.active_days,1);
|
||||
assert_eq!(first.consecutive_active_days,1);
|
||||
assert_eq!(first.activity_days.iter().map(|d| d.requests).sum::<u64>(),2);
|
||||
sqlx::query("UPDATE usage SET output_tokens=999 WHERE request_id='old'").execute(&pool).await.unwrap();
|
||||
let mut rollback = pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE usage SET total_tokens=999 WHERE request_id='second'").execute(&mut *rollback).await.unwrap();
|
||||
rollback.rollback().await.unwrap();
|
||||
assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,first.total);
|
||||
sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_input_tokens,billing_effective_input_tokens,billing_output_tokens,billing_cache_read_tokens,billing_cache_creation_tokens,billing_total_input_context,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('new','settled',100,100,30,40,10,150,0.12345678,0.12345678)").execute(&pool).await.unwrap();
|
||||
let settled = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(settled.total.request_count,2);
|
||||
assert_eq!(settled.total.total_tokens,300);
|
||||
assert_eq!(settled.today.cache_read_tokens,40);
|
||||
assert_eq!(settled.today.cache_creation_tokens,10);
|
||||
assert_eq!(settled.today.cache_input_tokens,250);
|
||||
assert_eq!(settled.total.billable_amount.as_deref(),Some("0.37345678"));
|
||||
sqlx::query("UPDATE usage_settlement_snapshots SET billing_actual_total_cost_usd=0.12345678 WHERE request_id='new'").execute(&pool).await.unwrap();
|
||||
assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,settled.total);
|
||||
// Independent usage and settlement transactions converge on the final
|
||||
// canonical fact regardless of which deferred projection acquires its shard first.
|
||||
let usage_update = async {
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE usage SET response_time_ms=900 WHERE request_id='new'")
|
||||
.execute(&mut *tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
};
|
||||
let settlement_update = async {
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE usage_settlement_snapshots SET billing_output_tokens=40 WHERE request_id='new'")
|
||||
.execute(&mut *tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::join!(usage_update,settlement_update);
|
||||
}).await.expect("concurrent writes finish without deadlock");
|
||||
let concurrent = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(concurrent.total.total_tokens,310);
|
||||
assert_eq!(concurrent.today.response_sum_ms,1700.0);
|
||||
// A correction followed by purge in one transaction captures the final fact.
|
||||
let mut purge = pool.begin().await.unwrap();
|
||||
sqlx::query("UPDATE usage_settlement_snapshots SET billing_output_tokens=50 WHERE request_id='new'").execute(&mut *purge).await.unwrap();
|
||||
sqlx::query("DELETE FROM usage WHERE request_id='new'").execute(&mut *purge).await.unwrap();
|
||||
purge.commit().await.unwrap();
|
||||
let retained = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(retained.total.request_count,2);
|
||||
assert_eq!(retained.total.total_tokens,320);
|
||||
assert_eq!(retained.total.billable_amount,settled.total.billable_amount);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id='new'").fetch_one(&pool).await.unwrap(),0,"purged requests retain totals without retaining correction ledgers");
|
||||
sqlx::query("UPDATE users SET is_deleted=true WHERE id='future-user'").execute(&pool).await.unwrap();
|
||||
sqlx::query("DELETE FROM users WHERE id='future-user'").execute(&pool).await.unwrap();
|
||||
let deleted = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(deleted.users.total,0);
|
||||
assert_eq!(deleted.users.deleted_today,1,"soft deletion then purge counts one removal");
|
||||
assert_eq!(deleted.total.request_count,2);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_pending").fetch_one(&pool).await.unwrap(),0);
|
||||
// Kathmandu midnight splits a UTC hour. Only that hour's minute
|
||||
// counters must supply the two separate local activity dates.
|
||||
let midnight = query.today_start(Utc::now()).unwrap()-Duration::days(1);
|
||||
sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1")
|
||||
.bind(midnight-Duration::minutes(2)).execute(&pool).await.unwrap();
|
||||
for (id, at) in [("before-midnight",midnight-Duration::minutes(1)),("after-midnight",midnight+Duration::minutes(1))] {
|
||||
sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at) VALUES($1,$1,'m','p','completed','pending',$2)")
|
||||
.bind(id).bind(at).execute(&pool).await.unwrap();
|
||||
}
|
||||
let boundary = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
let tz: chrono_tz::Tz = query.timezone.parse().unwrap();
|
||||
for at in [midnight-Duration::minutes(1),midnight+Duration::minutes(1)] {
|
||||
let day = at.with_timezone(&tz).date_naive().to_string();
|
||||
assert_eq!(boundary.activity_days.iter().find(|entry| entry.date==day).unwrap().requests,1);
|
||||
}
|
||||
assert_eq!(boundary.active_days,3);
|
||||
assert_eq!(boundary.consecutive_active_days,3);
|
||||
assert_eq!(boundary.total.request_count,4);
|
||||
|
||||
// Upgrade compatibility: old installations contain wide minutes but
|
||||
// no narrow activity minutes. Keep local-calendar history accurate
|
||||
// during partial compaction, including midnight splitting a UTC hour.
|
||||
let historic_midnight = query.today_start(Utc::now()).unwrap()-Duration::days(40);
|
||||
sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1")
|
||||
.bind(historic_midnight-Duration::days(1)).execute(&pool).await.unwrap();
|
||||
for (id,at) in [("historic-before",historic_midnight-Duration::minutes(1)),("historic-after",historic_midnight+Duration::minutes(1))] {
|
||||
sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,created_at,response_time_ms) VALUES($1,$1,'retained-user','m','p','completed','pending',$2,10)")
|
||||
.bind(id).bind(at).execute(&pool).await.unwrap();
|
||||
}
|
||||
sqlx::query("INSERT INTO dashboard_stats_minute(bucket_start,shard,metrics) SELECT date_trunc('minute',created_at),(hashtextextended(request_id,0)&15)::smallint,metrics FROM dashboard_request_contributions WHERE request_id LIKE 'historic-%'").execute(&pool).await.unwrap();
|
||||
sqlx::query("DELETE FROM dashboard_activity_minute WHERE bucket_start < now()-INTERVAL '35 days'").execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count) VALUES($1,0,'old-actor',1)").bind(historic_midnight).execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO dashboard_user_events_minute(bucket_start,shard,created_count) VALUES($1,0,1)").bind(historic_midnight).execute(&pool).await.unwrap();
|
||||
let legacy = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(legacy.active_days,boundary.active_days+2);
|
||||
assert_eq!(legacy.total.request_count,boundary.total.request_count+2);
|
||||
// A delayed correction seeds the old narrow minute before applying its
|
||||
// delta; a later compaction must not overwrite or double its count.
|
||||
sqlx::query("UPDATE usage SET response_time_ms=20 WHERE request_id='historic-before'").execute(&pool).await.unwrap();
|
||||
let corrected = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(corrected.activity_days,legacy.activity_days);
|
||||
repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap();
|
||||
assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().activity_days,legacy.activity_days,"partially compacted history remains complete");
|
||||
let old_correction = async {
|
||||
sqlx::query("UPDATE usage SET response_time_ms=40 WHERE request_id='historic-after'").execute(&pool).await.unwrap();
|
||||
};
|
||||
let compaction = async {
|
||||
repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap();
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5),async {
|
||||
tokio::join!(old_correction,compaction);
|
||||
}).await.expect("old corrections and retention use compatible lock orders");
|
||||
// A busy shard is deliberately deferred to the next maintenance pass.
|
||||
repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap();
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_actor_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_user_events_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0);
|
||||
let compacted = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
let mut expected_total = corrected.total.clone();
|
||||
expected_total.response_sum_ms += 30.0;
|
||||
assert_eq!(compacted.total,expected_total);
|
||||
assert_eq!(compacted.activity_days,legacy.activity_days);
|
||||
assert_eq!(compacted.consecutive_active_days,legacy.consecutive_active_days);
|
||||
sqlx::query("DELETE FROM usage WHERE request_id LIKE 'historic-%'").execute(&pool).await.unwrap();
|
||||
let purged_history = repo.query_dashboard_summary(&query).await.unwrap();
|
||||
assert_eq!(purged_history.total,compacted.total);
|
||||
assert_eq!(purged_history.activity_days,compacted.activity_days);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'historic-%'").fetch_one(&pool).await.unwrap(),0);
|
||||
|
||||
// Orphan ledgers left by the old trigger are removed in bounded PK
|
||||
// pages. Live rows before the orphans must not starve later pages.
|
||||
for id in ["zz-orphan-1","zz-orphan-2","zz-orphan-3"] {
|
||||
sqlx::query("INSERT INTO dashboard_request_contributions(request_id,created_at,metrics) VALUES($1,now(),'{}')").bind(id).execute(&pool).await.unwrap();
|
||||
}
|
||||
sqlx::query("UPDATE dashboard_stats_state SET contributions_cleanup_cursor=NULL").execute(&pool).await.unwrap();
|
||||
repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap();
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'zz-orphan-%'").fetch_one(&pool).await.unwrap(),3);
|
||||
for _ in 0..12 { repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); }
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'zz-orphan-%'").fetch_one(&pool).await.unwrap(),0);
|
||||
assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,purged_history.total);
|
||||
|
||||
// A correction may decide a bucket is still inside retention while a
|
||||
// concurrent cleaner crosses the next minute cutoff. Hold its narrow
|
||||
// row to suspend that correction after it locks the total shard, then
|
||||
// prove maintenance skips the shard instead of locking wide and waiting
|
||||
// on narrow (which would invert the correction's lock order).
|
||||
let edge_now=Utc::now();
|
||||
let edge_at=edge_now-Duration::days(35)+Duration::minutes(1);
|
||||
sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at,response_time_ms) VALUES('retention-edge','retention-edge','m','p','completed','pending',$1,10)")
|
||||
.bind(edge_at).execute(&pool).await.unwrap();
|
||||
let edge_shard:i16=sqlx::query_scalar("SELECT (hashtextextended('retention-edge',0)&15)::smallint").fetch_one(&pool).await.unwrap();
|
||||
let mut blocker=pool.begin().await.unwrap();
|
||||
sqlx::query("SELECT 1 FROM dashboard_activity_minute WHERE bucket_start=date_trunc('minute',$1::timestamptz) AND shard=$2 FOR UPDATE")
|
||||
.bind(edge_at).bind(edge_shard).execute(&mut *blocker).await.unwrap();
|
||||
let correction_pool=pool.clone();
|
||||
let edge_correction=tokio::spawn(async move {
|
||||
sqlx::query("UPDATE usage SET response_time_ms=30 WHERE request_id='retention-edge'")
|
||||
.execute(&correction_pool).await.unwrap();
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2),async {
|
||||
loop {
|
||||
let available=sqlx::query_scalar::<_,i16>("SELECT shard FROM dashboard_stats_total WHERE shard=$1 FOR UPDATE SKIP LOCKED")
|
||||
.bind(edge_shard).fetch_optional(&pool).await.unwrap();
|
||||
if available.is_none() { break; }
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}).await.expect("correction reached its shard lock");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1),repo.maintain_dashboard_projection(edge_now+Duration::minutes(2),1000))
|
||||
.await.expect("retention skips a correction's busy shard at the cutoff boundary").unwrap();
|
||||
blocker.rollback().await.unwrap();
|
||||
edge_correction.await.unwrap();
|
||||
let edge_corrected=repo.query_dashboard_summary(&query).await.unwrap();
|
||||
repo.maintain_dashboard_projection(edge_now+Duration::minutes(2),1000).await.unwrap();
|
||||
assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,edge_corrected.total);
|
||||
assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_minute WHERE bucket_start=date_trunc('minute',$1::timestamptz) AND shard=$2")
|
||||
.bind(edge_at).bind(edge_shard).fetch_one(&pool).await.unwrap(),0);
|
||||
}).catch_unwind().await;
|
||||
pool.close().await;
|
||||
sqlx::query(&format!("DROP DATABASE {database} WITH (FORCE)"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
if let Err(error) = outcome {
|
||||
std::panic::resume_unwind(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use super::SqlxUsageReadRepository;
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::Row;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
pub async fn summarize_health_observations(
|
||||
&self,
|
||||
query: &HealthObservationQuery,
|
||||
) -> Result<HealthObservationSummary, DataLayerError> {
|
||||
if query.from_unix_ms >= query.to_unix_ms
|
||||
|| query.to_unix_ms - query.from_unix_ms > 31 * 86_400_000
|
||||
|| query.segments == 0
|
||||
|| query.segments > 96
|
||||
|| query.to_unix_ms > 253_402_300_799_000
|
||||
{
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"invalid health observation window".into(),
|
||||
));
|
||||
}
|
||||
let (request_object, attempt_object) = match query.object_kind {
|
||||
HealthObservationObjectKind::ApiFormat => ("u.api_format", "u.api_format"),
|
||||
HealthObservationObjectKind::Model => ("u.model", "u.model"),
|
||||
HealthObservationObjectKind::Provider => ("u.provider_id", "c.provider_id"),
|
||||
};
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH observations AS (
|
||||
SELECT {request_object}::text AS object_value, u.created_at AS event_at, 'request' AS kind,
|
||||
u.status, u.failure_origin, u.failure_stage, u.failure_reason, u.response_time_ms
|
||||
FROM usage_analytics_facts_v1 u WHERE u.created_at >= $1 AND u.created_at < $2 AND u.record_kind <> 'session'
|
||||
UNION ALL
|
||||
SELECT {attempt_object}::text AS object_value, c.created_at AS event_at, 'attempt' AS kind,
|
||||
c.status::text, NULL, NULL, NULL, NULL::bigint
|
||||
FROM request_candidates c LEFT JOIN usage_analytics_facts_v1 u ON u.request_id = c.request_id
|
||||
WHERE c.created_at >= $1 AND c.created_at < $2
|
||||
AND (c.status IN ('streaming','success','failed','cancelled') OR (c.status = 'pending' AND c.started_at IS NOT NULL))
|
||||
), scoped AS (
|
||||
SELECT *, LEAST($4 - 1, FLOOR(EXTRACT(EPOCH FROM (event_at - $1)) * 1000 / $5)::integer) AS segment,
|
||||
(status IN ('failed','cancelled') AND ((status = 'cancelled' AND failure_origin = 'client') OR (failure_origin = 'client' AND
|
||||
(failure_stage IN ('authentication','admission') OR failure_reason IN ('invalid_input','invalid_credentials','quota_exceeded','policy_rejection'))))) IS TRUE AS excluded
|
||||
FROM observations WHERE object_value IS NOT NULL AND ($3::text[] IS NULL OR object_value = ANY($3))
|
||||
), aggregates AS (
|
||||
SELECT object_value, segment, GROUPING(object_value) AS all_objects, GROUPING(segment) AS all_segments,
|
||||
count(*) FILTER (WHERE kind = 'request')::bigint AS request_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status = 'completed')::bigint AS succeeded_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status = 'failed')::bigint AS failed_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status NOT IN ('completed','failed','cancelled'))::bigint AS in_progress_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status = 'cancelled')::bigint AS cancelled_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status = 'completed')::bigint AS service_succeeded_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status IN ('failed','cancelled') AND NOT excluded AND failure_origin IN ('gateway','upstream','transport'))::bigint AS service_failed_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND excluded)::bigint AS excluded_count,
|
||||
count(*) FILTER (WHERE kind = 'request' AND status IN ('failed','cancelled') AND NOT excluded AND (failure_origin IS NULL OR failure_origin NOT IN ('gateway','upstream','transport')))::bigint AS unknown_failure_count,
|
||||
count(*) FILTER (WHERE kind = 'attempt' AND status = 'success')::bigint AS attempt_succeeded_count,
|
||||
count(*) FILTER (WHERE kind = 'attempt' AND status = 'failed')::bigint AS attempt_failed_count,
|
||||
count(*) FILTER (WHERE kind = 'attempt' AND status IN ('pending','streaming'))::bigint AS attempt_in_progress_count,
|
||||
count(*) FILTER (WHERE kind = 'attempt' AND status = 'cancelled')::bigint AS attempt_cancelled_count,
|
||||
COALESCE(sum(response_time_ms) FILTER (WHERE kind = 'request'),0)::double precision AS latency_sum_ms,
|
||||
count(response_time_ms) FILTER (WHERE kind = 'request')::bigint AS latency_sample_count,
|
||||
(EXTRACT(EPOCH FROM max(event_at) FILTER (WHERE kind = 'request')) * 1000)::bigint AS last_request_at_unix_ms
|
||||
FROM scoped GROUP BY GROUPING SETS ((object_value,segment),(object_value),(segment),())
|
||||
) SELECT object_value, segment, all_objects, all_segments,
|
||||
to_jsonb(aggregates) - 'object_value' - 'segment' - 'all_objects' - 'all_segments' AS metrics FROM aggregates
|
||||
"#
|
||||
);
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout = '15s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let width = (query.to_unix_ms - query.from_unix_ms).div_ceil(u64::from(query.segments));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(
|
||||
DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64)
|
||||
.expect("validated timestamp"),
|
||||
)
|
||||
.bind(
|
||||
DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64)
|
||||
.expect("validated timestamp"),
|
||||
)
|
||||
.bind(query.object_values.as_ref())
|
||||
.bind(query.segments as i32)
|
||||
.bind(width as f64)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let make_timeline = || {
|
||||
(0..query.segments)
|
||||
.filter_map(|segment| {
|
||||
let from = query.from_unix_ms + u64::from(segment) * width;
|
||||
(from < query.to_unix_ms).then(|| HealthObservationBucket {
|
||||
from_unix_ms: from,
|
||||
to_unix_ms: (from + width).min(query.to_unix_ms),
|
||||
metrics: Default::default(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut result = HealthObservationSummary {
|
||||
timeline: make_timeline(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut objects: BTreeMap<String, HealthObservationObject> = BTreeMap::new();
|
||||
for row in rows {
|
||||
let metrics: HealthObservationMetrics =
|
||||
serde_json::from_value(row.try_get("metrics").map_postgres_err()?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?;
|
||||
let all_objects: i32 = row.try_get("all_objects").map_postgres_err()?;
|
||||
let all_segments: i32 = row.try_get("all_segments").map_postgres_err()?;
|
||||
let segment: Option<i32> = row.try_get("segment").map_postgres_err()?;
|
||||
if all_objects == 1 {
|
||||
if all_segments == 1 {
|
||||
result.overall = metrics;
|
||||
} else if let Some(bucket) =
|
||||
segment.and_then(|segment| result.timeline.get_mut(segment as usize))
|
||||
{
|
||||
bucket.metrics = metrics;
|
||||
}
|
||||
} else {
|
||||
let value: String = row.try_get("object_value").map_postgres_err()?;
|
||||
let object =
|
||||
objects
|
||||
.entry(value.clone())
|
||||
.or_insert_with(|| HealthObservationObject {
|
||||
object_value: value,
|
||||
metrics: Default::default(),
|
||||
timeline: make_timeline(),
|
||||
});
|
||||
if all_segments == 1 {
|
||||
object.metrics = metrics;
|
||||
} else if let Some(bucket) =
|
||||
segment.and_then(|segment| object.timeline.get_mut(segment as usize))
|
||||
{
|
||||
bucket.metrics = metrics;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.objects = objects.into_values().collect();
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,20 @@ use aether_data_contracts::repository::usage::{
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
|
||||
mod analytics;
|
||||
#[cfg(test)]
|
||||
mod analytics_tests;
|
||||
mod attribution;
|
||||
pub mod cleanup;
|
||||
mod dashboard;
|
||||
mod dashboard_summary;
|
||||
mod dashboard_retention;
|
||||
#[cfg(test)]
|
||||
mod dashboard_summary_tests;
|
||||
mod health;
|
||||
mod overview_buckets;
|
||||
mod preparation;
|
||||
mod projection_reader;
|
||||
|
||||
use preparation::prepare_usage_in_background;
|
||||
|
||||
@@ -70,6 +82,62 @@ const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT CASE WHEN octet_length(p
|
||||
const DELETE_USAGE_BODY_BLOB_SQL: &str = include_str!("queries/delete_usage_body_blob_sql.sql");
|
||||
static USAGE_BODY_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
|
||||
fn push_usage_analytics_drilldown(
|
||||
builder: &mut QueryBuilder<'_, Postgres>,
|
||||
has_where: &mut bool,
|
||||
provider_id: &Option<String>,
|
||||
api_key_id: &Option<String>,
|
||||
request_id: &Option<String>,
|
||||
attribution_kind: &Option<String>,
|
||||
actor_user_id: &Option<String>,
|
||||
endpoint_kind: &Option<String>,
|
||||
request_type: &Option<String>,
|
||||
slow_threshold_ms: Option<u64>,
|
||||
has_format_conversion: Option<bool>,
|
||||
) {
|
||||
for (column, value) in [
|
||||
("provider_id", provider_id),
|
||||
("api_key_id", api_key_id),
|
||||
("request_id", request_id),
|
||||
("endpoint_kind", endpoint_kind),
|
||||
("request_type", request_type),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
builder.push(if *has_where { " AND " } else { " WHERE " });
|
||||
*has_where = true;
|
||||
builder
|
||||
.push("\"usage\".")
|
||||
.push(column)
|
||||
.push(" = ")
|
||||
.push_bind(value.clone());
|
||||
}
|
||||
}
|
||||
for (column, value) in [
|
||||
("attribution_kind", attribution_kind),
|
||||
("actor_user_id", actor_user_id),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
builder.push(if *has_where { " AND " } else { " WHERE " });
|
||||
*has_where = true;
|
||||
builder.push("EXISTS(SELECT 1 FROM usage_analytics_facts_v1 f WHERE f.request_id=\"usage\".request_id AND f.").push(column).push(" = ").push_bind(value.clone()).push(")");
|
||||
}
|
||||
}
|
||||
if let Some(value) = slow_threshold_ms {
|
||||
builder.push(if *has_where { " AND " } else { " WHERE " });
|
||||
*has_where = true;
|
||||
builder
|
||||
.push("\"usage\".response_time_ms >= ")
|
||||
.push_bind(i64::try_from(value).unwrap_or(i64::MAX));
|
||||
}
|
||||
if let Some(value) = has_format_conversion {
|
||||
builder.push(if *has_where { " AND " } else { " WHERE " });
|
||||
*has_where = true;
|
||||
builder
|
||||
.push("\"usage\".has_format_conversion = ")
|
||||
.push_bind(value);
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_usage_body_in_background(
|
||||
decode: impl FnOnce() -> Result<Option<Value>, DataLayerError> + Send + 'static,
|
||||
) -> Result<Option<Value>, DataLayerError> {
|
||||
@@ -2073,6 +2141,7 @@ WHERE request_id = $1
|
||||
pub struct SqlxUsageReadRepository {
|
||||
pool: PgPool,
|
||||
tx_runner: PostgresTransactionRunner,
|
||||
overview_projection_reads: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -2301,7 +2370,23 @@ fn partition_first_byte_usages(
|
||||
impl SqlxUsageReadRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
let tx_runner = PostgresTransactionRunner::new(pool.clone());
|
||||
Self { pool, tx_runner }
|
||||
let overview_projection_reads = !std::env::var("AETHER_OVERVIEW_PROJECTION_READS")
|
||||
.is_ok_and(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"false" | "0" | "off"
|
||||
)
|
||||
});
|
||||
Self {
|
||||
pool,
|
||||
tx_runner,
|
||||
overview_projection_reads,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_overview_projection_reads(mut self, enabled: bool) -> Self {
|
||||
self.overview_projection_reads = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
@@ -3053,6 +3138,19 @@ ORDER BY request_count DESC, "usage".provider_name ASC
|
||||
.push_bind(created_until_unix_secs as f64)
|
||||
.push("::double precision)");
|
||||
}
|
||||
push_usage_analytics_drilldown(
|
||||
&mut builder,
|
||||
&mut has_where,
|
||||
&query.provider_id,
|
||||
&query.api_key_id,
|
||||
&query.request_id,
|
||||
&query.attribution_kind,
|
||||
&query.actor_user_id,
|
||||
&query.endpoint_kind,
|
||||
&query.request_type,
|
||||
query.slow_threshold_ms,
|
||||
query.has_format_conversion,
|
||||
);
|
||||
if let Some(user_id) = query.user_id.as_deref() {
|
||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||
has_where = true;
|
||||
@@ -3166,6 +3264,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> ''
|
||||
.push_bind(created_until_unix_secs as f64)
|
||||
.push("::double precision)");
|
||||
}
|
||||
push_usage_analytics_drilldown(
|
||||
&mut builder,
|
||||
&mut has_where,
|
||||
&query.provider_id,
|
||||
&query.api_key_id,
|
||||
&query.request_id,
|
||||
&query.attribution_kind,
|
||||
&query.actor_user_id,
|
||||
&query.endpoint_kind,
|
||||
&query.request_type,
|
||||
query.slow_threshold_ms,
|
||||
query.has_format_conversion,
|
||||
);
|
||||
if let Some(user_id) = query.user_id.as_deref() {
|
||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||
has_where = true;
|
||||
@@ -3360,6 +3471,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> ''
|
||||
.push_bind(created_until_unix_secs as f64)
|
||||
.push("::double precision)");
|
||||
}
|
||||
push_usage_analytics_drilldown(
|
||||
&mut builder,
|
||||
&mut has_where,
|
||||
&query.provider_id,
|
||||
&query.api_key_id,
|
||||
&query.request_id,
|
||||
&query.attribution_kind,
|
||||
&query.actor_user_id,
|
||||
&query.endpoint_kind,
|
||||
&query.request_type,
|
||||
query.slow_threshold_ms,
|
||||
query.has_format_conversion,
|
||||
);
|
||||
if let Some(user_id) = query.user_id.as_deref() {
|
||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||
has_where = true;
|
||||
@@ -3462,6 +3586,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> ''
|
||||
.push_bind(created_until_unix_secs as f64)
|
||||
.push("::double precision)");
|
||||
}
|
||||
push_usage_analytics_drilldown(
|
||||
&mut builder,
|
||||
&mut has_where,
|
||||
&query.provider_id,
|
||||
&query.api_key_id,
|
||||
&query.request_id,
|
||||
&query.attribution_kind,
|
||||
&query.actor_user_id,
|
||||
&query.endpoint_kind,
|
||||
&query.request_type,
|
||||
query.slow_threshold_ms,
|
||||
query.has_format_conversion,
|
||||
);
|
||||
if let Some(user_id) = query.user_id.as_deref() {
|
||||
builder.push(if has_where { " AND " } else { " WHERE " });
|
||||
has_where = true;
|
||||
@@ -10340,6 +10477,39 @@ RETURNING
|
||||
|
||||
#[async_trait]
|
||||
impl UsageReadRepository for SqlxUsageReadRepository {
|
||||
async fn query_dashboard_summary(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::StoredDashboardSummary, DataLayerError> {
|
||||
Self::query_dashboard_summary(self, query).await
|
||||
}
|
||||
|
||||
async fn query_dashboard_analytics(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery,
|
||||
) -> Result<
|
||||
aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics,
|
||||
DataLayerError,
|
||||
> {
|
||||
Self::query_dashboard_analytics(self, query).await
|
||||
}
|
||||
|
||||
async fn summarize_health_observations(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::HealthObservationQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::HealthObservationSummary, DataLayerError>
|
||||
{
|
||||
SqlxUsageReadRepository::summarize_health_observations(self, query).await
|
||||
}
|
||||
|
||||
async fn query_usage_analytics(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageAnalyticsQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::StoredUsageAnalytics, DataLayerError>
|
||||
{
|
||||
SqlxUsageReadRepository::query_usage_analytics(self, query).await
|
||||
}
|
||||
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use super::{analytics::analytics_additive_metrics_sql, SqlxUsageReadRepository};
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::Row;
|
||||
|
||||
impl SqlxUsageReadRepository {
|
||||
/// Move committed writer-owned events into bucket state in one transaction.
|
||||
/// Readers exclude both queued and merged dirtiness in their fact snapshot.
|
||||
/// Never acknowledge a high-water mark: transaction IDs can commit out of order.
|
||||
pub async fn merge_overview_dirty_events(&self) -> Result<u64, DataLayerError> {
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout = '15s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let acquired: bool = sqlx::query_scalar(
|
||||
"SELECT pg_try_advisory_xact_lock(hashtextextended('overview-dirty-merge', 19))",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
if !acquired {
|
||||
tx.rollback().await.map_postgres_err()?;
|
||||
return Ok(0);
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
r#"WITH selected AS MATERIALIZED (
|
||||
SELECT transaction_id,projection_version,granularity,bucket_start
|
||||
FROM stats_overview_dirty_events
|
||||
WHERE projection_version='overview-v2'
|
||||
ORDER BY transaction_id,projection_version,granularity,bucket_start
|
||||
LIMIT 10000 FOR UPDATE SKIP LOCKED
|
||||
), removed AS (
|
||||
DELETE FROM stats_overview_dirty_events e USING selected s
|
||||
WHERE (e.transaction_id,e.projection_version,e.granularity,e.bucket_start)
|
||||
= (s.transaction_id,s.projection_version,s.granularity,s.bucket_start)
|
||||
RETURNING e.*
|
||||
), merged AS (
|
||||
INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,
|
||||
source_revision,coverage_status,last_error)
|
||||
SELECT projection_version,granularity,bucket_start,count(*),
|
||||
CASE WHEN bool_or(unrecoverable) THEN 'unrecoverable' ELSE 'unbuilt' END,
|
||||
CASE WHEN bool_or(unrecoverable) THEN 'retained usage facts were deleted' END
|
||||
FROM removed GROUP BY projection_version,granularity,bucket_start
|
||||
ON CONFLICT (projection_version,granularity,bucket_start) DO UPDATE SET
|
||||
source_revision=stats_bucket_state.source_revision+EXCLUDED.source_revision,
|
||||
coverage_status=CASE WHEN EXCLUDED.coverage_status='unrecoverable'
|
||||
THEN 'unrecoverable' ELSE stats_bucket_state.coverage_status END,
|
||||
last_error=CASE WHEN EXCLUDED.coverage_status='unrecoverable'
|
||||
THEN EXCLUDED.last_error ELSE stats_bucket_state.last_error END
|
||||
RETURNING 1
|
||||
) SELECT count(*) FROM removed"#,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
/// Rebuild buckets marked dirty by normal writes without backfilling historical facts.
|
||||
pub async fn rebuild_overview_buckets(
|
||||
&self,
|
||||
target: DateTime<Utc>,
|
||||
budget: usize,
|
||||
) -> Result<usize, DataLayerError> {
|
||||
let budget = budget.min(48);
|
||||
if budget == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let merged_events = self.merge_overview_dirty_events().await?;
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT granularity,bucket_start FROM stats_bucket_state
|
||||
WHERE projection_version='overview-v2' AND source_revision > built_revision
|
||||
AND source_revision > 0
|
||||
AND coverage_status <> 'unrecoverable'
|
||||
AND (last_failed_at IS NULL OR last_failed_at < NOW() - INTERVAL '10 minutes')
|
||||
AND bucket_start + ('1 ' || granularity)::interval <= $1
|
||||
ORDER BY last_failed_at NULLS FIRST,bucket_start,granularity LIMIT $2"#,
|
||||
)
|
||||
.bind(target)
|
||||
.bind(budget as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let mut published = 0;
|
||||
for row in rows {
|
||||
let granularity: String = row.try_get("granularity").map_postgres_err()?;
|
||||
let bucket: DateTime<Utc> = row.try_get("bucket_start").map_postgres_err()?;
|
||||
match self
|
||||
.rebuild_merged_overview_bucket(&granularity, bucket)
|
||||
.await
|
||||
{
|
||||
Ok(true) => published += 1,
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
sqlx::query("UPDATE stats_bucket_state SET last_error=$3,last_failed_at=NOW() WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2")
|
||||
.bind(&granularity).bind(bucket).bind(error.to_string().chars().take(500).collect::<String>())
|
||||
.execute(&self.pool).await.map_postgres_err()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A busy current hour has no closed bucket to publish yet. Still report
|
||||
// progress so the bounded maintenance catch-up loop drains the next batch.
|
||||
Ok(published.max(usize::from(merged_events > 0)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn rebuild_overview_bucket(
|
||||
&self,
|
||||
granularity: &str,
|
||||
bucket: DateTime<Utc>,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
self.merge_overview_dirty_events().await?;
|
||||
self.rebuild_merged_overview_bucket(granularity, bucket)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rebuild_merged_overview_bucket(
|
||||
&self,
|
||||
granularity: &str,
|
||||
bucket: DateTime<Utc>,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let (table, duration) = match granularity {
|
||||
"hour" => ("stats_overview_hourly", chrono::Duration::hours(1)),
|
||||
"day" => ("stats_overview_daily", chrono::Duration::days(1)),
|
||||
_ => {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"invalid overview bucket granularity".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL statement_timeout = '15s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sqlx::query("SET LOCAL lock_timeout = '1s'")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let acquired: bool =
|
||||
sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(hashtextextended($1, 19))")
|
||||
.bind(format!("overview-v2:{granularity}:{}", bucket.timestamp()))
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
if !acquired {
|
||||
tx.rollback().await.map_postgres_err()?;
|
||||
return Ok(false);
|
||||
}
|
||||
let revision: Option<i64> = sqlx::query_scalar("SELECT source_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2")
|
||||
.bind(granularity).bind(bucket).fetch_optional(&mut *tx).await.map_postgres_err()?;
|
||||
let Some(revision) = revision else {
|
||||
// Another merger may be running, or a bounded batch has not reached this bucket yet.
|
||||
tx.rollback().await.map_postgres_err()?;
|
||||
return Ok(false);
|
||||
};
|
||||
let lost: bool = sqlx::query_scalar("SELECT coverage_status='unrecoverable' FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2")
|
||||
.bind(granularity).bind(bucket).fetch_one(&mut *tx).await.map_postgres_err()?;
|
||||
if lost {
|
||||
tx.rollback().await.map_postgres_err()?;
|
||||
return Ok(false);
|
||||
}
|
||||
sqlx::query(&format!(
|
||||
"DELETE FROM {table} WHERE projection_version='overview-v2' AND bucket_start=$1"
|
||||
))
|
||||
.bind(bucket)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let sql = format!(
|
||||
r#"INSERT INTO {table}(projection_version,bucket_start,dimensions,metrics)
|
||||
SELECT 'overview-v2',$1,dimensions,to_jsonb(m) - 'dimensions'
|
||||
FROM (SELECT dimensions, {} FROM (
|
||||
SELECT *, jsonb_build_object('attribution_kind',attribution_kind,'actor_user_id',actor_user_id,
|
||||
'credential_owner_id',credential_owner_id,'api_key_id',api_key_id,'model',model,
|
||||
'provider_id',provider_id,'api_format',api_format,'request_type',request_type,'record_kind',record_kind) dimensions
|
||||
FROM usage_analytics_facts_v1 WHERE created_at >= $1 AND created_at < $2 AND record_kind <> 'session'
|
||||
) facts GROUP BY dimensions) m"#,
|
||||
analytics_additive_metrics_sql(5000)
|
||||
);
|
||||
sqlx::query(&sql)
|
||||
.bind(bucket)
|
||||
.bind(bucket + duration)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let published = sqlx::query("UPDATE stats_bucket_state SET built_revision=$3,coverage_status='complete',built_at=NOW(),last_error=NULL,last_failed_at=NULL WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2 AND source_revision=$3")
|
||||
.bind(granularity).bind(bucket).bind(revision).execute(&mut *tx).await.map_postgres_err()?.rows_affected();
|
||||
if published != 1 {
|
||||
tx.rollback().await.map_postgres_err()?;
|
||||
return Ok(false);
|
||||
}
|
||||
tx.commit().await.map_postgres_err()?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use super::analytics::{
|
||||
analytics_additive_metrics_sql, dashboard_total_metrics_sql, push_analytics_filter,
|
||||
};
|
||||
use crate::error::SqlxResultExt;
|
||||
use aether_data_contracts::{DataLayerError, repository::usage::*};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Postgres, QueryBuilder, Row};
|
||||
|
||||
pub(super) async fn read_projection_coverage(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
read_enabled: bool,
|
||||
) -> Result<UsageAnalyticsProjectionCoverage, DataLayerError> {
|
||||
let row = sqlx::query(r#"WITH rounded AS (
|
||||
SELECT date_trunc('hour',$1::timestamptz AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS start_at,
|
||||
date_trunc('hour',LEAST($2::timestamptz,NOW()) AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS end_at
|
||||
), bounds AS (
|
||||
SELECT CASE WHEN start_at<$1 THEN start_at+INTERVAL '1 hour' ELSE start_at END AS start_at,end_at FROM rounded
|
||||
), pending AS (
|
||||
SELECT e.bucket_start,bool_or(e.unrecoverable) AS unrecoverable
|
||||
FROM stats_overview_dirty_events e CROSS JOIN bounds b
|
||||
WHERE e.projection_version='overview-v2' AND e.granularity='hour'
|
||||
AND e.bucket_start>=b.start_at AND e.bucket_start<b.end_at GROUP BY e.bucket_start
|
||||
), stored AS (
|
||||
SELECT s.* FROM stats_bucket_state s CROSS JOIN bounds b
|
||||
WHERE s.projection_version='overview-v2' AND s.granularity='hour'
|
||||
AND s.bucket_start>=b.start_at AND s.bucket_start<b.end_at
|
||||
), states AS (
|
||||
SELECT COALESCE(s.bucket_start,p.bucket_start) AS bucket,
|
||||
COALESCE(s.source_revision,0) AS source_revision,COALESCE(s.built_revision,-1) AS built_revision,
|
||||
CASE WHEN p.unrecoverable THEN 'unrecoverable'
|
||||
ELSE COALESCE(s.coverage_status,'unbuilt') END AS coverage_status,
|
||||
p.bucket_start IS NOT NULL AS pending,
|
||||
COALESCE(s.source_revision=s.built_revision AND s.coverage_status='complete',false)
|
||||
AND p.bucket_start IS NULL AS clean
|
||||
FROM stored s FULL JOIN pending p USING(bucket_start)
|
||||
), clean AS (
|
||||
SELECT bucket,row_number() OVER(ORDER BY bucket) AS position FROM states WHERE clean
|
||||
) SELECT CASE WHEN end_at>start_at THEN start_at END AS projection_from,
|
||||
CASE WHEN end_at>start_at THEN COALESCE((SELECT max(bucket)+INTERVAL '1 hour' FROM clean
|
||||
WHERE bucket=start_at+(position-1)*INTERVAL '1 hour'),start_at) END AS projection_through,
|
||||
(SELECT count(*) FROM states WHERE (source_revision>built_revision OR pending) AND coverage_status<>'unrecoverable') AS dirty,
|
||||
GREATEST(EXTRACT(EPOCH FROM(end_at-start_at))::bigint/3600,0)-(SELECT count(*) FROM states) AS missing
|
||||
FROM bounds"#)
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64).expect("validated"))
|
||||
.bind(DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64).expect("validated"))
|
||||
.fetch_one(&mut **tx).await.map_postgres_err()?;
|
||||
Ok(UsageAnalyticsProjectionCoverage {
|
||||
projection_from: row
|
||||
.try_get::<Option<DateTime<Utc>>, _>("projection_from")
|
||||
.map_postgres_err()?
|
||||
.map(|value| value.to_rfc3339()),
|
||||
projection_through: row
|
||||
.try_get::<Option<DateTime<Utc>>, _>("projection_through")
|
||||
.map_postgres_err()?
|
||||
.map(|value| value.to_rfc3339()),
|
||||
dirty_bucket_count: row.try_get::<i64, _>("dirty").map_postgres_err()? as u64,
|
||||
missing_bucket_count: row.try_get::<i64, _>("missing").map_postgres_err()? as u64,
|
||||
read_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn supports_projection(query: &UsageAnalyticsQuery) -> bool {
|
||||
query.status.is_none()
|
||||
&& query.endpoint_kind.is_none()
|
||||
&& query.is_stream.is_none()
|
||||
&& query.has_format_conversion.is_none()
|
||||
&& query.slow_threshold_ms.is_none_or(|value| value == 5000)
|
||||
}
|
||||
|
||||
const ADDITIVE_COUNTS: &[&str] = &[
|
||||
"request_count",
|
||||
"successful_request_count",
|
||||
"failed_request_count",
|
||||
"cancelled_request_count",
|
||||
"in_flight_request_count",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"usage_available_count",
|
||||
"pricing_available_count",
|
||||
"settled_count",
|
||||
"allocation_available_count",
|
||||
"trusted_attribution_count",
|
||||
"classified_failure_count",
|
||||
"latency_sample_count",
|
||||
"slow_request_count",
|
||||
"first_byte_sample_count",
|
||||
"output_tps_sample_count",
|
||||
"reported_usage_count",
|
||||
"estimated_usage_count",
|
||||
"mixed_usage_count",
|
||||
"unknown_usage_count",
|
||||
"cache_pricing_available_count",
|
||||
];
|
||||
|
||||
const ADDITIVE_AMOUNTS: &[&str] = &[
|
||||
"rated_amount",
|
||||
"billable_amount",
|
||||
"quota_covered_amount",
|
||||
"wallet_consumed_amount",
|
||||
"wallet_debit_amount",
|
||||
"wallet_recharge_debit_amount",
|
||||
"wallet_gift_debit_amount",
|
||||
"wallet_overdraft_amount",
|
||||
"cache_read_cost_amount",
|
||||
"cache_creation_cost_amount",
|
||||
"cache_estimated_full_cost_amount",
|
||||
];
|
||||
|
||||
/// Closed verified buckets and raw gaps are disjoint within the caller's read snapshot.
|
||||
pub(super) async fn read_additive_summary(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
) -> Result<UsageAnalyticsMetrics, DataLayerError> {
|
||||
read_projected_metrics(
|
||||
tx,
|
||||
query,
|
||||
&analytics_additive_metrics_sql(5000),
|
||||
ADDITIVE_COUNTS,
|
||||
&["latency_sum_ms", "first_byte_sum_ms", "output_tps_sum"],
|
||||
ADDITIVE_AMOUNTS,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn read_dashboard_total_summary(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
) -> Result<UsageAnalyticsMetrics, DataLayerError> {
|
||||
read_projected_metrics(
|
||||
tx,
|
||||
query,
|
||||
dashboard_total_metrics_sql(),
|
||||
&[
|
||||
"request_count",
|
||||
"total_tokens",
|
||||
"usage_available_count",
|
||||
"pricing_available_count",
|
||||
"settled_count",
|
||||
"allocation_available_count",
|
||||
],
|
||||
&[],
|
||||
&["billable_amount"],
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_projected_metrics(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
query: &UsageAnalyticsQuery,
|
||||
raw_metrics_sql: &str,
|
||||
counts: &[&str],
|
||||
float_sums: &[&str],
|
||||
amounts: &[&str],
|
||||
dashboard_total: bool,
|
||||
) -> Result<UsageAnalyticsMetrics, DataLayerError> {
|
||||
// Normal writers create UTC-aligned buckets. Ignore any manually supplied
|
||||
// nonaligned state so the equality exclusion below still partitions facts.
|
||||
// A writer can commit after a builder's snapshot without touching bucket state.
|
||||
// Its event is committed atomically with the facts, so the same read snapshot
|
||||
// must exclude that bucket until the event has been merged and rebuilt.
|
||||
let mut builder = QueryBuilder::<Postgres>::new(
|
||||
"WITH valid AS MATERIALIZED (SELECT bucket_start FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND source_revision=built_revision AND coverage_status='complete' AND NOT EXISTS (SELECT 1 FROM stats_overview_dirty_events e WHERE e.projection_version=stats_bucket_state.projection_version AND e.granularity=stats_bucket_state.granularity AND e.bucket_start=stats_bucket_state.bucket_start) AND bucket_start = date_trunc('hour', bucket_start AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND bucket_start >= ",
|
||||
);
|
||||
builder.push_bind(DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64).expect("validated"))
|
||||
.push(" AND bucket_start + INTERVAL '1 hour' <= ").push_bind(DateTime::<Utc>::from_timestamp_millis(query.to_unix_ms as i64).expect("validated"))
|
||||
.push(" AND bucket_start + INTERVAL '1 hour' <= NOW()), pieces AS (SELECT p.metrics FROM stats_overview_hourly p JOIN valid v USING(bucket_start) WHERE p.projection_version='overview-v2'");
|
||||
for (column, value) in [
|
||||
("actor_user_id", &query.actor_user_id),
|
||||
("credential_owner_id", &query.credential_owner_id),
|
||||
("attribution_kind", &query.attribution_kind),
|
||||
("api_key_id", &query.api_key_id),
|
||||
("model", &query.model),
|
||||
("provider_id", &query.provider_id),
|
||||
("api_format", &query.api_format),
|
||||
("request_type", &query.request_type),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
builder
|
||||
.push(" AND p.dimensions->>'")
|
||||
.push(column)
|
||||
.push("' = ")
|
||||
.push_bind(value.clone());
|
||||
}
|
||||
}
|
||||
builder
|
||||
.push(" UNION ALL SELECT to_jsonb(raw) FROM (SELECT ")
|
||||
.push(raw_metrics_sql);
|
||||
if dashboard_total {
|
||||
super::dashboard::push_dashboard_total_filter(&mut builder, query);
|
||||
} else {
|
||||
push_analytics_filter(&mut builder, query);
|
||||
}
|
||||
builder.push(" AND NOT EXISTS (SELECT 1 FROM valid WHERE valid.bucket_start = date_trunc('hour', usage_analytics_facts_v1.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')) raw) SELECT to_jsonb(m) AS metrics FROM (SELECT ");
|
||||
for (index, name) in counts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
builder.push(",");
|
||||
}
|
||||
builder
|
||||
.push("COALESCE(sum((metrics->>'")
|
||||
.push(*name)
|
||||
.push("')::bigint),0)::bigint AS ")
|
||||
.push(*name);
|
||||
}
|
||||
for name in float_sums {
|
||||
builder
|
||||
.push(",COALESCE(sum((metrics->>'")
|
||||
.push(*name)
|
||||
.push("')::double precision),0)::double precision AS ")
|
||||
.push(*name);
|
||||
}
|
||||
for name in amounts {
|
||||
builder
|
||||
.push(",sum((metrics->>'")
|
||||
.push(*name)
|
||||
.push("')::numeric)::text AS ")
|
||||
.push(*name);
|
||||
}
|
||||
builder.push(" FROM pieces) m");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
serde_json::from_value(row.try_get("metrics").map_postgres_err()?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))
|
||||
}
|
||||
@@ -157,6 +157,7 @@ const COUNT_ADMIN_WALLETS_SQL: &str = r#"
|
||||
SELECT COUNT(*) AS total
|
||||
FROM wallets
|
||||
WHERE ($1::TEXT IS NULL OR status = $1)
|
||||
AND ($3::TEXT IS NULL OR user_id = $3)
|
||||
AND (
|
||||
$2::TEXT IS NULL
|
||||
OR ($2 = 'user' AND user_id IS NOT NULL)
|
||||
@@ -186,14 +187,15 @@ FROM wallets w
|
||||
LEFT JOIN users ON users.id = w.user_id
|
||||
LEFT JOIN api_keys ON api_keys.id = w.api_key_id
|
||||
WHERE ($1::TEXT IS NULL OR w.status = $1)
|
||||
AND ($3::TEXT IS NULL OR w.user_id = $3)
|
||||
AND (
|
||||
$2::TEXT IS NULL
|
||||
OR ($2 = 'user' AND w.user_id IS NOT NULL)
|
||||
OR ($2 = 'api_key' AND w.api_key_id IS NOT NULL)
|
||||
)
|
||||
ORDER BY w.updated_at DESC
|
||||
OFFSET $3
|
||||
LIMIT $4
|
||||
OFFSET $4
|
||||
LIMIT $5
|
||||
"#;
|
||||
|
||||
const COUNT_ADMIN_WALLET_LEDGER_SQL: &str = r#"
|
||||
@@ -1046,6 +1048,7 @@ impl WalletReadRepository for SqlxWalletRepository {
|
||||
sqlx::query(COUNT_ADMIN_WALLETS_SQL)
|
||||
.bind(query.status.as_deref())
|
||||
.bind(query.owner_type.as_deref())
|
||||
.bind(query.user_id.as_deref())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?,
|
||||
@@ -1054,6 +1057,7 @@ impl WalletReadRepository for SqlxWalletRepository {
|
||||
sqlx::query(LIST_ADMIN_WALLETS_SQL)
|
||||
.bind(query.status.as_deref())
|
||||
.bind(query.owner_type.as_deref())
|
||||
.bind(query.user_id.as_deref())
|
||||
.bind(as_i64(query.offset, "wallet offset")?)
|
||||
.bind(as_i64(query.limit, "wallet limit")?)
|
||||
.fetch(&self.pool),
|
||||
|
||||
Reference in New Issue
Block a user