Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dev": "concurrently \"tsc-watch --onSuccess 'resolve-tspaths'\" \"nodemon --exec node --enable-source-maps --env-file=../../.env dist/index.js\"",
"format": "eslint --fix . && prettier --write .",
"lint": "eslint . && prettier --check .",
"rebuild-global-stats": "tsx --env-file=../../.env ./src/scripts/rebuild-global-stats.ts",
"start": "node --enable-source-maps dist/index.js"
},
"dependencies": {
Expand Down
180 changes: 180 additions & 0 deletions apps/worker/src/scripts/rebuild-global-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/* eslint-disable no-console */
/**
* Rebuild global_model_stats / global_source_stats from `log`.
*
* Why this is possible at all: data retention never deletes log rows. The
* cleanup job (cleanupExpiredLogData) only nulls the verbose payload columns —
* messages, content, raw/upstream request and response, tools, customHeaders,
* userAgent, responsesApiData — and sets data_retention_cleaned_up. Every column
* the aggregator reads (used_model, used_provider, used_mode, source,
* organization_id, created_at, the token columns, the cost columns, has_error,
* cached, streamed, unified_finish_reason) survives untouched, so a rebuild is
* lossless.
*
* That makes this the correct fix for rows the mode/kind migration
* (1786136603_warm_omega_flight.sql) left as 'unknown': re-deriving from the
* source data gives an exact used_mode and org_kind for every row, with no
* proration and no scaling.
*
* Each day is handled by the aggregator's own recomputeDayFully(), which deletes
* that day's rows and re-aggregates its 24 hours. So the script is idempotent
* and resumable: re-running any day is safe, and a crashed run is continued by
* re-running with --from/--to for whatever is left.
*
* Ordering: newest day first by default, so the recent days people actually look
* at are correct within minutes instead of at the very end of the run.
*
* Today and yesterday are skipped. The incremental walker owns today, and the
* safety net wipes-and-recomputes yesterday; racing either of them risks
* interleaving a delete with an insert and double-counting a day.
*
* Usage:
* pnpm --filter worker rebuild-global-stats # dry run: report the plan
* pnpm --filter worker rebuild-global-stats --commit # rebuild everything
* pnpm --filter worker rebuild-global-stats --commit --limit=7 # rebuild the 7 newest days first
* pnpm --filter worker rebuild-global-stats --commit --from=2025-05-20 --to=2026-08-01
* pnpm --filter worker rebuild-global-stats --commit --oldest-first
*
* Environment:
* DATABASE_URL - defaults to local postgres if unset
*/

import { recomputeDayFully } from "@/services/global-stats-aggregator.js";

import { db, sql } from "@llmgateway/db";

const DAY_MS = 24 * 60 * 60 * 1000;

function parseFlag(name: string): string | undefined {
const flag = `--${name}=`;
const arg = process.argv.find((a) => a.startsWith(flag));
return arg ? arg.slice(flag.length) : undefined;
}

function hasFlag(name: string): boolean {
return process.argv.includes(`--${name}`);
}

function floorToUTCDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}

function toDateString(d: Date): string {
return d.toISOString().slice(0, 10);
}

/**
* Earliest day worth rebuilding: whichever of the first log and the first
* existing stats row is older, so a rebuild can also fill in days the walker
* never reached.
*
* `order by created_at limit 1` rather than `min(created_at)` — the log table is
* huge, and this form is guaranteed to ride the index on (created_at, ...)
* instead of risking a sequential scan.
*/
async function resolveStart(): Promise<Date | null> {
const result = await db.execute(sql`
select least(
(select created_at from log order by created_at limit 1),
(select min(day_timestamp) from global_model_stats),
(select min(day_timestamp) from global_source_stats)
) as start
`);
const start = (result.rows[0] as { start: Date | string | null } | undefined)
?.start;
return start ? floorToUTCDay(new Date(start)) : null;
}
Comment on lines +78 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the column types for log.created_at, global_model_stats.day_timestamp, and global_source_stats.day_timestamp.

fd -e ts -e sql . --exec rg -n -C3 'day_timestamp|dayTimestamp' {} \; | head -60

echo "---- schema definitions ----"
ast-grep run --pattern 'timestamp($$$)' --lang typescript 2>/dev/null | head -40

Repository: theopenco/llmgateway

Length of output: 4960


🏁 Script executed:

#!/bin/bash
set -e

echo "---- rebuild script ----"
sed -n '1,130p' apps/worker/src/scripts/rebuild-global-stats.ts

echo "---- schema declarations ----"
rg -n -C4 'createdAt|dayTimestamp|globalModelStats|globalSourceStats' packages/db/src/schema.ts

echo "---- migrations ----"
rg -n -i -C3 'global_model_stats|global_source_stats|created_at.*timestamp|day_timestamp.*timestamp' packages/db drizzle migrations 2>/dev/null | head -240

echo "---- timestamp configuration and PostgreSQL clients ----"
rg -n -C3 'timestamp\(|withTimezone|parseInputDatesAsUTC|pg-types|node-postgres|timezone|TZ' packages apps package.json pnpm-lock.yaml 2>/dev/null | head -240

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e

echo "---- exact global-stats migrations ----"
rg -l -i 'global_model_stats|global_source_stats' packages/db/migrations | sort | while read -r f; do
	echo "### $f"
	rg -n -i -C5 'global_model_stats|global_source_stats' "$f"
done

echo "---- log table creation ----"
rg -l -n 'CREATE TABLE "log"|created_at.*timestamp' packages/db/migrations | while read -r f; do
	if rg -q 'CREATE TABLE "log"' "$f"; then
		echo "### $f"
		rg -n -C8 'CREATE TABLE "log"|created_at.*timestamp' "$f"
	fi
done

echo "---- database client and timestamp options ----"
fd -i 'package.json|*.ts' packages/db apps/worker --exec rg -n -C5 'drizzle\(|new Pool|Pool\(|parseInputDatesAsUTC|pg-types|timestamp|DATABASE_URL' {} \; | head -240

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e

echo "---- exact global-stats migrations ----"
rg -l -i 'global_model_stats|global_source_stats' packages/db/migrations | sort | while read -r f; do
	echo "### $f"
	rg -n -i -C5 'global_model_stats|global_source_stats' "$f"
done

echo "---- log table creation ----"
rg -l 'CREATE TABLE "log"' packages/db/migrations | while read -r f; do
	echo "### $f"
	rg -n -C8 'CREATE TABLE "log"|created_at.*timestamp' "$f"
done

echo "---- database client and timestamp options ----"
fd -i 'package.json|*.ts' packages/db apps/worker --exec rg -n -C5 'drizzle\(|new Pool|Pool\(|parseInputDatesAsUTC|pg-types|timestamp|DATABASE_URL' {} \; | head -240

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e

echo "---- migration files containing table creation ----"
rg -l --glob '*.sql' 'CREATE TABLE "global_model_stats"|CREATE TABLE "global_source_stats"|CREATE TABLE "log"' packages/db/migrations | sort

echo "---- exact CREATE TABLE statements ----"
rg -n --glob '*.sql' -C12 'CREATE TABLE "global_model_stats"|CREATE TABLE "global_source_stats"|CREATE TABLE "log"' packages/db/migrations

echo "---- snapshot type records for the three columns ----"
python3 - <<'PY'
import json
from pathlib import Path

targets = {
	("log", "created_at"),
	("global_model_stats", "day_timestamp"),
	("global_source_stats", "day_timestamp"),
}
for path in sorted(Path("packages/db/migrations/meta").glob("*_snapshot.json")):
	try:
		data = json.loads(path.read_text())
	except Exception:
		continue
	for item in data.get("tables", {}).values():
		if not isinstance(item, dict):
			continue
		table = item.get("name")
		if table not in {t[0] for t in targets}:
			continue
		for name, col in item.get("columns", {}).items():
			if (table, name) in targets:
				print(path.name, table, name, col.get("dataType"), col.get("columnType"), col.get("type"))
PY

echo "---- client configuration and dependency versions ----"
rg -n -C6 --glob '*.ts' --glob 'package.json' --glob 'pnpm-lock.yaml' \
	'drizzle\(|new Pool|Pool\(|parseInputDatesAsUTC|pg-types|node-postgres|DATABASE_URL' \
	packages/db apps/worker package.json pnpm-lock.yaml

Repository: theopenco/llmgateway

Length of output: 45016


🌐 Web query:

node-postgres timestamp without time zone parses local timezone Date parser

💡 Result:

By default, node-postgres parses PostgreSQL TIMESTAMP WITHOUT TIME ZONE columns as JavaScript Date objects based on the local time zone of the Node.js process [1][2]. The driver uses the postgres-date library to handle this conversion [3]. Because TIMESTAMP WITHOUT TIME ZONE lacks timezone information, the parser treats the input string as a local time when constructing the Date object [4][3]. This can lead to unexpected behavior if your application expects UTC or if your Node.js server and database server operate in different time zones [1][5]. To change this behavior, you can override the default type parser provided by the pg library [1][6]. Common approaches include: 1. Returning the raw string: If you want to avoid automatic Date conversion entirely, you can disable the parser [7][5]. const { types } = require('pg'); const TIMESTAMP_OID = 1114; types.setTypeParser(TIMESTAMP_OID, (timestamp) => timestamp); 2. Forcing UTC interpretation: If you need a Date object but want it interpreted as UTC, you can append a Z (UTC indicator) to the string before passing it to the Date constructor [8]. const { types } = require('pg'); const TIMESTAMP_OID = 1114; types.setTypeParser(TIMESTAMP_OID, (stringValue) => { return new Date(${stringValue.replace(' ', 'T')}Z); }); Note: If you are experiencing issues with time offsets, it is often recommended to use the TIMESTAMPTZ (timestamp with time zone) data type in PostgreSQL, which stores the value in UTC and avoids these ambiguity issues [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -e

node - <<'JS'
const value = "2025-05-20 00:00:00";

for (const tz of ["UTC", "America/Los_Angeles", "Asia/Tokyo"]) {
	const parsed = require("child_process")
		.execFileSync(process.execPath, ["-e", `
			const d = new Date(${JSON.stringify(value)});
			const floored = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
			console.log(JSON.stringify({
				tz: process.env.TZ,
				parsed: d.toISOString(),
				floored: floored.toISOString()
			}));
		`], { env: { ...process.env, TZ: tz } })
		.toString()
		.trim();
	console.log(parsed);
}
JS

Repository: theopenco/llmgateway

Length of output: 439


Interpret the timestamp boundary as UTC. The three columns are timestamp without time zone values, and node-postgres parses them as local-time Date objects. Under TZ=Asia/Tokyo, floorToUTCDay can select the previous day. Convert the least(...) expression with AT TIME ZONE 'UTC', or parse text with an explicit Z; a bare text cast is insufficient.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/scripts/rebuild-global-stats.ts` around lines 78 - 88, Update
the least(...) start-boundary query in the rebuild-global-stats flow to
interpret the timestamp without time zone values as UTC before node-postgres
converts them to Date objects. Apply AT TIME ZONE 'UTC' to the SQL expression
(or parse an explicitly Z-suffixed text value), then preserve the existing
floorToUTCDay handling and null behavior.


async function main(): Promise<void> {
const commit = hasFlag("commit");
const oldestFirst = hasFlag("oldest-first");
const limit = parseFlag("limit") ? Number(parseFlag("limit")) : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate all CLI flags before use. The script converts process.argv values into numbers and dates without validation. Malformed input then propagates as NaN or Invalid Date, which either silently rebuilds nothing, rebuilds everything, or throws RangeError during logging.

  • apps/worker/src/scripts/rebuild-global-stats.ts#L93-L93: parse --limit once and reject values that are not positive integers; also change line 132 to limit === undefined ? days : days.slice(0, limit) so --limit=0 does not select every day.
  • apps/worker/src/scripts/rebuild-global-stats.ts#L102-L118: reject --from and --to values that produce an Invalid Date before the clamp and range checks run.
📍 Affects 1 file
  • apps/worker/src/scripts/rebuild-global-stats.ts#L93-L93 (this comment)
  • apps/worker/src/scripts/rebuild-global-stats.ts#L102-L118
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/scripts/rebuild-global-stats.ts` at line 93, Validate every
CLI date and numeric flag before using it: in the limit parsing around
apps/worker/src/scripts/rebuild-global-stats.ts lines 93-93, parse --limit once
and reject values that are not positive integers, then update the day selection
around line 132 to use the undefined check so a zero limit does not select all
days. In the --from/--to handling at lines 102-118, reject Invalid Date results
before clamp and range validation proceed.


const fromFlag = parseFlag("from");
const toFlag = parseFlag("to");

const today = floorToUTCDay(new Date());
// Today belongs to the walker, yesterday to the safety net.
const defaultEnd = new Date(today.getTime() - 2 * DAY_MS); // eslint-disable-line no-mixed-operators

const start = fromFlag
? floorToUTCDay(new Date(`${fromFlag}T00:00:00Z`))
: await resolveStart();
if (!start) {
console.log("No logs and no global stats rows — nothing to rebuild.");
return;
}

let end = toFlag
? floorToUTCDay(new Date(`${toFlag}T00:00:00Z`))
: defaultEnd;
if (end.getTime() > defaultEnd.getTime()) {
console.log(
`Clamping --to to ${toDateString(defaultEnd)}: today and yesterday are owned by the walker and the safety net.`,
);
end = defaultEnd;
}

if (end.getTime() < start.getTime()) {
console.log("Nothing to rebuild — the range is empty.");
return;
}

const days: Date[] = [];
for (let d = new Date(start); d <= end; d = new Date(d.getTime() + DAY_MS)) {
days.push(new Date(d));
}
if (!oldestFirst) {
days.reverse();
}
const planned = limit ? days.slice(0, limit) : days;

console.log(
`${commit ? "REBUILDING" : "DRY RUN"} — ${toDateString(start)} .. ${toDateString(end)} (${planned.length} of ${days.length} days, ${oldestFirst ? "oldest" : "newest"} first)`,
);

if (!commit) {
const before = await db.execute(sql`
select
(select count(*) from global_model_stats
where used_mode = 'unknown' or org_kind = 'unknown') as model_rows_unattributed,
(select count(*) from global_source_stats
where used_mode = 'unknown' or org_kind = 'unknown') as source_rows_unattributed
`);
console.log(before.rows[0]);
console.log("\nDry run — nothing was written. Re-run with --commit.");
return;
}

const startedAt = Date.now();
let done = 0;
for (const day of planned) {
const completed = await recomputeDayFully(day);
if (!completed) {
console.log(`Stop requested — halted after ${done} day(s).`);
break;
}
Comment on lines +153 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find where the stop flag used by isStopRequested is set.

rg -nP --type=ts -C5 '\b(isStopRequested|requestStop|stopRequested)\b'

Repository: theopenco/llmgateway

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate file ---'
wc -l apps/worker/src/scripts/rebuild-global-stats.ts
sed -n '1,220p' apps/worker/src/scripts/rebuild-global-stats.ts

printf '%s\n' '--- stop-related definitions and usages ---'
rg -n -C4 'isStopRequested|requestStop|stopRequested|SIGINT|SIGTERM|shutdown|graceful' . \
	-g '!node_modules' -g '!dist' -g '!build'

Repository: theopenco/llmgateway

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

service_file="$(fd -t f 'global-stats-aggregator' . | head -n 1)"
printf 'service_file=%s\n' "$service_file"
wc -l "$service_file"
ast-grep outline "$service_file"
printf '%s\n' '--- recomputeDayFully and direct stop references ---'
rg -n -C12 'recomputeDayFully|isStopRequested|requestStop|stopRequested' "$service_file"
printf '%s\n' '--- all exact symbol references, excluding generated/dependency files ---'
rg -n --hidden -g '!node_modules' -g '!dist' -g '!build' -g '!pnpm-lock.yaml' '\bisStopRequested\b|\brequestStop\b|\bstopRequested\b' .

Repository: theopenco/llmgateway

Length of output: 486


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate service files ---'
fd -t f 'global-stats-aggregator' apps/worker

printf '%s\n' '--- implementation references ---'
rg -n -C16 'recomputeDayFully|isStopRequested|requestStop|stopRequested' apps/worker/src \
	-g '*.ts' -g '!*.spec.ts'

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- shutdown state and signal registration ---'
cat -n apps/worker/src/shutdown.ts | sed -n '1,140p'
printf '%s\n' '--- process signal handlers in worker sources ---'
rg -n -C12 "process\.on\(['\"](SIGINT|SIGTERM)|requestStop\(" apps/worker/src \
	-g '*.ts' -g '!*.spec.ts'
printf '%s\n' '--- worker entrypoint imports and startup/shutdown section ---'
rg -n -C10 'from "./shutdown\.js"|from "`@/shutdown`\.js"|process\.on|startWorker|main\(' apps/worker/src/worker.ts apps/worker/src/index.ts apps/worker/src/main.ts 2>/dev/null || true

Repository: theopenco/llmgateway

Length of output: 11347


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

script = Path("apps/worker/src/scripts/rebuild-global-stats.ts").read_text()
aggregator = Path("apps/worker/src/services/global-stats-aggregator.ts").read_text()
shutdown = Path("apps/worker/src/shutdown.ts").read_text()
worker_entry = Path("apps/worker/src/index.ts").read_text()

checks = {
	"script imports recomputeDayFully": "recomputeDayFully" in script,
	"aggregator imports isStopRequested": 'import { isStopRequested } from "`@/shutdown.js`";' in aggregator,
	"recomputeDayFully checks isStopRequested": bool(
		re.search(r"export async function recomputeDayFully[\s\S]*?isStopRequested\(\)", aggregator)
	),
	"shutdown initializes shouldStop=false": "let shouldStop = false;" in shutdown,
	"standalone script has SIGINT handler": bool(
		re.search(r"process\.on\(\s*[\"']SIGINT[\"']", script)
	),
	"standalone script has SIGTERM handler": bool(
		re.search(r"process\.on\(\s*[\"']SIGTERM[\"']", script)
	),
	"worker entrypoint has SIGINT handler": bool(
		re.search(r"process\.on\(\s*[\"']SIGINT[\"']", worker_entry)
	),
	"worker entrypoint has SIGTERM handler": bool(
		re.search(r"process\.on\(\s*[\"']SIGTERM[\"']", worker_entry)
	),
}
for name, result in checks.items():
	print(f"{name}: {result}")
PY

Repository: theopenco/llmgateway

Length of output: 505


Register signal handlers for the standalone script.

recomputeDayFully() checks isStopRequested(), but this script does not register SIGINT or SIGTERM handlers. A signal can terminate the process during a day rebuild and leave that day partially rebuilt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/scripts/rebuild-global-stats.ts` around lines 153 - 158,
Register SIGINT and SIGTERM handlers in the standalone script before the loop
that invokes recomputeDayFully, using the existing stop-request mechanism so
signals set the requested-stop state instead of terminating immediately.
Preserve the loop’s current halted-after-completion behavior and cleanup any
handlers when the script finishes.

done++;
if (done % 10 === 0 || done === planned.length) {
const elapsed = (Date.now() - startedAt) / 1000;
const rate = done / elapsed;
const remaining = planned.length - done;
console.log(
`${done}/${planned.length} days (${toDateString(day)}) — ${elapsed.toFixed(0)}s elapsed, ~${rate > 0 ? Math.round(remaining / rate) : 0}s remaining`,
);
}
}

console.log(
`Rebuilt ${done} day(s) in ${((Date.now() - startedAt) / 1000).toFixed(0)}s.`,
);
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
2 changes: 1 addition & 1 deletion apps/worker/src/services/global-stats-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ async function setLastSafetyNetDay(day: Date): Promise<void> {
// Returns true on full completion. Returns false if stop was requested
// mid-walk; the caller must NOT mark the day as recomputed in that case so the
// next worker start retries from the partially-recomputed state.
async function recomputeDayFully(day: Date): Promise<boolean> {
export async function recomputeDayFully(day: Date): Promise<boolean> {
const dayStr = formatUTCTimestamp(day);

await db.transaction(async (tx) => {
Expand Down
Loading