chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# ClickHouse Package
|
||||
|
||||
`@internal/clickhouse` - ClickHouse client for analytics and observability data.
|
||||
|
||||
## Migrations
|
||||
|
||||
Goose-format SQL migrations live in `schema/`. Two rules below are load-bearing — both can block a deploy.
|
||||
|
||||
### Rule 1: number to `max + 1`, never slot in
|
||||
|
||||
Goose runs in strict mode in the deploy pipeline. If a migration file numbered *below* the version currently recorded in `goose_db_version` ever shows up, goose refuses to apply it and the deploy fails:
|
||||
|
||||
```
|
||||
goose run: error: found 1 missing migrations before current version 30:
|
||||
version 29: 029_add_task_kind_to_task_runs_v2.sql
|
||||
```
|
||||
|
||||
When adding a migration:
|
||||
|
||||
1. Look at `schema/` and take the largest existing number, call it `N`.
|
||||
2. Name your file `0(N+1)_descriptive_name.sql`.
|
||||
3. If you've been on a branch while main added migrations, **rebase and renumber** before opening the PR — a file numbered below the new max will block the next deploy after your PR merges.
|
||||
|
||||
### Rule 2: DDL must be idempotent
|
||||
|
||||
Migrations can be applied out of order in some environments (`goose up --allow-missing` for local recovery, manual fixups, etc.) and may be retried. Always use idempotent forms so a re-apply is a no-op:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
ADD COLUMN IF NOT EXISTS new_column String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
DROP COLUMN IF EXISTS new_column;
|
||||
```
|
||||
|
||||
Equivalent forms for other DDL:
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS …`
|
||||
- `DROP TABLE IF EXISTS …`
|
||||
- `ADD INDEX IF NOT EXISTS …` / `DROP INDEX IF EXISTS …`
|
||||
- `CREATE MATERIALIZED VIEW IF NOT EXISTS …` / `DROP VIEW IF EXISTS …`
|
||||
|
||||
ClickHouse supports `IF [NOT] EXISTS` on all of the above. Older migrations in this directory predate the rule and are not idempotent — leave them as-is unless you're explicitly hardening one.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- `raw_` prefix for input tables (where data lands first)
|
||||
- `_v1`, `_v2` suffixes for table versioning
|
||||
- `_mv_v1` suffix for materialized views
|
||||
- `_per_day`, `_per_month` for aggregation tables
|
||||
|
||||
See `README.md` in this directory for full naming convention documentation.
|
||||
|
||||
## Purpose
|
||||
|
||||
Stores time-series data for task run analytics, event streams, and performance metrics. Separate from PostgreSQL to handle high-volume writes from task execution.
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM golang:1.26@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479
|
||||
|
||||
|
||||
RUN go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
|
||||
|
||||
|
||||
COPY ./schema ./schema
|
||||
|
||||
ENV GOOSE_DRIVER=clickhouse
|
||||
ENV GOOSE_DBSTRING="tcp://default:password@clickhouse:9000"
|
||||
ENV GOOSE_MIGRATION_DIR=./schema
|
||||
|
||||
# Run migrations as non-root (dev-only migration helper; goose needs no root).
|
||||
USER nobody
|
||||
CMD ["goose", "up"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# ClickHouse Table Naming Conventions
|
||||
|
||||
The following document is heavily inspired by the [Unkey](https://unkey.dev) ClickHouse naming conventions.
|
||||
|
||||
This document outlines the naming conventions for tables and materialized views in our ClickHouse setup. Adhering to these conventions ensures consistency, clarity, and ease of management across our data infrastructure.
|
||||
|
||||
## General Rules
|
||||
|
||||
1. Use lowercase letters and separate words with underscores.
|
||||
2. Avoid ClickHouse reserved words and special characters in names.
|
||||
3. Be descriptive but concise.
|
||||
|
||||
## Table Naming Convention
|
||||
|
||||
Format: `[prefix]_[domain]_[description]_[version]`
|
||||
|
||||
### Prefixes
|
||||
|
||||
- `raw_`: Input data tables
|
||||
- `tmp_{yourname}_`: Temporary tables for experiments, add your name, so it's easy to identify ownership.
|
||||
|
||||
### Versioning
|
||||
|
||||
- Version numbers: `_v1`, `_v2`, etc.
|
||||
|
||||
### Aggregation Suffixes
|
||||
|
||||
For aggregated or summary tables, use suffixes like:
|
||||
|
||||
- `_per_day`
|
||||
- `_per_month`
|
||||
- `_summary`
|
||||
|
||||
## Materialized View Naming Convention
|
||||
|
||||
Format: `[description]_[aggregation]_mv_[version]`
|
||||
|
||||
- Always suffix with `mv_[version]`
|
||||
- Include a description of the view's purpose
|
||||
- Add aggregation level if applicable
|
||||
|
||||
## Examples
|
||||
|
||||
1. Raw Data Table:
|
||||
`raw_sales_transactions_v1`
|
||||
|
||||
2. Materialized View:
|
||||
`active_users_per_day_mv_v2`
|
||||
|
||||
3. Temporary Table:
|
||||
`tmp_eric_user_analysis_v1`
|
||||
|
||||
4. Aggregated Table:
|
||||
`sales_summary_per_hour_mv_v1`
|
||||
|
||||
## Consistency Across Related Objects
|
||||
|
||||
Maintain consistent naming across related tables, views, and other objects:
|
||||
|
||||
- `raw_user_activity_v1`
|
||||
- `user_activity_per_day_v1`
|
||||
- `user_activity_per_day_mv_v1`
|
||||
|
||||
By following these conventions, we ensure a clear, consistent, and scalable naming structure for our ClickHouse setup.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@internal/clickhouse",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.12.1",
|
||||
"@internal/tracing": "workspace:*",
|
||||
"@internal/tsql": "workspace:*",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"zod": "3.25.76",
|
||||
"zod-error": "1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/testcontainers": "workspace:*",
|
||||
"rimraf": "6.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.build.json",
|
||||
"build": "pnpm run clean && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch -p tsconfig.build.json",
|
||||
"db:migrate": "node ../../scripts/docker.mjs -f docker/docker-compose.yml up clickhouse_migrator --build",
|
||||
"db:migrate:down": "GOOSE_COMMAND=down pnpm run db:migrate",
|
||||
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
|
||||
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/index.ts",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"default": "./dist/src/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose up
|
||||
|
||||
CREATE DATABASE trigger_dev;
|
||||
|
||||
-- +goose down
|
||||
DROP DATABASE trigger_dev;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.smoke_test (
|
||||
id UUID DEFAULT generateUUIDv4(),
|
||||
timestamp DateTime64(3) DEFAULT now64(3),
|
||||
message String,
|
||||
number UInt32
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (timestamp, id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.smoke_test;
|
||||
@@ -0,0 +1,100 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE trigger_dev.task_runs_v1
|
||||
(
|
||||
/* ─── ids & hierarchy ─────────────────────────────────────── */
|
||||
environment_id String,
|
||||
organization_id String,
|
||||
project_id String,
|
||||
run_id String,
|
||||
|
||||
environment_type LowCardinality(String),
|
||||
friendly_id String,
|
||||
attempt UInt8 DEFAULT 1,
|
||||
|
||||
/* ─── enums / status ──────────────────────────────────────── */
|
||||
engine LowCardinality(String),
|
||||
status LowCardinality(String),
|
||||
|
||||
/* ─── queue / concurrency / schedule ─────────────────────── */
|
||||
task_identifier String,
|
||||
queue String,
|
||||
|
||||
schedule_id String,
|
||||
batch_id String,
|
||||
|
||||
/* ─── related runs ─────────────────────────────────────────────── */
|
||||
root_run_id String,
|
||||
parent_run_id String,
|
||||
depth UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── telemetry ─────────────────────────────────────────────── */
|
||||
span_id String,
|
||||
trace_id String,
|
||||
idempotency_key String,
|
||||
|
||||
/* ─── timing ─────────────────────────────────────────────── */
|
||||
created_at DateTime64(3),
|
||||
updated_at DateTime64(3),
|
||||
started_at Nullable(DateTime64(3)),
|
||||
executed_at Nullable(DateTime64(3)),
|
||||
completed_at Nullable(DateTime64(3)),
|
||||
delay_until Nullable(DateTime64(3)),
|
||||
queued_at Nullable(DateTime64(3)),
|
||||
expired_at Nullable(DateTime64(3)),
|
||||
expiration_ttl String,
|
||||
|
||||
/* ─── cost / usage ───────────────────────────────────────── */
|
||||
usage_duration_ms UInt32 DEFAULT 0,
|
||||
cost_in_cents Float64 DEFAULT 0,
|
||||
base_cost_in_cents Float64 DEFAULT 0,
|
||||
|
||||
/* ─── payload & context ──────────────────────────────────── */
|
||||
output JSON(max_dynamic_paths = 1024),
|
||||
error JSON(max_dynamic_paths = 64),
|
||||
|
||||
/* ─── tagging / versions ─────────────────────────────────── */
|
||||
tags Array(String) CODEC(ZSTD(1)),
|
||||
task_version String CODEC(LZ4),
|
||||
sdk_version String CODEC(LZ4),
|
||||
cli_version String CODEC(LZ4),
|
||||
machine_preset LowCardinality(String) CODEC(LZ4),
|
||||
|
||||
is_test UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── commit lsn ─────────────────────────────────────────────── */
|
||||
_version UInt64,
|
||||
_is_deleted UInt8 DEFAULT 0
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(_version, _is_deleted)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (toDate(created_at), environment_id, task_identifier, created_at, run_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
/* Fast tag filtering */
|
||||
ALTER TABLE trigger_dev.task_runs_v1
|
||||
ADD INDEX idx_tags tags TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4;
|
||||
|
||||
CREATE TABLE trigger_dev.raw_task_runs_payload_v1
|
||||
(
|
||||
run_id String,
|
||||
created_at DateTime64(3),
|
||||
payload JSON(max_dynamic_paths = 1024)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (run_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
CREATE VIEW trigger_dev.tmp_eric_task_runs_full_v1 AS
|
||||
SELECT
|
||||
s.*,
|
||||
p.payload as payload
|
||||
FROM trigger_dev.task_runs_v1 AS s FINAL
|
||||
LEFT JOIN trigger_dev.raw_task_runs_payload_v1 AS p ON s.run_id = p.run_id
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.task_runs_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.raw_task_runs_payload_v1;
|
||||
DROP VIEW IF EXISTS trigger_dev.tmp_eric_task_runs_full_v1;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- +goose Up
|
||||
|
||||
/*
|
||||
This is the second version of the task runs table.
|
||||
The main change is we've added organization_id and project_id to the sort key, and removed the toDate(created_at) and task_identifier columns from the sort key.
|
||||
We will add a skip index for the task_identifier column in a future migration.
|
||||
*/
|
||||
CREATE TABLE trigger_dev.task_runs_v2
|
||||
(
|
||||
/* ─── ids & hierarchy ─────────────────────────────────────── */
|
||||
environment_id String,
|
||||
organization_id String,
|
||||
project_id String,
|
||||
run_id String,
|
||||
|
||||
environment_type LowCardinality(String),
|
||||
friendly_id String,
|
||||
attempt UInt8 DEFAULT 1,
|
||||
|
||||
/* ─── enums / status ──────────────────────────────────────── */
|
||||
engine LowCardinality(String),
|
||||
status LowCardinality(String),
|
||||
|
||||
/* ─── queue / concurrency / schedule ─────────────────────── */
|
||||
task_identifier String,
|
||||
queue String,
|
||||
|
||||
schedule_id String,
|
||||
batch_id String,
|
||||
|
||||
/* ─── related runs ─────────────────────────────────────────────── */
|
||||
root_run_id String,
|
||||
parent_run_id String,
|
||||
depth UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── telemetry ─────────────────────────────────────────────── */
|
||||
span_id String,
|
||||
trace_id String,
|
||||
idempotency_key String,
|
||||
|
||||
/* ─── timing ─────────────────────────────────────────────── */
|
||||
created_at DateTime64(3),
|
||||
updated_at DateTime64(3),
|
||||
started_at Nullable(DateTime64(3)),
|
||||
executed_at Nullable(DateTime64(3)),
|
||||
completed_at Nullable(DateTime64(3)),
|
||||
delay_until Nullable(DateTime64(3)),
|
||||
queued_at Nullable(DateTime64(3)),
|
||||
expired_at Nullable(DateTime64(3)),
|
||||
expiration_ttl String,
|
||||
|
||||
/* ─── cost / usage ───────────────────────────────────────── */
|
||||
usage_duration_ms UInt32 DEFAULT 0,
|
||||
cost_in_cents Float64 DEFAULT 0,
|
||||
base_cost_in_cents Float64 DEFAULT 0,
|
||||
|
||||
/* ─── payload & context ──────────────────────────────────── */
|
||||
output JSON(max_dynamic_paths = 1024),
|
||||
error JSON(max_dynamic_paths = 64),
|
||||
|
||||
/* ─── tagging / versions ─────────────────────────────────── */
|
||||
tags Array(String) CODEC(ZSTD(1)),
|
||||
task_version String CODEC(LZ4),
|
||||
sdk_version String CODEC(LZ4),
|
||||
cli_version String CODEC(LZ4),
|
||||
machine_preset LowCardinality(String) CODEC(LZ4),
|
||||
|
||||
is_test UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── commit lsn ─────────────────────────────────────────────── */
|
||||
_version UInt64,
|
||||
_is_deleted UInt8 DEFAULT 0
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(_version, _is_deleted)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (organization_id, project_id, environment_id, created_at, run_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
/* Fast tag filtering */
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD INDEX idx_tags tags TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4;
|
||||
|
||||
CREATE VIEW trigger_dev.tmp_eric_task_runs_full_v2 AS
|
||||
SELECT
|
||||
s.*,
|
||||
p.payload as payload
|
||||
FROM trigger_dev.task_runs_v2 AS s FINAL
|
||||
LEFT JOIN trigger_dev.raw_task_runs_payload_v1 AS p ON s.run_id = p.run_id
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.task_runs_v2;
|
||||
DROP VIEW IF EXISTS trigger_dev.tmp_eric_task_runs_full_v2
|
||||
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
/*
|
||||
Add concurrency_key and bulk_action_group_ids columns with defaults.
|
||||
*/
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN concurrency_key String DEFAULT '',
|
||||
ADD COLUMN bulk_action_group_ids Array(String) DEFAULT [];
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN concurrency_key,
|
||||
DROP COLUMN bulk_action_group_ids;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
/*
|
||||
Add worker_queue column.
|
||||
*/
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN worker_queue String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN worker_queue;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.task_events_v1
|
||||
(
|
||||
-- This the main "tenant" ID
|
||||
environment_id String,
|
||||
-- The organization ID here so we can do MV rollups of usage
|
||||
organization_id String,
|
||||
-- The project ID here so we can do MV rollups of usage
|
||||
project_id String,
|
||||
-- The task slug (e.g. "my-task")
|
||||
task_identifier String CODEC(ZSTD(1)),
|
||||
-- The non-friendly ID for the run
|
||||
run_id String CODEC(ZSTD(1)),
|
||||
-- nanoseconds since the epoch
|
||||
start_time DateTime64(9) CODEC(Delta(8), ZSTD(1)),
|
||||
trace_id String CODEC(ZSTD(1)),
|
||||
span_id String CODEC(ZSTD(1)),
|
||||
-- will be an empty string for root spans
|
||||
parent_span_id String CODEC(ZSTD(1)),
|
||||
-- Log body, event name, or span name
|
||||
message String CODEC(ZSTD(1)),
|
||||
-- this is the new level column, can be
|
||||
-- SPAN, SPAN_EVENT, DEBUG_EVENT, LOG_DEBUG, LOG_LOG, LOG_SUCCESS, LOG_INFO, LOG_WARN, LOG_ERROR, ANCESTOR_OVERRIDE
|
||||
kind LowCardinality(String) CODEC(ZSTD(1)),
|
||||
-- isError, isPartial, isCancelled will now be in this status column
|
||||
-- OK, ERROR, PARTIAL, CANCELLED
|
||||
status LowCardinality(String) CODEC(ZSTD(1)),
|
||||
-- span/log/event attributes and resource attributes
|
||||
-- includes error attributes, gen_ai attributes, and other attributes
|
||||
attributes JSON CODEC(ZSTD(1)),
|
||||
attributes_text String MATERIALIZED toJSONString(attributes),
|
||||
-- This is the metadata column, includes style for styling the event in the UI
|
||||
-- is a JSON stringified object, e.g. {"style":{"icon":"play","variant":"primary"},"error":{"message":"Error message","attributes":{"error.type":"ErrorType","error.code":"123"}}}
|
||||
metadata String CODEC(ZSTD(1)),
|
||||
-- nanoseconds since the start time, only non-zero for spans
|
||||
duration UInt64 CODEC(ZSTD(1)),
|
||||
-- The TTL for the event, will be deleted 7 days after the event expires
|
||||
expires_at DateTime64(3),
|
||||
|
||||
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_span_id span_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_duration duration TYPE minmax GRANULARITY 1,
|
||||
INDEX idx_attributes_text attributes_text TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toDate(start_time)
|
||||
ORDER BY (environment_id, toUnixTimestamp(start_time), trace_id)
|
||||
TTL toDateTime(expires_at) + INTERVAL 7 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.task_events_v1;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.task_event_usage_by_minute_v1
|
||||
(
|
||||
organization_id String,
|
||||
project_id String,
|
||||
environment_id String,
|
||||
bucket_start DateTime,
|
||||
event_count UInt64
|
||||
)
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(bucket_start)
|
||||
ORDER BY (organization_id, project_id, environment_id, bucket_start)
|
||||
TTL bucket_start + INTERVAL 8 DAY;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.task_event_usage_by_hour_v1
|
||||
(
|
||||
organization_id String,
|
||||
project_id String,
|
||||
environment_id String,
|
||||
bucket_start DateTime,
|
||||
event_count UInt64
|
||||
)
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(bucket_start)
|
||||
ORDER BY (organization_id, project_id, environment_id, bucket_start)
|
||||
TTL bucket_start + INTERVAL 400 DAY;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.mv_task_event_usage_by_minute_v1
|
||||
TO trigger_dev.task_event_usage_by_minute_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
toStartOfMinute(start_time) AS bucket_start,
|
||||
count() AS event_count
|
||||
FROM trigger_dev.task_events_v1
|
||||
GROUP BY organization_id, project_id, environment_id, bucket_start;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.mv_task_event_usage_by_hour_v1
|
||||
TO trigger_dev.task_event_usage_by_hour_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
toStartOfHour(bucket_start) AS bucket_start,
|
||||
sum(event_count) AS event_count
|
||||
FROM trigger_dev.task_event_usage_by_minute_v1
|
||||
GROUP BY organization_id, project_id, environment_id, bucket_start;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.mv_task_event_usage_by_minute_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.mv_task_event_usage_by_hour_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.task_event_usage_by_hour_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.task_event_usage_by_minute_v1;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
DROP TABLE IF EXISTS trigger_dev.mv_task_event_usage_by_minute_v1;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.mv_task_event_usage_by_minute_v2
|
||||
TO trigger_dev.task_event_usage_by_minute_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
toStartOfMinute(start_time) AS bucket_start,
|
||||
count() AS event_count
|
||||
FROM trigger_dev.task_events_v1
|
||||
WHERE kind != 'DEBUG_EVENT' AND kind != 'ANCESTOR_OVERRIDE' AND status != 'PARTIAL'
|
||||
GROUP BY organization_id, project_id, environment_id, bucket_start;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.mv_task_event_usage_by_minute_v2;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.mv_task_event_usage_by_minute_v1
|
||||
TO trigger_dev.task_event_usage_by_minute_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
toStartOfMinute(start_time) AS bucket_start,
|
||||
count() AS event_count
|
||||
FROM trigger_dev.task_events_v1
|
||||
GROUP BY organization_id, project_id, environment_id, bucket_start;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.task_events_v2
|
||||
(
|
||||
-- This the main "tenant" ID
|
||||
environment_id String,
|
||||
-- The organization ID here so we can do MV rollups of usage
|
||||
organization_id String,
|
||||
-- The project ID here so we can do MV rollups of usage
|
||||
project_id String,
|
||||
-- The task slug (e.g. "my-task")
|
||||
task_identifier String CODEC(ZSTD(1)),
|
||||
-- The non-friendly ID for the run
|
||||
run_id String CODEC(ZSTD(1)),
|
||||
-- nanoseconds since the epoch
|
||||
start_time DateTime64(9) CODEC(Delta(8), ZSTD(1)),
|
||||
trace_id String CODEC(ZSTD(1)),
|
||||
span_id String CODEC(ZSTD(1)),
|
||||
-- will be an empty string for root spans
|
||||
parent_span_id String CODEC(ZSTD(1)),
|
||||
-- Log body, event name, or span name
|
||||
message String CODEC(ZSTD(1)),
|
||||
-- this is the new level column, can be
|
||||
-- SPAN, SPAN_EVENT, DEBUG_EVENT, LOG_DEBUG, LOG_LOG, LOG_SUCCESS, LOG_INFO, LOG_WARN, LOG_ERROR, ANCESTOR_OVERRIDE
|
||||
kind LowCardinality(String) CODEC(ZSTD(1)),
|
||||
-- isError, isPartial, isCancelled will now be in this status column
|
||||
-- OK, ERROR, PARTIAL, CANCELLED
|
||||
status LowCardinality(String) CODEC(ZSTD(1)),
|
||||
-- span/log/event attributes and resource attributes
|
||||
-- includes error attributes, gen_ai attributes, and other attributes
|
||||
attributes JSON CODEC(ZSTD(1)),
|
||||
attributes_text String MATERIALIZED toJSONString(attributes),
|
||||
-- This is the metadata column, includes style for styling the event in the UI
|
||||
-- is a JSON stringified object, e.g. {"style":{"icon":"play","variant":"primary"},"error":{"message":"Error message","attributes":{"error.type":"ErrorType","error.code":"123"}}}
|
||||
metadata String CODEC(ZSTD(1)),
|
||||
-- nanoseconds since the start time, only non-zero for spans
|
||||
duration UInt64 CODEC(ZSTD(1)),
|
||||
-- The TTL for the event, will be deleted 7 days after the event expires
|
||||
expires_at DateTime64(3),
|
||||
-- NEW: Insert timestamp for partitioning (avoids "too many parts" errors from late-arriving events)
|
||||
inserted_at DateTime64(3) DEFAULT now64(3),
|
||||
|
||||
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_span_id span_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_duration duration TYPE minmax GRANULARITY 1,
|
||||
INDEX idx_attributes_text attributes_text TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toDate(inserted_at)
|
||||
ORDER BY (environment_id, toUnixTimestamp(start_time), trace_id)
|
||||
TTL toDateTime(expires_at) + INTERVAL 7 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.task_events_v2;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Create materialized views for task_events_v2 table (partitioned by inserted_at)
|
||||
-- These write to the same target tables as the v1 MVs so usage is aggregated across both tables
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.mv_task_event_v2_usage_by_minute
|
||||
TO trigger_dev.task_event_usage_by_minute_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
toStartOfMinute(start_time) AS bucket_start,
|
||||
count() AS event_count
|
||||
FROM trigger_dev.task_events_v2
|
||||
WHERE kind != 'DEBUG_EVENT' AND kind != 'ANCESTOR_OVERRIDE' AND status != 'PARTIAL'
|
||||
GROUP BY organization_id, project_id, environment_id, bucket_start;
|
||||
|
||||
-- +goose Down
|
||||
DROP VIEW IF EXISTS trigger_dev.mv_task_event_v2_usage_by_minute;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
/*
|
||||
Add max_duration_in_seconds column.
|
||||
*/
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN max_duration_in_seconds Nullable (UInt32) DEFAULT NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN max_duration_in_seconds;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Add columns for storing user-provided idempotency key and scope for searching
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN idempotency_key_user String DEFAULT '';
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN idempotency_key_scope String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN idempotency_key_user;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN idempotency_key_scope;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- +goose Up
|
||||
-- Update the materialized columns to extract the 'data' field if it exists
|
||||
-- This avoids the {"data": ...} wrapper in the text representation
|
||||
-- Note: Direct JSON path access (output.data) returns null for nested objects,
|
||||
-- so we use JSONExtractRaw on the stringified JSON instead
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN output_text String MATERIALIZED if (
|
||||
toJSONString (output) = '{}',
|
||||
'',
|
||||
if (
|
||||
length (JSONExtractRaw (toJSONString (output), 'data')) > 0,
|
||||
JSONExtractRaw (toJSONString (output), 'data'),
|
||||
toJSONString (output)
|
||||
)
|
||||
);
|
||||
|
||||
-- For error: extract error.data if it exists
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN error_text String MATERIALIZED if (
|
||||
toJSONString (error) = '{}',
|
||||
'',
|
||||
if (
|
||||
length (JSONExtractRaw (toJSONString (error), 'data')) > 0,
|
||||
JSONExtractRaw (toJSONString (error), 'data'),
|
||||
toJSONString (error)
|
||||
)
|
||||
);
|
||||
|
||||
-- Add the indexes
|
||||
ALTER TABLE trigger_dev.task_runs_v2 ADD INDEX idx_output_text output_text TYPE ngrambf_v1 (3, 131072, 3, 0) GRANULARITY 4;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2 ADD INDEX idx_error_text error_text TYPE ngrambf_v1 (3, 131072, 3, 0) GRANULARITY 4;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP INDEX IF EXISTS idx_output_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP INDEX IF EXISTS idx_error_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS output_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS error_text;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Add indexes for text search on task task_events_v2 tables for message and attributes fields
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_attributes_text_search lower(attributes_text)
|
||||
TYPE ngrambf_v1(3, 32768, 2, 0)
|
||||
GRANULARITY 1;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_message_text_search lower(message)
|
||||
TYPE ngrambf_v1(3, 32768, 2, 0)
|
||||
GRANULARITY 1;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX idx_attributes_text_search;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX idx_message_text_search;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.task_events_search_v1
|
||||
(
|
||||
environment_id String,
|
||||
organization_id String,
|
||||
project_id String,
|
||||
triggered_timestamp DateTime64(9) CODEC(Delta(8), ZSTD(1)),
|
||||
trace_id String CODEC(ZSTD(1)),
|
||||
span_id String CODEC(ZSTD(1)),
|
||||
run_id String CODEC(ZSTD(1)),
|
||||
task_identifier String CODEC(ZSTD(1)),
|
||||
start_time DateTime64(9) CODEC(Delta(8), ZSTD(1)),
|
||||
inserted_at DateTime64(3),
|
||||
message String CODEC(ZSTD(1)),
|
||||
kind LowCardinality(String) CODEC(ZSTD(1)),
|
||||
status LowCardinality(String) CODEC(ZSTD(1)),
|
||||
duration UInt64 CODEC(ZSTD(1)),
|
||||
parent_span_id String CODEC(ZSTD(1)),
|
||||
attributes_text String CODEC(ZSTD(1)),
|
||||
|
||||
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_message_text_search lower(message) TYPE ngrambf_v1(3, 32768, 2, 0) GRANULARITY 1,
|
||||
INDEX idx_attributes_text_search lower(attributes_text) TYPE ngrambf_v1(3, 32768, 2, 0) GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toDate(triggered_timestamp)
|
||||
ORDER BY (organization_id, environment_id, triggered_timestamp, trace_id)
|
||||
--Right now we have maximum retention of up to 30 days based on plan.
|
||||
--We put a logical limit for now, the 90 DAY TTL is just a backup
|
||||
--This might need to be updated for longer retention periods
|
||||
TTL toDateTime(triggered_timestamp) + INTERVAL 90 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.task_events_search_mv_v1
|
||||
TO trigger_dev.task_events_search_v1 AS
|
||||
SELECT
|
||||
environment_id,
|
||||
organization_id,
|
||||
project_id,
|
||||
trace_id,
|
||||
span_id,
|
||||
run_id,
|
||||
task_identifier,
|
||||
start_time,
|
||||
inserted_at,
|
||||
message,
|
||||
kind,
|
||||
status,
|
||||
duration,
|
||||
parent_span_id,
|
||||
attributes_text,
|
||||
fromUnixTimestamp64Nano(toUnixTimestamp64Nano(start_time) + toInt64(duration)) AS triggered_timestamp
|
||||
FROM trigger_dev.task_events_v2
|
||||
WHERE
|
||||
kind != 'DEBUG_EVENT'
|
||||
AND status != 'PARTIAL'
|
||||
AND NOT (kind = 'SPAN_EVENT' AND attributes_text = '{}')
|
||||
AND kind != 'ANCESTOR_OVERRIDE'
|
||||
AND message != 'trigger.dev/start';
|
||||
|
||||
-- +goose Down
|
||||
DROP VIEW IF EXISTS trigger_dev.task_events_search_mv_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.task_events_search_v1;
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
-- +goose Up
|
||||
-- We drop the existing MV and recreate it with the new filter condition
|
||||
DROP VIEW IF EXISTS trigger_dev.task_events_search_mv_v1;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.task_events_search_mv_v1
|
||||
TO trigger_dev.task_events_search_v1 AS
|
||||
SELECT
|
||||
environment_id,
|
||||
organization_id,
|
||||
project_id,
|
||||
trace_id,
|
||||
span_id,
|
||||
run_id,
|
||||
task_identifier,
|
||||
start_time,
|
||||
inserted_at,
|
||||
message,
|
||||
kind,
|
||||
status,
|
||||
duration,
|
||||
parent_span_id,
|
||||
attributes_text,
|
||||
fromUnixTimestamp64Nano(toUnixTimestamp64Nano(start_time) + toInt64(duration)) AS triggered_timestamp
|
||||
FROM trigger_dev.task_events_v2
|
||||
WHERE
|
||||
trace_id != '' -- New condition added here
|
||||
AND kind != 'DEBUG_EVENT'
|
||||
AND status != 'PARTIAL'
|
||||
AND NOT (kind = 'SPAN_EVENT' AND attributes_text = '{}')
|
||||
AND kind != 'ANCESTOR_OVERRIDE'
|
||||
AND message != 'trigger.dev/start';
|
||||
|
||||
-- +goose Down
|
||||
-- In the down migration, we revert to the previous filter set
|
||||
DROP VIEW IF EXISTS trigger_dev.task_events_search_mv_v1;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.task_events_search_mv_v1
|
||||
TO trigger_dev.task_events_search_v1 AS
|
||||
SELECT
|
||||
environment_id,
|
||||
organization_id,
|
||||
project_id,
|
||||
trace_id,
|
||||
span_id,
|
||||
run_id,
|
||||
task_identifier,
|
||||
start_time,
|
||||
inserted_at,
|
||||
message,
|
||||
kind,
|
||||
status,
|
||||
duration,
|
||||
parent_span_id,
|
||||
attributes_text,
|
||||
fromUnixTimestamp64Nano(toUnixTimestamp64Nano(start_time) + toInt64(duration)) AS triggered_timestamp
|
||||
FROM trigger_dev.task_events_v2
|
||||
WHERE
|
||||
kind != 'DEBUG_EVENT'
|
||||
AND status != 'PARTIAL'
|
||||
AND NOT (kind = 'SPAN_EVENT' AND attributes_text = '{}')
|
||||
AND kind != 'ANCESTOR_OVERRIDE'
|
||||
AND message != 'trigger.dev/start';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
|
||||
-- These indexes are not used in any WHERE clause.
|
||||
-- idx_duration: duration is only written/read as a column, never filtered on.
|
||||
-- idx_attributes_text: search queries use the task_events_search_v1 table instead.
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX IF EXISTS idx_duration;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX IF EXISTS idx_attributes_text;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_duration duration
|
||||
TYPE minmax
|
||||
GRANULARITY 1;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_attributes_text attributes_text
|
||||
TYPE tokenbf_v1(32768, 3, 0)
|
||||
GRANULARITY 8;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.metrics_v1
|
||||
(
|
||||
organization_id LowCardinality(String),
|
||||
project_id LowCardinality(String),
|
||||
environment_id String CODEC(ZSTD(1)),
|
||||
metric_name LowCardinality(String),
|
||||
metric_type LowCardinality(String),
|
||||
metric_subject String CODEC(ZSTD(1)),
|
||||
bucket_start DateTime CODEC(Delta(4), ZSTD(1)),
|
||||
value Float64 DEFAULT 0 CODEC(ZSTD(1)),
|
||||
attributes JSON(
|
||||
`trigger.run_id` String,
|
||||
`trigger.task_slug` String,
|
||||
`trigger.attempt_number` Int64,
|
||||
`trigger.environment_type` LowCardinality(String),
|
||||
`trigger.machine_id` String,
|
||||
`trigger.machine_name` LowCardinality(String),
|
||||
`trigger.worker_id` String,
|
||||
`trigger.worker_version` String,
|
||||
`system.cpu.logical_number` String,
|
||||
`system.cpu.state` LowCardinality(String),
|
||||
`system.memory.state` LowCardinality(String),
|
||||
`system.device` String,
|
||||
`system.filesystem.type` LowCardinality(String),
|
||||
`system.filesystem.mountpoint` String,
|
||||
`system.filesystem.mode` LowCardinality(String),
|
||||
`system.filesystem.state` LowCardinality(String),
|
||||
`disk.io.direction` LowCardinality(String),
|
||||
`process.cpu.state` LowCardinality(String),
|
||||
`network.io.direction` LowCardinality(String),
|
||||
max_dynamic_paths=8
|
||||
),
|
||||
INDEX idx_run_id attributes.trigger.run_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_task_slug attributes.trigger.task_slug TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toDate(bucket_start)
|
||||
ORDER BY (organization_id, project_id, environment_id, metric_name, metric_subject, bucket_start)
|
||||
TTL bucket_start + INTERVAL 60 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.metrics_v1;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD COLUMN machine_id String DEFAULT '' CODEC(ZSTD(1));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP COLUMN machine_id;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN error_fingerprint String DEFAULT '';
|
||||
|
||||
-- Bloom filter index for fast error fingerprint lookups
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD INDEX idx_error_fingerprint error_fingerprint TYPE bloom_filter GRANULARITY 4;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2 DROP INDEX idx_error_fingerprint;
|
||||
ALTER TABLE trigger_dev.task_runs_v2 DROP COLUMN error_fingerprint;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Aggregated error groups table (per task + fingerprint)
|
||||
CREATE TABLE trigger_dev.errors_v1
|
||||
(
|
||||
organization_id String,
|
||||
project_id String,
|
||||
environment_id String,
|
||||
task_identifier String,
|
||||
error_fingerprint String,
|
||||
|
||||
-- Error details (samples from occurrences)
|
||||
error_type String,
|
||||
error_message String,
|
||||
sample_stack_trace String,
|
||||
|
||||
-- SimpleAggregateFunction stores raw values and applies the function during merge,
|
||||
-- avoiding binary state encoding issues with AggregateFunction.
|
||||
last_seen_date SimpleAggregateFunction(max, DateTime),
|
||||
|
||||
first_seen SimpleAggregateFunction(min, DateTime64(3)),
|
||||
last_seen SimpleAggregateFunction(max, DateTime64(3)),
|
||||
occurrence_count AggregateFunction(sum, UInt64),
|
||||
affected_task_versions AggregateFunction(uniq, String),
|
||||
|
||||
-- Samples for debugging
|
||||
sample_run_id AggregateFunction(any, String),
|
||||
sample_friendly_id AggregateFunction(any, String),
|
||||
|
||||
-- Status distribution
|
||||
status_distribution AggregateFunction(sumMap, Array(String), Array(UInt64))
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (organization_id, project_id, environment_id, task_identifier, error_fingerprint)
|
||||
TTL last_seen_date + INTERVAL 90 DAY
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Materialized view to auto-populate from task_runs_v2
|
||||
CREATE MATERIALIZED VIEW trigger_dev.errors_mv_v1
|
||||
TO trigger_dev.errors_v1
|
||||
AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
|
||||
any(coalesce(nullIf(toString(error.data.type), ''), nullIf(toString(error.data.name), ''), 'Error')) as error_type,
|
||||
any(coalesce(nullIf(substring(toString(error.data.message), 1, 500), ''), 'Unknown error')) as error_message,
|
||||
any(coalesce(substring(toString(error.data.stack), 1, 2000), '')) as sample_stack_trace,
|
||||
|
||||
toDateTime(max(created_at)) as last_seen_date,
|
||||
|
||||
min(created_at) as first_seen,
|
||||
max(created_at) as last_seen,
|
||||
sumState(toUInt64(1)) as occurrence_count,
|
||||
uniqState(task_version) as affected_task_versions,
|
||||
|
||||
anyState(run_id) as sample_run_id,
|
||||
anyState(friendly_id) as sample_friendly_id,
|
||||
|
||||
sumMapState([status], [toUInt64(1)]) as status_distribution
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint;
|
||||
|
||||
-- +goose Down
|
||||
DROP VIEW IF EXISTS trigger_dev.errors_mv_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.errors_v1;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- +goose Up
|
||||
-- Per-minute error occurrence counts, keyed by fingerprint + task + version.
|
||||
-- Powers precise time-range filtering and dynamic-granularity occurrence charts.
|
||||
CREATE TABLE
|
||||
trigger_dev.error_occurrences_v1 (
|
||||
organization_id String,
|
||||
project_id String,
|
||||
environment_id String,
|
||||
task_identifier String,
|
||||
error_fingerprint String,
|
||||
task_version String,
|
||||
minute DateTime,
|
||||
error_type String,
|
||||
error_message String,
|
||||
stack_trace String,
|
||||
count UInt64,
|
||||
INDEX idx_error_type_search lower(error_type) TYPE ngrambf_v1 (3, 32768, 2, 0) GRANULARITY 1,
|
||||
INDEX idx_error_message_search lower(error_message) TYPE ngrambf_v1 (3, 32768, 2, 0) GRANULARITY 1
|
||||
) ENGINE = SummingMergeTree (count)
|
||||
PARTITION BY
|
||||
toDate (minute)
|
||||
ORDER BY
|
||||
(
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
minute
|
||||
) TTL minute + INTERVAL 90 DAY SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW trigger_dev.error_occurrences_mv_v1 TO trigger_dev.error_occurrences_v1 AS
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
toStartOfMinute (created_at) as minute,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(toString (error.data.type), ''),
|
||||
nullIf(toString (error.data.name), ''),
|
||||
'Error'
|
||||
)
|
||||
) as error_type,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(
|
||||
substring(toString (error.data.message), 1, 500),
|
||||
''
|
||||
),
|
||||
'Unknown error'
|
||||
)
|
||||
) as error_message,
|
||||
any (
|
||||
coalesce(
|
||||
substring(toString (error.data.stack), 1, 2000),
|
||||
''
|
||||
)
|
||||
) as stack_trace,
|
||||
count() as count
|
||||
FROM
|
||||
trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN (
|
||||
'SYSTEM_FAILURE',
|
||||
'CRASHED',
|
||||
'INTERRUPTED',
|
||||
'COMPLETED_WITH_ERRORS',
|
||||
'TIMED_OUT'
|
||||
)
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
minute;
|
||||
|
||||
-- +goose Down
|
||||
DROP VIEW IF EXISTS trigger_dev.error_occurrences_mv_v1;
|
||||
|
||||
DROP TABLE IF EXISTS trigger_dev.error_occurrences_v1;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.llm_metrics_v1
|
||||
(
|
||||
-- Tenant context
|
||||
organization_id LowCardinality(String),
|
||||
project_id LowCardinality(String),
|
||||
environment_id String CODEC(ZSTD(1)),
|
||||
run_id String CODEC(ZSTD(1)),
|
||||
task_identifier LowCardinality(String),
|
||||
trace_id String CODEC(ZSTD(1)),
|
||||
span_id String CODEC(ZSTD(1)),
|
||||
|
||||
-- Model & provider
|
||||
gen_ai_system LowCardinality(String),
|
||||
request_model String CODEC(ZSTD(1)),
|
||||
response_model String CODEC(ZSTD(1)),
|
||||
matched_model_id String CODEC(ZSTD(1)),
|
||||
operation_id LowCardinality(String),
|
||||
finish_reason LowCardinality(String),
|
||||
cost_source LowCardinality(String),
|
||||
|
||||
-- Pricing
|
||||
pricing_tier_id String CODEC(ZSTD(1)),
|
||||
pricing_tier_name LowCardinality(String),
|
||||
|
||||
-- Token usage
|
||||
input_tokens UInt64 DEFAULT 0,
|
||||
output_tokens UInt64 DEFAULT 0,
|
||||
total_tokens UInt64 DEFAULT 0,
|
||||
usage_details Map(LowCardinality(String), UInt64),
|
||||
|
||||
-- Cost
|
||||
input_cost Decimal64(12) DEFAULT 0,
|
||||
output_cost Decimal64(12) DEFAULT 0,
|
||||
total_cost Decimal64(12) DEFAULT 0,
|
||||
cost_details Map(LowCardinality(String), Decimal64(12)),
|
||||
provider_cost Decimal64(12) DEFAULT 0,
|
||||
|
||||
-- Performance
|
||||
ms_to_first_chunk Float64 DEFAULT 0,
|
||||
tokens_per_second Float64 DEFAULT 0,
|
||||
|
||||
-- Attribution
|
||||
metadata Map(LowCardinality(String), String),
|
||||
|
||||
-- Timing
|
||||
start_time DateTime64(9) CODEC(Delta(8), ZSTD(1)),
|
||||
duration UInt64 DEFAULT 0 CODEC(ZSTD(1)),
|
||||
inserted_at DateTime64(3) DEFAULT now64(3),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_span_id span_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_response_model response_model TYPE bloom_filter(0.01) GRANULARITY 1,
|
||||
INDEX idx_metadata_keys mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toDate(inserted_at)
|
||||
ORDER BY (organization_id, project_id, environment_id, toDate(inserted_at), run_id)
|
||||
TTL toDateTime(inserted_at) + INTERVAL 365 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.llm_metrics_v1;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
ADD COLUMN prompt_slug LowCardinality(String) DEFAULT '';
|
||||
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
ADD COLUMN prompt_version UInt32 DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
DROP COLUMN prompt_slug;
|
||||
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
DROP COLUMN prompt_version;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
ADD COLUMN base_response_model String DEFAULT '' CODEC(ZSTD(1));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.llm_metrics_v1
|
||||
DROP COLUMN base_response_model;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Pre-aggregated model performance metrics with no tenant information.
|
||||
-- Used for cross-tenant model comparisons in the Model Registry.
|
||||
-- Aggregated per minute for high-resolution model performance tracking.
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.llm_model_aggregates_v1
|
||||
(
|
||||
response_model String,
|
||||
base_response_model String DEFAULT '',
|
||||
gen_ai_system LowCardinality(String),
|
||||
minute DateTime,
|
||||
|
||||
-- Counts & totals (SimpleAggregateFunction for sum)
|
||||
call_count SimpleAggregateFunction(sum, UInt64),
|
||||
total_input_tokens SimpleAggregateFunction(sum, UInt64),
|
||||
total_output_tokens SimpleAggregateFunction(sum, UInt64),
|
||||
total_cost SimpleAggregateFunction(sum, Float64),
|
||||
|
||||
-- Performance quantiles (AggregateFunction for merge across parts)
|
||||
ttfc_quantiles AggregateFunction(quantiles(0.5, 0.9, 0.95, 0.99), Float64),
|
||||
tps_quantiles AggregateFunction(quantiles(0.5, 0.9, 0.95, 0.99), Float64),
|
||||
duration_quantiles AggregateFunction(quantiles(0.5, 0.9, 0.95, 0.99), UInt64),
|
||||
|
||||
-- Finish reason distribution
|
||||
finish_reason_counts SimpleAggregateFunction(sumMap, Map(String, UInt64))
|
||||
)
|
||||
ENGINE = AggregatingMergeTree
|
||||
PARTITION BY toYYYYMM(minute)
|
||||
ORDER BY (response_model, base_response_model, gen_ai_system, minute)
|
||||
TTL toDate(minute) + INTERVAL 365 DAY
|
||||
SETTINGS ttl_only_drop_parts = 1;
|
||||
|
||||
-- Materialized view that feeds the aggregate table from llm_metrics_v1.
|
||||
-- Strips all tenant-specific columns (org, project, env, run, span, trace).
|
||||
-- base_response_model comes from the source table (populated during event enrichment).
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.llm_model_aggregates_mv_v1
|
||||
TO trigger_dev.llm_model_aggregates_v1
|
||||
AS SELECT
|
||||
response_model,
|
||||
base_response_model,
|
||||
gen_ai_system,
|
||||
toStartOfMinute(start_time) AS minute,
|
||||
count() AS call_count,
|
||||
sum(input_tokens) AS total_input_tokens,
|
||||
sum(output_tokens) AS total_output_tokens,
|
||||
sum(total_cost) AS total_cost,
|
||||
quantilesStateIf(0.5, 0.9, 0.95, 0.99)(ms_to_first_chunk, ms_to_first_chunk > 0) AS ttfc_quantiles,
|
||||
quantilesStateIf(0.5, 0.9, 0.95, 0.99)(tokens_per_second, tokens_per_second > 0) AS tps_quantiles,
|
||||
quantilesState(0.5, 0.9, 0.95, 0.99)(duration) AS duration_quantiles,
|
||||
sumMap(map(finish_reason, toUInt64(1))) AS finish_reason_counts
|
||||
FROM trigger_dev.llm_metrics_v1
|
||||
WHERE response_model != ''
|
||||
GROUP BY response_model, base_response_model, gen_ai_system, minute;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.llm_model_aggregates_mv_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.llm_model_aggregates_v1;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN trigger_source LowCardinality(String) DEFAULT '';
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN root_trigger_source LowCardinality(String) DEFAULT '';
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN is_warm_start Nullable(UInt8) DEFAULT NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN trigger_source;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN root_trigger_source;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN is_warm_start;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE trigger_dev.sessions_v1
|
||||
(
|
||||
/* ─── identity ─────────────────────────────────────────────── */
|
||||
environment_id String,
|
||||
organization_id String,
|
||||
project_id String,
|
||||
session_id String,
|
||||
|
||||
environment_type LowCardinality(String),
|
||||
friendly_id String,
|
||||
external_id String DEFAULT '',
|
||||
|
||||
/* ─── type discriminator ──────────────────────────────────── */
|
||||
type LowCardinality(String),
|
||||
task_identifier String DEFAULT '',
|
||||
|
||||
/* ─── filtering / free-form ──────────────────────────────── */
|
||||
tags Array(String) CODEC(ZSTD(1)),
|
||||
metadata JSON(max_dynamic_paths = 256),
|
||||
|
||||
/* ─── terminal markers ────────────────────────────────────── */
|
||||
closed_at Nullable(DateTime64(3)),
|
||||
closed_reason String DEFAULT '',
|
||||
expires_at Nullable(DateTime64(3)),
|
||||
|
||||
/* ─── timing ─────────────────────────────────────────────── */
|
||||
created_at DateTime64(3),
|
||||
updated_at DateTime64(3),
|
||||
|
||||
/* ─── commit lsn ────────────────────────────────────────── */
|
||||
_version UInt64,
|
||||
_is_deleted UInt8 DEFAULT 0
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(_version, _is_deleted)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (organization_id, project_id, environment_id, created_at, session_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.sessions_v1;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- IF NOT EXISTS is required because this migration was previously numbered
|
||||
-- 029 and may have been applied in environments where goose accepted it
|
||||
-- before 030_create_sessions_v1 advanced the version counter. Renaming to
|
||||
-- 031 makes goose treat this as new everywhere, so the DDL must tolerate
|
||||
-- the column already being present.
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN IF NOT EXISTS task_kind LowCardinality(String) DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS task_kind;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN IF NOT EXISTS region String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS region;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN IF NOT EXISTS plan_type LowCardinality(String) DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS plan_type;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Existing rows default to 0 and are intentionally NOT backfilled: the Sessions
|
||||
-- list reads isTest from Postgres (ClickHouse only supplies session IDs), so the
|
||||
-- UI is correct without it. A backfill is only needed if a ClickHouse-side
|
||||
-- isTest filter/aggregate over sessions_v1 is added later.
|
||||
ALTER TABLE trigger_dev.sessions_v1
|
||||
ADD COLUMN IF NOT EXISTS is_test UInt8 DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.sessions_v1
|
||||
DROP COLUMN IF EXISTS is_test;
|
||||
@@ -0,0 +1,192 @@
|
||||
-- +goose Up
|
||||
-- Fix how the error materialized views derive their display columns from the
|
||||
-- stored error JSON:
|
||||
-- * error_type: use the real class name (or internal code), not the generic
|
||||
-- serialization tag (BUILT_IN_ERROR / STRING_ERROR / ...).
|
||||
-- * error_message: fall back name -> raw before 'Unknown error' so messageless
|
||||
-- errors (tagged errors) and non-Error throws still get a meaningful title.
|
||||
-- * stack trace: read error.data.stackTrace (the stored field), not
|
||||
-- error.data.stack, which was always empty.
|
||||
-- Display-only. Only affects rows inserted after this migration.
|
||||
|
||||
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
|
||||
any(coalesce(nullIf(toString(error.data.name), ''), nullIf(toString(error.data.code), ''), 'Error')) as error_type,
|
||||
any(coalesce(
|
||||
nullIf(substring(toString(error.data.message), 1, 500), ''),
|
||||
nullIf(toString(error.data.name), ''),
|
||||
nullIf(substring(toString(error.data.raw), 1, 500), ''),
|
||||
'Unknown error'
|
||||
)) as error_message,
|
||||
any(coalesce(substring(toString(error.data.stackTrace), 1, 2000), '')) as sample_stack_trace,
|
||||
|
||||
toDateTime(max(created_at)) as last_seen_date,
|
||||
|
||||
min(created_at) as first_seen,
|
||||
max(created_at) as last_seen,
|
||||
sumState(toUInt64(1)) as occurrence_count,
|
||||
uniqState(task_version) as affected_task_versions,
|
||||
|
||||
anyState(run_id) as sample_run_id,
|
||||
anyState(friendly_id) as sample_friendly_id,
|
||||
|
||||
sumMapState([status], [toUInt64(1)]) as status_distribution
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint;
|
||||
|
||||
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
toStartOfMinute (created_at) as minute,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(toString (error.data.name), ''),
|
||||
nullIf(toString (error.data.code), ''),
|
||||
'Error'
|
||||
)
|
||||
) as error_type,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(substring(toString (error.data.message), 1, 500), ''),
|
||||
nullIf(toString (error.data.name), ''),
|
||||
nullIf(substring(toString (error.data.raw), 1, 500), ''),
|
||||
'Unknown error'
|
||||
)
|
||||
) as error_message,
|
||||
any (
|
||||
coalesce(
|
||||
substring(toString (error.data.stackTrace), 1, 2000),
|
||||
''
|
||||
)
|
||||
) as stack_trace,
|
||||
count() as count
|
||||
FROM
|
||||
trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN (
|
||||
'SYSTEM_FAILURE',
|
||||
'CRASHED',
|
||||
'INTERRUPTED',
|
||||
'COMPLETED_WITH_ERRORS',
|
||||
'TIMED_OUT'
|
||||
)
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
minute;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE trigger_dev.errors_mv_v1 MODIFY QUERY
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
|
||||
any(coalesce(nullIf(toString(error.data.type), ''), nullIf(toString(error.data.name), ''), 'Error')) as error_type,
|
||||
any(coalesce(nullIf(substring(toString(error.data.message), 1, 500), ''), 'Unknown error')) as error_message,
|
||||
any(coalesce(substring(toString(error.data.stack), 1, 2000), '')) as sample_stack_trace,
|
||||
|
||||
toDateTime(max(created_at)) as last_seen_date,
|
||||
|
||||
min(created_at) as first_seen,
|
||||
max(created_at) as last_seen,
|
||||
sumState(toUInt64(1)) as occurrence_count,
|
||||
uniqState(task_version) as affected_task_versions,
|
||||
|
||||
anyState(run_id) as sample_run_id,
|
||||
anyState(friendly_id) as sample_friendly_id,
|
||||
|
||||
sumMapState([status], [toUInt64(1)]) as status_distribution
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint;
|
||||
|
||||
ALTER TABLE trigger_dev.error_occurrences_mv_v1 MODIFY QUERY
|
||||
SELECT
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
toStartOfMinute (created_at) as minute,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(toString (error.data.type), ''),
|
||||
nullIf(toString (error.data.name), ''),
|
||||
'Error'
|
||||
)
|
||||
) as error_type,
|
||||
any (
|
||||
coalesce(
|
||||
nullIf(
|
||||
substring(toString (error.data.message), 1, 500),
|
||||
''
|
||||
),
|
||||
'Unknown error'
|
||||
)
|
||||
) as error_message,
|
||||
any (
|
||||
coalesce(
|
||||
substring(toString (error.data.stack), 1, 2000),
|
||||
''
|
||||
)
|
||||
) as stack_trace,
|
||||
count() as count
|
||||
FROM
|
||||
trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
error_fingerprint != ''
|
||||
AND status IN (
|
||||
'SYSTEM_FAILURE',
|
||||
'CRASHED',
|
||||
'INTERRUPTED',
|
||||
'COMPLETED_WITH_ERRORS',
|
||||
'TIMED_OUT'
|
||||
)
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
organization_id,
|
||||
project_id,
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
minute;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Task Runs V2 > should be able to query task runs using the query builder 1`] = `
|
||||
{
|
||||
"params": {
|
||||
"environmentId": "cm9kddfcs01zqdy88ld9mmrli",
|
||||
},
|
||||
"query": "SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Task Runs V2 > should be able to query task runs using the query builder 2`] = `
|
||||
{
|
||||
"params": {
|
||||
"environmentId": "cm9kddfcs01zqdy88ld9mmrli",
|
||||
"status": "COMPLETED_SUCCESSFULLY",
|
||||
},
|
||||
"query": "SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String} AND status = {status: String}",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { ClickhouseClient } from "./client.js";
|
||||
import { z } from "zod";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
describe("ClickHouse Client", () => {
|
||||
clickhouseTest("should be able to insert and query data", async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insertSmokeTest = client.insert({
|
||||
name: "insert-smoke-test",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const querySmokeTest = client.query({
|
||||
name: "query-smoke-test",
|
||||
query: "SELECT * FROM trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
timestamp: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertError, insertResult] = await insertSmokeTest([
|
||||
{ message: "hello", number: 42 },
|
||||
{ message: "world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
expect(insertResult).toEqual(
|
||||
expect.objectContaining({
|
||||
executed: true,
|
||||
query_id: expect.any(String),
|
||||
summary: expect.objectContaining({ read_rows: "2", elapsed_ns: expect.any(String) }),
|
||||
})
|
||||
);
|
||||
|
||||
const [queryError, result] = await querySmokeTest({});
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "hello",
|
||||
number: 42,
|
||||
timestamp: expect.any(String),
|
||||
id: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: "world",
|
||||
number: 100,
|
||||
timestamp: expect.any(String),
|
||||
id: expect.any(String),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const insertSmokeTestAsyncWaiting = client.insert({
|
||||
name: "insert-smoke-test-async-waiting",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1,
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const [insertErrorAsyncWaiting, insertResultAsyncWaiting] = await insertSmokeTestAsyncWaiting([
|
||||
{ message: "async-waiting-hello", number: 42 },
|
||||
{ message: "async-waiting-world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertErrorAsyncWaiting).toBeNull();
|
||||
expect(insertResultAsyncWaiting).toEqual(expect.objectContaining({ executed: true }));
|
||||
|
||||
// Should be able to query for the data right away
|
||||
const [queryErrorAsyncWaiting, resultAsyncWaiting] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncWaiting).toBeNull();
|
||||
expect(resultAsyncWaiting).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: "async-waiting-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-waiting-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
|
||||
const insertSmokeTestAsyncDontWait = client.insert({
|
||||
name: "insert-smoke-test-async-dont-wait",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const [insertErrorAsyncDontWait, insertResultAsyncDontWait] =
|
||||
await insertSmokeTestAsyncDontWait([
|
||||
{ message: "async-dont-wait-hello", number: 42 },
|
||||
{ message: "async-dont-wait-world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertErrorAsyncDontWait).toBeNull();
|
||||
expect(insertResultAsyncDontWait).toEqual(expect.objectContaining({ executed: true }));
|
||||
|
||||
// Querying now should return an array without the data
|
||||
const [queryErrorAsyncDontWait, resultAsyncDontWait] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncDontWait).toBeNull();
|
||||
expect(resultAsyncDontWait).toEqual(
|
||||
expect.not.arrayContaining([
|
||||
expect.objectContaining({ message: "async-dont-wait-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-dont-wait-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
|
||||
// Now we wait for the data to be flushed
|
||||
await setTimeout(2000);
|
||||
|
||||
// Querying now should return the data
|
||||
const [queryErrorAsyncDontWait2, resultAsyncDontWait2] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncDontWait2).toBeNull();
|
||||
expect(resultAsyncDontWait2).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: "async-dont-wait-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-dont-wait-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
export type ErrorContext = Record<string, unknown>;
|
||||
|
||||
export abstract class BaseError<TContext extends ErrorContext = ErrorContext> extends Error {
|
||||
public abstract readonly retry: boolean;
|
||||
public readonly cause: BaseError | undefined;
|
||||
public readonly context: TContext | undefined;
|
||||
public readonly message: string;
|
||||
public abstract readonly name: string;
|
||||
|
||||
constructor(opts: { message: string; cause?: BaseError; context?: TContext }) {
|
||||
super(opts.message);
|
||||
this.message = opts.message;
|
||||
this.cause = opts.cause;
|
||||
this.context = opts.context;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return `${this.name}: ${this.message} - ${JSON.stringify(
|
||||
this.context
|
||||
)} - caused by ${this.cause?.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsertError extends BaseError {
|
||||
public readonly retry = true;
|
||||
public readonly name = InsertError.name;
|
||||
constructor(message: string) {
|
||||
super({
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
export class QueryError extends BaseError<{ query: string }> {
|
||||
public readonly retry = true;
|
||||
public readonly name = QueryError.name;
|
||||
constructor(message: string, context: { query: string }) {
|
||||
super({
|
||||
message,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Result } from "@trigger.dev/core/v3";
|
||||
import { InsertError, QueryError } from "./errors.js";
|
||||
import type {
|
||||
ClickhouseQueryBuilderFastFunction,
|
||||
ClickhouseQueryBuilderFunction,
|
||||
ClickhouseReader,
|
||||
ClickhouseWriter,
|
||||
QueryResultWithStats,
|
||||
} from "./types.js";
|
||||
import type { z } from "zod";
|
||||
import type { ClickHouseSettings, InsertResult } from "@clickhouse/client";
|
||||
import { ClickhouseQueryBuilder, ClickhouseQueryFastBuilder } from "./queryBuilder.js";
|
||||
|
||||
export class NoopClient implements ClickhouseReader, ClickhouseWriter {
|
||||
public async close() {
|
||||
return;
|
||||
}
|
||||
|
||||
public queryBuilder<TOut extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
baseQuery: string;
|
||||
schema: TOut;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryBuilderFunction<z.input<TOut>> {
|
||||
return () =>
|
||||
new ClickhouseQueryBuilder(req.name, req.baseQuery, this, req.schema, req.settings);
|
||||
}
|
||||
|
||||
public queryBuilderFast<TOut extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryBuilderFastFunction<TOut> {
|
||||
return () =>
|
||||
new ClickhouseQueryFastBuilder(req.name, req.table, req.columns, this, req.settings);
|
||||
}
|
||||
|
||||
public query<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
query: string;
|
||||
params?: TIn;
|
||||
schema: TOut;
|
||||
}): (params: z.input<TIn>) => Promise<Result<z.output<TOut>[], QueryError>> {
|
||||
return async (params: z.input<TIn>) => {
|
||||
const validParams = req.params?.safeParse(params);
|
||||
|
||||
if (validParams?.error) {
|
||||
return [new QueryError(`Bad params: ${validParams.error.message}`, { query: "" }), null];
|
||||
}
|
||||
|
||||
return [null, []];
|
||||
};
|
||||
}
|
||||
|
||||
public queryWithStats<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
query: string;
|
||||
params?: TIn;
|
||||
schema: TOut;
|
||||
}): (params: z.input<TIn>) => Promise<Result<QueryResultWithStats<z.output<TOut>>, QueryError>> {
|
||||
return async (params: z.input<TIn>) => {
|
||||
const validParams = req.params?.safeParse(params);
|
||||
|
||||
if (validParams?.error) {
|
||||
return [new QueryError(`Bad params: ${validParams.error.message}`, { query: "" }), null];
|
||||
}
|
||||
|
||||
return [
|
||||
null,
|
||||
{
|
||||
rows: [],
|
||||
stats: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
byte_seconds: "0",
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
query: string;
|
||||
columns: string[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): (params: TParams) => Promise<Result<TOut[], QueryError>> {
|
||||
return async (params: TParams) => {
|
||||
return [null, []];
|
||||
};
|
||||
}
|
||||
|
||||
public queryFastStream<
|
||||
TOut extends Record<string, any>,
|
||||
TParams extends Record<string, any>,
|
||||
>(req: {
|
||||
name: string;
|
||||
query: string;
|
||||
columns: string[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): (params: TParams) => AsyncIterable<TOut> {
|
||||
return async function* () {
|
||||
// Noop: empty stream.
|
||||
};
|
||||
}
|
||||
|
||||
public insert<TSchema extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
schema: TSchema;
|
||||
settings?: ClickHouseSettings;
|
||||
}): (
|
||||
events: z.input<TSchema> | z.input<TSchema>[]
|
||||
) => Promise<Result<InsertResult, InsertError>> {
|
||||
return async (events: z.input<TSchema> | z.input<TSchema>[]) => {
|
||||
const v = Array.isArray(events)
|
||||
? req.schema.array().safeParse(events)
|
||||
: req.schema.safeParse(events);
|
||||
|
||||
if (!v.success) {
|
||||
return [new InsertError(v.error.message), null];
|
||||
}
|
||||
|
||||
return [
|
||||
null,
|
||||
{
|
||||
executed: true,
|
||||
query_id: "noop",
|
||||
summary: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
},
|
||||
response_headers: {},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public insertUnsafe<TRecord extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
settings?: ClickHouseSettings;
|
||||
}): (events: TRecord | TRecord[]) => Promise<Result<InsertResult, InsertError>> {
|
||||
return async (events: TRecord | TRecord[]) => {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
executed: true,
|
||||
query_id: "noop",
|
||||
summary: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
},
|
||||
response_headers: {},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public insertCompact<TRecord extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: readonly string[];
|
||||
toArray: (record: TRecord) => any[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): (events: TRecord | TRecord[]) => Promise<Result<InsertResult, InsertError>> {
|
||||
return async (events: TRecord | TRecord[]) => {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
executed: true,
|
||||
query_id: "noop",
|
||||
summary: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
},
|
||||
response_headers: {},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public insertCompactRaw(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: readonly string[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): (events: readonly any[][] | any[]) => Promise<Result<InsertResult, InsertError>> {
|
||||
return async (events: readonly any[][] | any[]) => {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
executed: true,
|
||||
query_id: "noop",
|
||||
summary: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
},
|
||||
response_headers: {},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseQueryFunction, ClickhouseReader, ColumnExpression } from "./types.js";
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
export type QueryParamValue = string | number | boolean | Array<string | number | boolean> | null;
|
||||
export type QueryParams = Record<string, QueryParamValue>;
|
||||
|
||||
export type WhereCondition = {
|
||||
clause: string;
|
||||
params?: QueryParams;
|
||||
};
|
||||
|
||||
export class ClickhouseQueryBuilder<TOutput> {
|
||||
private name: string;
|
||||
private baseQuery: string;
|
||||
private whereClauses: string[] = [];
|
||||
private havingClauses: string[] = [];
|
||||
private params: QueryParams = {};
|
||||
private orderByClause: string | null = null;
|
||||
private limitClause: string | null = null;
|
||||
private reader: ClickhouseReader;
|
||||
private schema: z.ZodSchema<TOutput>;
|
||||
private settings: ClickHouseSettings | undefined;
|
||||
private groupByClause: string | null = null;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
baseQuery: string,
|
||||
reader: ClickhouseReader,
|
||||
schema: z.ZodSchema<TOutput>,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
this.name = name;
|
||||
this.baseQuery = baseQuery;
|
||||
this.reader = reader;
|
||||
this.schema = schema;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/** Set query parameters without adding a WHERE clause. Use for base queries with inline params. */
|
||||
setParams(params: QueryParams): this {
|
||||
Object.assign(this.params, params);
|
||||
return this;
|
||||
}
|
||||
|
||||
where(clause: string, params?: QueryParams): this {
|
||||
this.whereClauses.push(clause);
|
||||
if (params) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
whereIf(condition: any, clause: string, params?: QueryParams): this {
|
||||
if (condition) {
|
||||
this.where(clause, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
whereOr(conditions: WhereCondition[]): this {
|
||||
if (conditions.length === 0) {
|
||||
return this;
|
||||
}
|
||||
const combinedClause = conditions.map((c) => `(${c.clause})`).join(" OR ");
|
||||
this.whereClauses.push(`(${combinedClause})`);
|
||||
for (const condition of conditions) {
|
||||
if (condition.params) {
|
||||
Object.assign(this.params, condition.params);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
groupBy(clause: string): this {
|
||||
this.groupByClause = clause;
|
||||
return this;
|
||||
}
|
||||
|
||||
having(clause: string, params?: QueryParams): this {
|
||||
this.havingClauses.push(clause);
|
||||
if (params) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
havingIf(condition: any, clause: string, params?: QueryParams): this {
|
||||
if (condition) {
|
||||
this.having(clause, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
orderBy(clause: string): this {
|
||||
this.orderByClause = clause;
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(limit: number): this {
|
||||
this.limitClause = `LIMIT ${limit}`;
|
||||
return this;
|
||||
}
|
||||
|
||||
execute(): ReturnType<ClickhouseQueryFunction<void, TOutput>> {
|
||||
const { query, params } = this.build();
|
||||
|
||||
const queryFunction = this.reader.query({
|
||||
name: this.name,
|
||||
query,
|
||||
params: z.any(),
|
||||
schema: this.schema,
|
||||
settings: this.settings,
|
||||
});
|
||||
|
||||
return queryFunction(params);
|
||||
}
|
||||
|
||||
build(): { query: string; params: QueryParams } {
|
||||
let query = this.baseQuery;
|
||||
if (this.whereClauses.length > 0) {
|
||||
query += " WHERE " + this.whereClauses.join(" AND ");
|
||||
}
|
||||
if (this.groupByClause) {
|
||||
query += ` GROUP BY ${this.groupByClause}`;
|
||||
}
|
||||
if (this.havingClauses.length > 0) {
|
||||
query += " HAVING " + this.havingClauses.join(" AND ");
|
||||
}
|
||||
if (this.orderByClause) {
|
||||
query += ` ORDER BY ${this.orderByClause}`;
|
||||
}
|
||||
if (this.limitClause) {
|
||||
query += ` ${this.limitClause}`;
|
||||
}
|
||||
return { query, params: this.params };
|
||||
}
|
||||
}
|
||||
|
||||
export class ClickhouseQueryFastBuilder<TOutput extends Record<string, any>> {
|
||||
private name: string;
|
||||
private table: string;
|
||||
private columns: Array<string | ColumnExpression>;
|
||||
private reader: ClickhouseReader;
|
||||
private settings: ClickHouseSettings | undefined;
|
||||
private prewhereClauses: string[] = [];
|
||||
private whereClauses: string[] = [];
|
||||
private havingClauses: string[] = [];
|
||||
private params: QueryParams = {};
|
||||
private orderByClause: string | null = null;
|
||||
private limitClause: string | null = null;
|
||||
private groupByClause: string | null = null;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
table: string,
|
||||
columns: Array<string | ColumnExpression>,
|
||||
reader: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
this.name = name;
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
this.reader = reader;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a PREWHERE clause - filters applied before reading columns.
|
||||
* Use for primary key columns (environment_id, start_time) to reduce I/O.
|
||||
*/
|
||||
prewhere(clause: string, params?: QueryParams): this {
|
||||
this.prewhereClauses.push(clause);
|
||||
if (params) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
prewhereIf(condition: any, clause: string, params?: QueryParams): this {
|
||||
if (condition) {
|
||||
this.prewhere(clause, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
where(clause: string, params?: QueryParams): this {
|
||||
this.whereClauses.push(clause);
|
||||
if (params) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
whereIf(condition: any, clause: string, params?: QueryParams): this {
|
||||
if (condition) {
|
||||
this.where(clause, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
whereOr(conditions: WhereCondition[]): this {
|
||||
if (conditions.length === 0) {
|
||||
return this;
|
||||
}
|
||||
const combinedClause = conditions.map((c) => `(${c.clause})`).join(" OR ");
|
||||
this.whereClauses.push(`(${combinedClause})`);
|
||||
for (const condition of conditions) {
|
||||
if (condition.params) {
|
||||
Object.assign(this.params, condition.params);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
groupBy(clause: string): this {
|
||||
this.groupByClause = clause;
|
||||
return this;
|
||||
}
|
||||
|
||||
having(clause: string, params?: QueryParams): this {
|
||||
this.havingClauses.push(clause);
|
||||
if (params) {
|
||||
Object.assign(this.params, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
havingIf(condition: any, clause: string, params?: QueryParams): this {
|
||||
if (condition) {
|
||||
this.having(clause, params);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
orderBy(clause: string): this {
|
||||
this.orderByClause = clause;
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(limit: number): this {
|
||||
this.limitClause = `LIMIT ${limit}`;
|
||||
return this;
|
||||
}
|
||||
|
||||
execute(): ReturnType<ClickhouseQueryFunction<void, TOutput>> {
|
||||
const { query, params } = this.build();
|
||||
|
||||
const queryFunction = this.reader.queryFast<TOutput, Record<string, any>>({
|
||||
name: this.name,
|
||||
query,
|
||||
columns: this.columns,
|
||||
settings: this.settings,
|
||||
});
|
||||
|
||||
return queryFunction(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link execute} but streams rows instead of buffering them into an
|
||||
* array. Returns an async iterable so the whole result set is never resident
|
||||
* in memory at once.
|
||||
*/
|
||||
executeStream(): AsyncIterable<TOutput> {
|
||||
const { query, params } = this.build();
|
||||
|
||||
const streamFunction = this.reader.queryFastStream<TOutput, Record<string, any>>({
|
||||
name: this.name,
|
||||
query,
|
||||
columns: this.columns,
|
||||
settings: this.settings,
|
||||
});
|
||||
|
||||
return streamFunction(params);
|
||||
}
|
||||
|
||||
build(): { query: string; params: QueryParams } {
|
||||
let query = `SELECT ${this.buildColumns().join(", ")} FROM ${this.table}`;
|
||||
if (this.prewhereClauses.length > 0) {
|
||||
query += " PREWHERE " + this.prewhereClauses.join(" AND ");
|
||||
}
|
||||
if (this.whereClauses.length > 0) {
|
||||
query += " WHERE " + this.whereClauses.join(" AND ");
|
||||
}
|
||||
if (this.groupByClause) {
|
||||
query += ` GROUP BY ${this.groupByClause}`;
|
||||
}
|
||||
if (this.havingClauses.length > 0) {
|
||||
query += " HAVING " + this.havingClauses.join(" AND ");
|
||||
}
|
||||
if (this.orderByClause) {
|
||||
query += ` ORDER BY ${this.orderByClause}`;
|
||||
}
|
||||
if (this.limitClause) {
|
||||
query += ` ${this.limitClause}`;
|
||||
}
|
||||
return { query, params: this.params };
|
||||
}
|
||||
|
||||
buildColumns(): string[] {
|
||||
return this.columns.map((column) => {
|
||||
if (typeof column === "string") {
|
||||
return column;
|
||||
}
|
||||
return [column.expression, column.name].join(" AS ");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* TSQL Query Execution for ClickHouse
|
||||
*
|
||||
* This module provides a safe interface for executing TSQL queries against ClickHouse
|
||||
* with enforced WHERE clause conditions (tenant isolation + plan limits) and SQL injection protection.
|
||||
*/
|
||||
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import {
|
||||
compileTSQL,
|
||||
type OutputColumnMetadata,
|
||||
sanitizeErrorMessage,
|
||||
transformResults,
|
||||
type FieldMappings,
|
||||
type QuerySettings,
|
||||
type TableSchema,
|
||||
type TimeRange,
|
||||
type WhereClauseCondition,
|
||||
} from "@internal/tsql";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
import { z } from "zod";
|
||||
import { QueryError } from "./errors.js";
|
||||
import type { ClickhouseReader, QueryStats } from "./types.js";
|
||||
|
||||
const logger = new Logger("tsql", "info");
|
||||
|
||||
export type { QueryStats };
|
||||
|
||||
export type { FieldMappings, QuerySettings, TableSchema, TimeRange, WhereClauseCondition };
|
||||
|
||||
/**
|
||||
* Options for executing a TSQL query
|
||||
*/
|
||||
export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
|
||||
/** The name of the operation (for logging/tracing) */
|
||||
name: string;
|
||||
/** The TSQL query string to execute */
|
||||
query: string;
|
||||
/** The Zod schema for validating output rows */
|
||||
schema: TOut;
|
||||
/** Schema registry defining allowed tables and columns */
|
||||
tableSchema: TableSchema[];
|
||||
/**
|
||||
* REQUIRED: Conditions always applied at the table level.
|
||||
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
|
||||
* Applied to every table reference including subqueries, CTEs, and JOINs.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* // Tenant isolation
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* // Plan-based time limit
|
||||
* triggered_at: { op: "gte", value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
enforcedWhereClause: Record<string, WhereClauseCondition | undefined>;
|
||||
/** Optional ClickHouse query settings */
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
/** Optional TSQL query settings (maxRows, timezone, etc.) */
|
||||
querySettings?: Partial<QuerySettings>;
|
||||
/**
|
||||
* Whether to transform result values using the schema's valueMap
|
||||
* When enabled, internal ClickHouse values (e.g., 'COMPLETED_SUCCESSFULLY')
|
||||
* are converted to user-friendly display names (e.g., 'Completed')
|
||||
* @default true
|
||||
*/
|
||||
transformValues?: boolean;
|
||||
/**
|
||||
* Runtime field mappings for dynamic value translation.
|
||||
* Maps internal ClickHouse values to external user-facing values.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* project: { "cm12345": "my-project-ref" },
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fieldMappings?: FieldMappings;
|
||||
/**
|
||||
* Run EXPLAIN instead of executing the query.
|
||||
* Returns the ClickHouse execution plan with index information.
|
||||
* Should only be used by admins for debugging query performance.
|
||||
* @default false
|
||||
*/
|
||||
explain?: boolean;
|
||||
/**
|
||||
* Fallback WHERE conditions to apply when the user hasn't filtered on a column.
|
||||
* Key is the column name, value is the fallback condition.
|
||||
* These are applied at the AST level (top-level query only).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Apply triggered_at >= 7 days ago if user doesn't filter on triggered_at
|
||||
* whereClauseFallback: {
|
||||
* triggered_at: { op: 'gte', value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
whereClauseFallback?: Record<string, WhereClauseCondition>;
|
||||
/**
|
||||
* Time range for `timeBucket()` interval calculation.
|
||||
* When provided, `timeBucket()` uses this to determine the appropriate bucket size
|
||||
* based on the span of the time range.
|
||||
*/
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful result from TSQL query execution
|
||||
*/
|
||||
export interface TSQLQuerySuccess<T> {
|
||||
rows: T[];
|
||||
columns: OutputColumnMetadata[];
|
||||
stats: QueryStats;
|
||||
/**
|
||||
* Columns that were hidden when SELECT * was used.
|
||||
* Only populated when SELECT * is transformed to core columns only.
|
||||
*/
|
||||
hiddenColumns?: string[];
|
||||
/**
|
||||
* Whether the result count equals the maxRows limit.
|
||||
* When true, the results may be truncated and more rows may exist.
|
||||
*/
|
||||
reachedMaxRows: boolean;
|
||||
/**
|
||||
* The raw EXPLAIN output from ClickHouse.
|
||||
* Only populated when `explain: true` is passed.
|
||||
*/
|
||||
explainOutput?: string;
|
||||
/**
|
||||
* The generated ClickHouse SQL query.
|
||||
* Only populated when `explain: true` is passed.
|
||||
*/
|
||||
generatedSql?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for TSQL query execution
|
||||
*/
|
||||
export type TSQLQueryResult<T> = [QueryError, null] | [null, TSQLQuerySuccess<T>];
|
||||
|
||||
/**
|
||||
* Execute a TSQL query against ClickHouse
|
||||
*
|
||||
* This function:
|
||||
* 1. Compiles the TSQL query to ClickHouse SQL (parse, validate, inject enforced WHERE clauses)
|
||||
* 2. Executes the query and returns validated results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const [error, rows] = await executeTSQL(reader, {
|
||||
* name: "get_task_runs",
|
||||
* query: "SELECT id, status FROM task_runs WHERE status = 'completed' ORDER BY created_at DESC LIMIT 100",
|
||||
* schema: z.object({ id: z.string(), status: z.string() }),
|
||||
* tableSchema: [taskRunsSchema],
|
||||
* enforcedWhereClause: {
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
reader: ClickhouseReader,
|
||||
options: ExecuteTSQLOptions<TOut>
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
const shouldTransformValues = options.transformValues ?? true;
|
||||
const isExplain = options.explain ?? false;
|
||||
const maxRows = options.querySettings?.maxRows;
|
||||
|
||||
let generatedSql: string | undefined;
|
||||
let generatedParams: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
// 1. Compile the TSQL query to ClickHouse SQL
|
||||
// Pass maxRows + 1 to fetch one extra row for overflow detection
|
||||
const compiledSettings =
|
||||
maxRows !== undefined
|
||||
? { ...options.querySettings, maxRows: maxRows + 1 }
|
||||
: options.querySettings;
|
||||
|
||||
const { sql, params, columns, hiddenColumns } = compileTSQL(options.query, {
|
||||
tableSchema: options.tableSchema,
|
||||
enforcedWhereClause: options.enforcedWhereClause,
|
||||
settings: compiledSettings,
|
||||
fieldMappings: options.fieldMappings,
|
||||
whereClauseFallback: options.whereClauseFallback,
|
||||
timeRange: options.timeRange,
|
||||
});
|
||||
|
||||
generatedSql = sql;
|
||||
generatedParams = params;
|
||||
|
||||
// 2. Execute the query (or EXPLAIN) with stats
|
||||
const queryToExecute = isExplain ? `EXPLAIN indexes = 1 ${sql}` : sql;
|
||||
|
||||
const queryFn = reader.queryWithStats({
|
||||
name: isExplain ? `${options.name}-explain` : options.name,
|
||||
query: queryToExecute,
|
||||
params: z.record(z.any()),
|
||||
// EXPLAIN returns rows with an 'explain' column
|
||||
schema: isExplain ? z.object({ explain: z.string() }) : options.schema,
|
||||
settings: options.clickhouseSettings,
|
||||
});
|
||||
|
||||
const [error, result] = await queryFn(params);
|
||||
|
||||
if (error) {
|
||||
// Sanitize error message to show TSQL names instead of ClickHouse internals
|
||||
const sanitizedMessage = sanitizeErrorMessage(error.message, options.tableSchema);
|
||||
return [new QueryError(sanitizedMessage, { query: options.query }), null];
|
||||
}
|
||||
|
||||
const { rows, stats } = result;
|
||||
|
||||
// Handle EXPLAIN mode - run multiple explain types and combine outputs
|
||||
if (isExplain) {
|
||||
const explainRows = rows as Array<{ explain: string }>;
|
||||
const indexesOutput = explainRows.map((r) => r.explain).join("\n");
|
||||
|
||||
// Run additional explain queries for more comprehensive output
|
||||
const explainTypes = [
|
||||
{ name: "ESTIMATE", query: `EXPLAIN ESTIMATE ${sql}` },
|
||||
{ name: "PIPELINE", query: `EXPLAIN PIPELINE ${sql}` },
|
||||
];
|
||||
|
||||
const additionalOutputs: string[] = [];
|
||||
|
||||
for (const explainType of explainTypes) {
|
||||
try {
|
||||
const additionalQueryFn = reader.queryWithStats({
|
||||
name: `${options.name}-explain-${explainType.name.toLowerCase()}`,
|
||||
query: explainType.query,
|
||||
params: z.record(z.any()),
|
||||
schema: z.object({ explain: z.string() }),
|
||||
settings: options.clickhouseSettings,
|
||||
});
|
||||
|
||||
const [additionalError, additionalResult] = await additionalQueryFn(params);
|
||||
|
||||
if (!additionalError && additionalResult) {
|
||||
const additionalRows = additionalResult.rows as Array<{ explain: string }>;
|
||||
const output = additionalRows.map((r) => r.explain).join("\n");
|
||||
additionalOutputs.push(`── ${explainType.name} ──\n${output}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from additional explain queries
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all explain outputs
|
||||
const combinedOutput = ["── INDEXES ──", indexesOutput, "", ...additionalOutputs].join("\n");
|
||||
|
||||
return [
|
||||
null,
|
||||
{
|
||||
rows: [] as z.output<TOut>[],
|
||||
columns: [],
|
||||
stats,
|
||||
hiddenColumns,
|
||||
reachedMaxRows: false,
|
||||
explainOutput: combinedOutput,
|
||||
generatedSql,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Determine if we exceeded maxRows (we fetched maxRows + 1 to detect overflow)
|
||||
const reachedMaxRows = maxRows !== undefined && rows !== undefined && rows.length > maxRows;
|
||||
|
||||
// Remove the overflow row if we got one (pop is O(1), slice would be O(n))
|
||||
const finalRows = rows ?? [];
|
||||
if (reachedMaxRows) {
|
||||
finalRows.pop();
|
||||
}
|
||||
|
||||
// Build the result, including hiddenColumns if present
|
||||
const baseResult = { columns, stats, hiddenColumns, reachedMaxRows };
|
||||
|
||||
// 3. Transform result values if enabled
|
||||
if (shouldTransformValues && finalRows.length > 0) {
|
||||
const transformedRows = transformResults(
|
||||
finalRows as Record<string, unknown>[],
|
||||
options.tableSchema,
|
||||
{ fieldMappings: options.fieldMappings }
|
||||
);
|
||||
return [null, { rows: transformedRows as z.output<TOut>[], ...baseResult }];
|
||||
}
|
||||
|
||||
return [null, { rows: finalRows as z.output<TOut>[], ...baseResult }];
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
// Log TSQL compilation or unexpected errors (with original message for debugging)
|
||||
logger.error("[TSQL] Query error", {
|
||||
name: options.name,
|
||||
error: errorMessage,
|
||||
tsql: options.query,
|
||||
generatedSql: generatedSql ?? "(compilation failed)",
|
||||
generatedParams: generatedParams ?? {},
|
||||
});
|
||||
|
||||
// Sanitize error message to show TSQL names instead of ClickHouse internals
|
||||
const sanitizedMessage = sanitizeErrorMessage(errorMessage, options.tableSchema);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return [new QueryError(sanitizedMessage, { query: options.query }), null];
|
||||
}
|
||||
return [new QueryError("Unknown error executing TSQL query", { query: options.query }), null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reusable TSQL query executor bound to specific table schemas
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tsqlExecutor = createTSQLExecutor(reader, [taskRunsSchema, taskEventsSchema]);
|
||||
*
|
||||
* const [error, rows] = await tsqlExecutor.execute({
|
||||
* name: "get_task_runs",
|
||||
* query: "SELECT * FROM task_runs LIMIT 10",
|
||||
* schema: taskRunRowSchema,
|
||||
* enforcedWhereClause: {
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createTSQLExecutor(reader: ClickhouseReader, tableSchema: TableSchema[]) {
|
||||
return {
|
||||
execute: <TOut extends z.ZodSchema>(
|
||||
options: Omit<ExecuteTSQLOptions<TOut>, "tableSchema">
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> => {
|
||||
return executeTSQL(reader, { ...options, tableSchema });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { Result } from "@trigger.dev/core/v3";
|
||||
import type { z } from "zod";
|
||||
import type { InsertError, QueryError } from "./errors.js";
|
||||
import {
|
||||
type ClickHouseSettings,
|
||||
type BaseQueryParams,
|
||||
type InsertResult,
|
||||
} from "@clickhouse/client";
|
||||
import type { ClickhouseQueryBuilder, ClickhouseQueryFastBuilder } from "./queryBuilder.js";
|
||||
|
||||
export type ClickhouseQueryFunction<TInput, TOutput> = (
|
||||
params: TInput,
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<TOutput[], QueryError>>;
|
||||
|
||||
/**
|
||||
* Like {@link ClickhouseQueryFunction} but yields rows as they stream off the
|
||||
* socket instead of buffering them into an array. The whole result set is never
|
||||
* held in memory at once. Errors are thrown (the async iterable rejects) rather
|
||||
* than returned as a Result tuple.
|
||||
*/
|
||||
export type ClickhouseQueryStreamFunction<TInput, TOutput> = (
|
||||
params: TInput,
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => AsyncIterable<TOutput>;
|
||||
|
||||
/**
|
||||
* Query statistics returned by ClickHouse
|
||||
*/
|
||||
export interface QueryStats {
|
||||
read_rows: string;
|
||||
read_bytes: string;
|
||||
written_rows: string;
|
||||
written_bytes: string;
|
||||
total_rows_to_read: string;
|
||||
result_rows: string;
|
||||
result_bytes: string;
|
||||
elapsed_ns: string;
|
||||
byte_seconds: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for queries that include stats
|
||||
*/
|
||||
export interface QueryResultWithStats<TOutput> {
|
||||
rows: TOutput[];
|
||||
stats: QueryStats;
|
||||
}
|
||||
|
||||
export type ClickhouseQueryWithStatsFunction<TInput, TOutput> = (
|
||||
params: TInput,
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<QueryResultWithStats<TOutput>, QueryError>>;
|
||||
|
||||
export type ClickhouseQueryBuilderFunction<TOutput> = (options?: {
|
||||
settings?: ClickHouseSettings;
|
||||
}) => ClickhouseQueryBuilder<TOutput>;
|
||||
|
||||
export type ClickhouseQueryBuilderFastFunction<TOutput extends Record<string, any>> = (options?: {
|
||||
settings?: ClickHouseSettings;
|
||||
}) => ClickhouseQueryFastBuilder<TOutput>;
|
||||
|
||||
export type ColumnExpression = {
|
||||
name: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
export interface ClickhouseReader {
|
||||
query<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The SQL query to run.
|
||||
* Use {paramName: Type} to define parameters
|
||||
* Example: `SELECT * FROM table WHERE id = {id: String}`
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* The schema of the parameters
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
params?: TIn;
|
||||
/**
|
||||
* The schema of the output of each row
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
schema: TOut;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryFunction<z.input<TIn>, z.output<TOut>>;
|
||||
|
||||
/**
|
||||
* Execute a query and return both rows and query statistics.
|
||||
* Same as `query` but includes ClickHouse query stats in the result.
|
||||
*/
|
||||
queryWithStats<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The SQL query to run.
|
||||
* Use {paramName: Type} to define parameters
|
||||
* Example: `SELECT * FROM table WHERE id = {id: String}`
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* The schema of the parameters
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
params?: TIn;
|
||||
/**
|
||||
* The schema of the output of each row
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
schema: TOut;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>>;
|
||||
|
||||
queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The SQL query to run.
|
||||
* Use {paramName: Type} to define parameters
|
||||
* Example: `SELECT * FROM table WHERE id = {id: String}`
|
||||
*/
|
||||
query: string;
|
||||
|
||||
/**
|
||||
* The columns returned by the query, in the order
|
||||
*
|
||||
* @example ["run_id", "created_at", "updated_at"]
|
||||
*/
|
||||
columns: Array<string | ColumnExpression>;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryFunction<TParams, TOut>;
|
||||
|
||||
/**
|
||||
* Like {@link queryFast} but streams rows instead of buffering them. Returns an
|
||||
* async iterable so the caller can process arbitrarily large result sets with
|
||||
* bounded memory.
|
||||
*/
|
||||
queryFastStream<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
query: string;
|
||||
columns: Array<string | ColumnExpression>;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryStreamFunction<TParams, TOut>;
|
||||
|
||||
queryBuilder<TOut extends z.ZodSchema<any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The initial select clause
|
||||
*
|
||||
* @example SELECT run_id from trigger_dev.task_runs_v1
|
||||
*/
|
||||
baseQuery: string;
|
||||
/**
|
||||
* The schema of the output of each row
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
schema: TOut;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryBuilderFunction<z.input<TOut>>;
|
||||
|
||||
queryBuilderFast<TOut extends Record<string, any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The table to query
|
||||
*
|
||||
* @example trigger_dev.task_runs_v1
|
||||
*/
|
||||
table: string;
|
||||
/**
|
||||
* The columns to query
|
||||
*
|
||||
* @example ["run_id", "created_at", "updated_at"]
|
||||
*/
|
||||
columns: Array<string | ColumnExpression>;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryBuilderFastFunction<TOut>;
|
||||
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ClickhouseInsertFunction<TInput> = (
|
||||
events: TInput | TInput[],
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<InsertResult, InsertError>>;
|
||||
|
||||
export interface ClickhouseWriter {
|
||||
insert<TSchema extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
schema: TSchema;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseInsertFunction<z.input<TSchema>>;
|
||||
|
||||
insertUnsafe<TRecord extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseInsertFunction<TRecord>;
|
||||
|
||||
insertCompact<TRecord extends Record<string, any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: readonly string[];
|
||||
toArray: (record: TRecord) => any[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseInsertFunction<TRecord>;
|
||||
|
||||
insertCompactRaw(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: readonly string[];
|
||||
settings?: ClickHouseSettings;
|
||||
}): (
|
||||
events: readonly any[][] | any[],
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<InsertResult, InsertError>>;
|
||||
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseReader } from "./client/types.js";
|
||||
import { ClickhouseQueryBuilder } from "./client/queryBuilder.js";
|
||||
|
||||
export const ErrorGroupsListQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
task_identifier: z.string(),
|
||||
error_type: z.string(),
|
||||
error_message: z.string(),
|
||||
first_seen: z.string(),
|
||||
last_seen: z.string(),
|
||||
occurrence_count: z.number(),
|
||||
sample_run_id: z.string(),
|
||||
sample_friendly_id: z.string(),
|
||||
});
|
||||
|
||||
export type ErrorGroupsListQueryResult = z.infer<typeof ErrorGroupsListQueryResult>;
|
||||
|
||||
/**
|
||||
* Gets a query builder for listing error groups from the pre-aggregated errors_v1 table.
|
||||
* Allows flexible filtering and pagination.
|
||||
*/
|
||||
export function getErrorGroupsListQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getErrorGroupsList",
|
||||
baseQuery: `
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
task_identifier,
|
||||
any(error_type) as error_type,
|
||||
any(error_message) as error_message,
|
||||
toString(toUnixTimestamp64Milli(min(first_seen))) as first_seen,
|
||||
toString(toUnixTimestamp64Milli(max(last_seen))) as last_seen,
|
||||
toUInt64(sumMerge(occurrence_count)) as occurrence_count,
|
||||
anyMerge(sample_run_id) as sample_run_id,
|
||||
anyMerge(sample_friendly_id) as sample_friendly_id
|
||||
FROM trigger_dev.errors_v1
|
||||
`,
|
||||
schema: ErrorGroupsListQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const ErrorGroupQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
task_identifier: z.string(),
|
||||
error_type: z.string(),
|
||||
error_message: z.string(),
|
||||
first_seen: z.string(),
|
||||
last_seen: z.string(),
|
||||
occurrence_count: z.number(),
|
||||
sample_run_id: z.string(),
|
||||
sample_friendly_id: z.string(),
|
||||
});
|
||||
|
||||
export type ErrorGroupQueryResult = z.infer<typeof ErrorGroupQueryResult>;
|
||||
|
||||
export const ErrorGroupQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
days: z.number().int().default(30),
|
||||
limit: z.number().int().default(50),
|
||||
offset: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type ErrorGroupQueryParams = z.infer<typeof ErrorGroupQueryParams>;
|
||||
|
||||
/**
|
||||
* Gets error groups from the pre-aggregated errors_v1 table.
|
||||
* Much faster than on-the-fly aggregation.
|
||||
*/
|
||||
export function getErrorGroups(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getErrorGroups",
|
||||
query: `
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
task_identifier,
|
||||
any(error_type) as error_type,
|
||||
any(error_message) as error_message,
|
||||
toString(toUnixTimestamp64Milli(min(first_seen))) as first_seen,
|
||||
toString(toUnixTimestamp64Milli(max(last_seen))) as last_seen,
|
||||
toUInt64(sumMerge(occurrence_count)) as occurrence_count,
|
||||
anyMerge(sample_run_id) as sample_run_id,
|
||||
anyMerge(sample_friendly_id) as sample_friendly_id
|
||||
FROM trigger_dev.errors_v1
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
GROUP BY error_fingerprint, task_identifier
|
||||
HAVING toInt64(last_seen) >= toInt64(toUnixTimestamp(now() - INTERVAL {days: Int64} DAY)) * 1000
|
||||
ORDER BY toInt64(last_seen) DESC
|
||||
LIMIT {limit: Int64}
|
||||
OFFSET {offset: Int64}
|
||||
`,
|
||||
schema: ErrorGroupQueryResult,
|
||||
params: ErrorGroupQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const ErrorInstanceQueryResult = z.object({
|
||||
run_id: z.string(),
|
||||
friendly_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
created_at: z.string(),
|
||||
status: z.string(),
|
||||
error_text: z.string(),
|
||||
trace_id: z.string(),
|
||||
task_version: z.string(),
|
||||
});
|
||||
|
||||
export type ErrorInstanceQueryResult = z.infer<typeof ErrorInstanceQueryResult>;
|
||||
|
||||
export const ErrorInstanceQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
errorFingerprint: z.string(),
|
||||
limit: z.number().int().default(50),
|
||||
offset: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type ErrorInstanceQueryParams = z.infer<typeof ErrorInstanceQueryParams>;
|
||||
|
||||
export const ErrorHourlyOccurrencesQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
hour_epoch: z.number(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
export type ErrorHourlyOccurrencesQueryResult = z.infer<typeof ErrorHourlyOccurrencesQueryResult>;
|
||||
|
||||
export const ErrorHourlyOccurrencesQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
fingerprints: z.array(z.string()),
|
||||
hours: z.number().int().default(24),
|
||||
});
|
||||
|
||||
export type ErrorHourlyOccurrencesQueryParams = z.infer<typeof ErrorHourlyOccurrencesQueryParams>;
|
||||
|
||||
/**
|
||||
* Gets hourly occurrence counts for specific error fingerprints over the past N hours.
|
||||
* Queries task_runs_v2 directly, grouped by fingerprint and hour.
|
||||
*/
|
||||
export function getErrorHourlyOccurrences(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getErrorHourlyOccurrences",
|
||||
query: `
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
toUnixTimestamp(toStartOfHour(created_at)) as hour_epoch,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND created_at >= now() - INTERVAL {hours: Int64} HOUR
|
||||
AND error_fingerprint IN {fingerprints: Array(String)}
|
||||
AND status IN ('SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT')
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
error_fingerprint,
|
||||
hour_epoch
|
||||
ORDER BY
|
||||
error_fingerprint ASC,
|
||||
hour_epoch ASC
|
||||
`,
|
||||
schema: ErrorHourlyOccurrencesQueryResult,
|
||||
params: ErrorHourlyOccurrencesQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets individual run instances for a specific error fingerprint.
|
||||
*/
|
||||
export function getErrorInstances(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getErrorInstances",
|
||||
query: `
|
||||
SELECT
|
||||
run_id,
|
||||
friendly_id,
|
||||
task_identifier,
|
||||
toString(created_at) as created_at,
|
||||
status,
|
||||
error_text,
|
||||
trace_id,
|
||||
task_version
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND error_fingerprint = {errorFingerprint: String}
|
||||
AND _is_deleted = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {limit: Int64}
|
||||
OFFSET {offset: Int64}
|
||||
`,
|
||||
schema: ErrorInstanceQueryResult,
|
||||
params: ErrorInstanceQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Affected versions – distinct task_version from error_occurrences_v1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ErrorAffectedVersionsQueryResult = z.object({
|
||||
task_version: z.string(),
|
||||
});
|
||||
|
||||
export type ErrorAffectedVersionsQueryResult = z.infer<typeof ErrorAffectedVersionsQueryResult>;
|
||||
|
||||
/**
|
||||
* Query builder for fetching distinct task_version values for an error fingerprint
|
||||
* from the error_occurrences_v1 SummingMergeTree table.
|
||||
* task_version is part of the ORDER BY key, so this is efficient.
|
||||
*/
|
||||
export function getErrorAffectedVersionsQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getErrorAffectedVersions",
|
||||
baseQuery: `
|
||||
SELECT DISTINCT task_version
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
schema: ErrorAffectedVersionsQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// error_occurrences_v1 – per-minute bucketed error counts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ErrorOccurrencesListQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
task_identifier: z.string(),
|
||||
error_type: z.string(),
|
||||
error_message: z.string(),
|
||||
occurrence_count: z.number(),
|
||||
});
|
||||
|
||||
export type ErrorOccurrencesListQueryResult = z.infer<typeof ErrorOccurrencesListQueryResult>;
|
||||
|
||||
/**
|
||||
* Query builder for listing error groups from the per-minute error_occurrences_v1 table.
|
||||
* Time filtering is done via WHERE on the `minute` column, giving precise time-scoped counts.
|
||||
*/
|
||||
export function getErrorOccurrencesListQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getErrorOccurrencesList",
|
||||
baseQuery: `
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
task_identifier,
|
||||
any(error_type) as error_type,
|
||||
any(error_message) as error_message,
|
||||
sum(count) as occurrence_count
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
schema: ErrorOccurrencesListQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const ErrorOccurrencesBucketQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
bucket_epoch: z.number(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
export type ErrorOccurrencesBucketQueryResult = z.infer<typeof ErrorOccurrencesBucketQueryResult>;
|
||||
|
||||
/**
|
||||
* Creates a query builder for bucketed error occurrence counts.
|
||||
* The `intervalExpr` is a ClickHouse INTERVAL literal (e.g. "INTERVAL 1 HOUR").
|
||||
* Returns a builder directly since the base query varies with each granularity.
|
||||
*/
|
||||
export function createErrorOccurrencesQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
intervalExpr: string,
|
||||
settings?: ClickHouseSettings
|
||||
): ClickhouseQueryBuilder<ErrorOccurrencesBucketQueryResult> {
|
||||
return new ClickhouseQueryBuilder(
|
||||
"getErrorOccurrencesBucketed",
|
||||
`
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
toUnixTimestamp(toStartOfInterval(minute, ${intervalExpr})) as bucket_epoch,
|
||||
sum(count) as count
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
ch,
|
||||
ErrorOccurrencesBucketQueryResult,
|
||||
settings
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorOccurrencesByVersionQueryResult = z.object({
|
||||
error_fingerprint: z.string(),
|
||||
task_version: z.string(),
|
||||
bucket_epoch: z.number(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
export type ErrorOccurrencesByVersionQueryResult = z.infer<
|
||||
typeof ErrorOccurrencesByVersionQueryResult
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates a query builder for bucketed error occurrence counts grouped by task_version.
|
||||
* Used for stacked-by-version activity charts on the error detail page.
|
||||
*/
|
||||
export function createErrorOccurrencesByVersionQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
intervalExpr: string,
|
||||
settings?: ClickHouseSettings
|
||||
): ClickhouseQueryBuilder<ErrorOccurrencesByVersionQueryResult> {
|
||||
return new ClickhouseQueryBuilder(
|
||||
"getErrorOccurrencesByVersion",
|
||||
`
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
task_version,
|
||||
toUnixTimestamp(toStartOfInterval(minute, ${intervalExpr})) as bucket_epoch,
|
||||
sum(count) as count
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
ch,
|
||||
ErrorOccurrencesByVersionQueryResult,
|
||||
settings
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluator – active errors since a timestamp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ActiveErrorsSinceQueryResult = z.object({
|
||||
environment_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
error_fingerprint: z.string(),
|
||||
error_type: z.string(),
|
||||
error_message: z.string(),
|
||||
sample_stack_trace: z.string(),
|
||||
first_seen: z.string(),
|
||||
last_seen: z.string(),
|
||||
occurrence_count: z.number(),
|
||||
});
|
||||
|
||||
export type ActiveErrorsSinceQueryResult = z.infer<typeof ActiveErrorsSinceQueryResult>;
|
||||
|
||||
/**
|
||||
* Query builder for fetching all errors active since a given timestamp.
|
||||
* Returns errors with last_seen > scheduledAt, grouped by env/task/fingerprint.
|
||||
* Used by the error alert evaluator to find new issues, regressions, and un-ignored errors.
|
||||
*/
|
||||
export function getActiveErrorsSinceQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getActiveErrorsSince",
|
||||
baseQuery: `
|
||||
SELECT
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
any(error_type) as error_type,
|
||||
any(error_message) as error_message,
|
||||
any(sample_stack_trace) as sample_stack_trace,
|
||||
toString(toUnixTimestamp64Milli(min(first_seen))) as first_seen,
|
||||
toString(toUnixTimestamp64Milli(max(last_seen))) as last_seen,
|
||||
toUInt64(sumMerge(occurrence_count)) as occurrence_count
|
||||
FROM trigger_dev.errors_v1
|
||||
`,
|
||||
schema: ActiveErrorsSinceQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const OccurrenceCountsSinceQueryResult = z.object({
|
||||
environment_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
error_fingerprint: z.string(),
|
||||
occurrences_since: z.number(),
|
||||
});
|
||||
|
||||
export type OccurrenceCountsSinceQueryResult = z.infer<typeof OccurrenceCountsSinceQueryResult>;
|
||||
|
||||
/**
|
||||
* Query builder for occurrence counts since a given timestamp, grouped by error.
|
||||
* Used by the alert evaluator to check ignore thresholds.
|
||||
*/
|
||||
export function getOccurrenceCountsSinceQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getOccurrenceCountsSince",
|
||||
baseQuery: `
|
||||
SELECT
|
||||
environment_id,
|
||||
task_identifier,
|
||||
error_fingerprint,
|
||||
sum(count) as occurrences_since
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
schema: OccurrenceCountsSinceQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert evaluator helpers – occurrence rate & count since timestamp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ErrorOccurrenceTotalCountResult = z.object({
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
export type ErrorOccurrenceTotalCountResult = z.infer<typeof ErrorOccurrenceTotalCountResult>;
|
||||
|
||||
/**
|
||||
* Query builder for summing occurrences since a given timestamp.
|
||||
* Used by the alert evaluator to check total-count-based ignore thresholds.
|
||||
*/
|
||||
export function getOccurrenceCountSinceQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getOccurrenceCountSince",
|
||||
baseQuery: `
|
||||
SELECT
|
||||
sum(count) as total_count
|
||||
FROM trigger_dev.error_occurrences_v1
|
||||
`,
|
||||
schema: ErrorOccurrenceTotalCountResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
export type { ClickHouseSettings };
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import type { ClickhouseReader, ClickhouseWriter } from "./client/types.js";
|
||||
import { NoopClient } from "./client/noop.js";
|
||||
import {
|
||||
insertTaskRunsCompactArrays,
|
||||
insertRawTaskRunPayloadsCompactArrays,
|
||||
getTaskRunsQueryBuilder,
|
||||
getTaskActivityQueryBuilder,
|
||||
getCurrentRunningStats,
|
||||
getChildRunStatusCounts,
|
||||
getAverageDurations,
|
||||
getTaskUsageByOrganization,
|
||||
getTaskRunsCountQueryBuilder,
|
||||
getTaskRunTagsQueryBuilder,
|
||||
getPendingVersionIdsQueryBuilder,
|
||||
getTaskRunExistsQueryBuilder,
|
||||
} from "./taskRuns.js";
|
||||
import {
|
||||
getSpanDetailsQueryBuilder,
|
||||
getSpanDetailsQueryBuilderV2,
|
||||
getTraceDetailedSummaryQueryBuilder,
|
||||
getTraceDetailedSummaryQueryBuilderV2,
|
||||
getTraceEventsForExportQueryBuilder,
|
||||
getTraceEventsForExportQueryBuilderV2,
|
||||
getTraceSummaryQueryBuilder,
|
||||
getTraceSummaryQueryBuilderV2,
|
||||
insertTaskEvents,
|
||||
insertTaskEventsV2,
|
||||
getLogDetailQueryBuilderV2,
|
||||
getLogsSearchListQueryBuilder,
|
||||
} from "./taskEvents.js";
|
||||
import { insertMetrics } from "./metrics.js";
|
||||
import { insertLlmMetrics } from "./llmMetrics.js";
|
||||
import {
|
||||
getSessionTagsQueryBuilder,
|
||||
getSessionsCountQueryBuilder,
|
||||
getSessionsQueryBuilder,
|
||||
insertSessionsCompactArrays,
|
||||
} from "./sessions.js";
|
||||
import {
|
||||
getGlobalModelMetrics,
|
||||
getGlobalModelComparison,
|
||||
getPopularModels,
|
||||
} from "./llmModelAggregates.js";
|
||||
import {
|
||||
getErrorGroups,
|
||||
getErrorInstances,
|
||||
getErrorGroupsListQueryBuilder,
|
||||
getErrorHourlyOccurrences,
|
||||
getErrorOccurrencesListQueryBuilder,
|
||||
createErrorOccurrencesQueryBuilder,
|
||||
createErrorOccurrencesByVersionQueryBuilder,
|
||||
getErrorAffectedVersionsQueryBuilder,
|
||||
getOccurrenceCountSinceQueryBuilder,
|
||||
getActiveErrorsSinceQueryBuilder,
|
||||
getOccurrenceCountsSinceQueryBuilder,
|
||||
} from "./errors.js";
|
||||
export { msToClickHouseInterval } from "./intervals.js";
|
||||
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
|
||||
import type { Agent as HttpAgent } from "http";
|
||||
import type { Agent as HttpsAgent } from "https";
|
||||
|
||||
export type * from "./taskRuns.js";
|
||||
export type * from "./taskEvents.js";
|
||||
export type * from "./metrics.js";
|
||||
export type * from "./llmMetrics.js";
|
||||
export type * from "./llmModelAggregates.js";
|
||||
export type * from "./errors.js";
|
||||
export type * from "./sessions.js";
|
||||
export type * from "./client/queryBuilder.js";
|
||||
|
||||
// Re-export column constants, indices, and type-safe accessors
|
||||
export {
|
||||
TASK_RUN_COLUMNS,
|
||||
TASK_RUN_INDEX,
|
||||
PAYLOAD_COLUMNS,
|
||||
PAYLOAD_INDEX,
|
||||
getTaskRunField,
|
||||
getPayloadField,
|
||||
composeTaskRunVersion,
|
||||
} from "./taskRuns.js";
|
||||
|
||||
export { SESSION_COLUMNS, SESSION_INDEX, getSessionField } from "./sessions.js";
|
||||
|
||||
// TSQL query execution
|
||||
export {
|
||||
executeTSQL,
|
||||
createTSQLExecutor,
|
||||
type ExecuteTSQLOptions,
|
||||
type TableSchema,
|
||||
type TSQLQueryResult,
|
||||
type TSQLQuerySuccess,
|
||||
type QueryStats,
|
||||
type FieldMappings,
|
||||
type WhereClauseCondition,
|
||||
} from "./client/tsql.js";
|
||||
export type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
|
||||
|
||||
// Errors
|
||||
export { QueryError } from "./client/errors.js";
|
||||
|
||||
export type ClickhouseCommonConfig = {
|
||||
keepAlive?: {
|
||||
enabled?: boolean;
|
||||
idleSocketTtl?: number;
|
||||
};
|
||||
httpAgent?: HttpAgent | HttpsAgent;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
compression?: {
|
||||
request?: boolean;
|
||||
response?: boolean;
|
||||
};
|
||||
maxOpenConnections?: number;
|
||||
};
|
||||
|
||||
export type ClickHouseConfig =
|
||||
| ({
|
||||
name?: string;
|
||||
url?: string;
|
||||
writerUrl?: never;
|
||||
readerUrl?: never;
|
||||
} & ClickhouseCommonConfig)
|
||||
| ({
|
||||
name?: never;
|
||||
url?: never;
|
||||
writerName?: string;
|
||||
writerUrl: string;
|
||||
readerName?: string;
|
||||
readerUrl: string;
|
||||
} & ClickhouseCommonConfig);
|
||||
|
||||
export class ClickHouse {
|
||||
public readonly reader: ClickhouseReader;
|
||||
public readonly writer: ClickhouseWriter;
|
||||
private readonly logger: Logger;
|
||||
private _splitClients: boolean;
|
||||
|
||||
constructor(config: ClickHouseConfig) {
|
||||
this.logger = config.logger ?? new Logger("ClickHouse", config.logLevel ?? "debug");
|
||||
|
||||
if (config.url) {
|
||||
const url = new URL(config.url);
|
||||
url.password = "redacted";
|
||||
|
||||
this.logger.info("🏠 Initializing ClickHouse client with url", { url: url.toString() });
|
||||
|
||||
const client = new ClickhouseClient({
|
||||
name: config.name ?? "clickhouse",
|
||||
url: config.url,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
logLevel: config.logLevel,
|
||||
keepAlive: config.keepAlive,
|
||||
httpAgent: config.httpAgent,
|
||||
maxOpenConnections: config.maxOpenConnections,
|
||||
compression: config.compression,
|
||||
});
|
||||
this.reader = client;
|
||||
this.writer = client;
|
||||
|
||||
this._splitClients = false;
|
||||
} else if (config.writerUrl && config.readerUrl) {
|
||||
this.reader = new ClickhouseClient({
|
||||
name: config.readerName ?? "clickhouse-reader",
|
||||
url: config.readerUrl,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
logLevel: config.logLevel,
|
||||
keepAlive: config.keepAlive,
|
||||
httpAgent: config.httpAgent,
|
||||
maxOpenConnections: config.maxOpenConnections,
|
||||
compression: config.compression,
|
||||
});
|
||||
this.writer = new ClickhouseClient({
|
||||
name: config.writerName ?? "clickhouse-writer",
|
||||
url: config.writerUrl,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
logLevel: config.logLevel,
|
||||
keepAlive: config.keepAlive,
|
||||
httpAgent: config.httpAgent,
|
||||
maxOpenConnections: config.maxOpenConnections,
|
||||
compression: config.compression,
|
||||
});
|
||||
|
||||
this._splitClients = true;
|
||||
} else {
|
||||
this.reader = new NoopClient();
|
||||
this.writer = new NoopClient();
|
||||
|
||||
this._splitClients = true;
|
||||
}
|
||||
}
|
||||
|
||||
static fromEnv(): ClickHouse {
|
||||
if (
|
||||
typeof process.env.CLICKHOUSE_WRITER_URL === "string" &&
|
||||
typeof process.env.CLICKHOUSE_READER_URL === "string"
|
||||
) {
|
||||
return new ClickHouse({
|
||||
writerUrl: process.env.CLICKHOUSE_WRITER_URL,
|
||||
readerUrl: process.env.CLICKHOUSE_READER_URL,
|
||||
writerName: process.env.CLICKHOUSE_WRITER_NAME,
|
||||
readerName: process.env.CLICKHOUSE_READER_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
return new ClickHouse({
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
name: process.env.CLICKHOUSE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this._splitClients) {
|
||||
await Promise.all([this.reader.close(), this.writer.close()]);
|
||||
} else {
|
||||
await this.reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
get taskRuns() {
|
||||
return {
|
||||
insertCompactArrays: insertTaskRunsCompactArrays(this.writer),
|
||||
insertPayloadsCompactArrays: insertRawTaskRunPayloadsCompactArrays(this.writer),
|
||||
queryBuilder: getTaskRunsQueryBuilder(this.reader),
|
||||
countQueryBuilder: getTaskRunsCountQueryBuilder(this.reader),
|
||||
existsQueryBuilder: getTaskRunExistsQueryBuilder(this.reader, { max_execution_time: 10 }),
|
||||
tagQueryBuilder: getTaskRunTagsQueryBuilder(this.reader),
|
||||
pendingVersionIdsQueryBuilder: getPendingVersionIdsQueryBuilder(this.reader),
|
||||
getTaskActivity: getTaskActivityQueryBuilder(this.reader),
|
||||
getCurrentRunningStats: getCurrentRunningStats(this.reader),
|
||||
getChildRunStatusCounts: getChildRunStatusCounts(this.reader),
|
||||
getAverageDurations: getAverageDurations(this.reader),
|
||||
getTaskUsageByOrganization: getTaskUsageByOrganization(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get taskEvents() {
|
||||
return {
|
||||
insert: insertTaskEvents(this.writer),
|
||||
traceSummaryQueryBuilder: getTraceSummaryQueryBuilder(this.reader),
|
||||
traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilder(this.reader),
|
||||
traceEventsForExportQueryBuilder: getTraceEventsForExportQueryBuilder(this.reader),
|
||||
spanDetailsQueryBuilder: getSpanDetailsQueryBuilder(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get metrics() {
|
||||
return {
|
||||
insert: insertMetrics(this.writer),
|
||||
};
|
||||
}
|
||||
|
||||
get llmMetrics() {
|
||||
return {
|
||||
insert: insertLlmMetrics(this.writer),
|
||||
};
|
||||
}
|
||||
|
||||
get llmModelAggregates() {
|
||||
return {
|
||||
globalMetrics: getGlobalModelMetrics(this.reader),
|
||||
comparison: getGlobalModelComparison(this.reader),
|
||||
popular: getPopularModels(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get sessions() {
|
||||
return {
|
||||
insertCompactArrays: insertSessionsCompactArrays(this.writer),
|
||||
queryBuilder: getSessionsQueryBuilder(this.reader),
|
||||
countQueryBuilder: getSessionsCountQueryBuilder(this.reader),
|
||||
tagQueryBuilder: getSessionTagsQueryBuilder(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get taskEventsV2() {
|
||||
return {
|
||||
insert: insertTaskEventsV2(this.writer),
|
||||
traceSummaryQueryBuilder: getTraceSummaryQueryBuilderV2(this.reader),
|
||||
traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilderV2(this.reader),
|
||||
traceEventsForExportQueryBuilder: getTraceEventsForExportQueryBuilderV2(this.reader),
|
||||
spanDetailsQueryBuilder: getSpanDetailsQueryBuilderV2(this.reader),
|
||||
logDetailQueryBuilder: getLogDetailQueryBuilderV2(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get taskEventsSearch() {
|
||||
return {
|
||||
logsListQueryBuilder: getLogsSearchListQueryBuilder(this.reader),
|
||||
};
|
||||
}
|
||||
|
||||
get errors() {
|
||||
return {
|
||||
getGroups: getErrorGroups(this.reader),
|
||||
getInstances: getErrorInstances(this.reader),
|
||||
getHourlyOccurrences: getErrorHourlyOccurrences(this.reader),
|
||||
affectedVersionsQueryBuilder: getErrorAffectedVersionsQueryBuilder(this.reader),
|
||||
listQueryBuilder: getErrorGroupsListQueryBuilder(this.reader),
|
||||
occurrencesListQueryBuilder: getErrorOccurrencesListQueryBuilder(this.reader),
|
||||
createOccurrencesQueryBuilder: (intervalExpr: string) =>
|
||||
createErrorOccurrencesQueryBuilder(this.reader, intervalExpr),
|
||||
createOccurrencesByVersionQueryBuilder: (intervalExpr: string) =>
|
||||
createErrorOccurrencesByVersionQueryBuilder(this.reader, intervalExpr),
|
||||
occurrenceCountSinceQueryBuilder: getOccurrenceCountSinceQueryBuilder(this.reader),
|
||||
activeErrorsSinceQueryBuilder: getActiveErrorsSinceQueryBuilder(this.reader),
|
||||
occurrenceCountsSinceQueryBuilder: getOccurrenceCountsSinceQueryBuilder(this.reader),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Converts a granularity in milliseconds to a ClickHouse INTERVAL expression. */
|
||||
export function msToClickHouseInterval(ms: number): string {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
return `INTERVAL ${seconds} SECOND`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const LlmMetricsV1Input = z.object({
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
environment_id: z.string(),
|
||||
run_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
trace_id: z.string(),
|
||||
span_id: z.string(),
|
||||
|
||||
gen_ai_system: z.string(),
|
||||
request_model: z.string(),
|
||||
response_model: z.string(),
|
||||
base_response_model: z.string(),
|
||||
matched_model_id: z.string(),
|
||||
operation_id: z.string(),
|
||||
finish_reason: z.string(),
|
||||
cost_source: z.string(),
|
||||
|
||||
pricing_tier_id: z.string(),
|
||||
pricing_tier_name: z.string(),
|
||||
|
||||
input_tokens: z.number(),
|
||||
output_tokens: z.number(),
|
||||
total_tokens: z.number(),
|
||||
usage_details: z.record(z.string(), z.number()),
|
||||
|
||||
input_cost: z.number(),
|
||||
output_cost: z.number(),
|
||||
total_cost: z.number(),
|
||||
cost_details: z.record(z.string(), z.number()),
|
||||
provider_cost: z.number(),
|
||||
|
||||
ms_to_first_chunk: z.number(),
|
||||
tokens_per_second: z.number(),
|
||||
|
||||
metadata: z.record(z.string(), z.string()),
|
||||
|
||||
prompt_slug: z.string(),
|
||||
prompt_version: z.number(),
|
||||
|
||||
start_time: z.string(),
|
||||
duration: z.string(),
|
||||
});
|
||||
|
||||
export type LlmMetricsV1Input = z.input<typeof LlmMetricsV1Input>;
|
||||
|
||||
export function insertLlmMetrics(ch: ClickhouseWriter) {
|
||||
return ch.insertUnsafe<LlmMetricsV1Input>({
|
||||
name: "insertLlmMetrics",
|
||||
table: "trigger_dev.llm_metrics_v1",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from "zod";
|
||||
import { ClickhouseQueryBuilder } from "./client/queryBuilder.js";
|
||||
import type { ClickhouseReader } from "./client/types.js";
|
||||
|
||||
// --- Schemas ---
|
||||
|
||||
const ModelMetricsRow = z.object({
|
||||
response_model: z.string(),
|
||||
gen_ai_system: z.string(),
|
||||
minute: z.string(),
|
||||
call_count: z.coerce.number(),
|
||||
total_input_tokens: z.coerce.number(),
|
||||
total_output_tokens: z.coerce.number(),
|
||||
total_cost: z.coerce.number(),
|
||||
ttfc_p50: z.coerce.number(),
|
||||
ttfc_p90: z.coerce.number(),
|
||||
ttfc_p95: z.coerce.number(),
|
||||
ttfc_p99: z.coerce.number(),
|
||||
tps_p50: z.coerce.number(),
|
||||
tps_p90: z.coerce.number(),
|
||||
tps_p95: z.coerce.number(),
|
||||
tps_p99: z.coerce.number(),
|
||||
duration_p50: z.coerce.number(),
|
||||
duration_p90: z.coerce.number(),
|
||||
duration_p95: z.coerce.number(),
|
||||
duration_p99: z.coerce.number(),
|
||||
});
|
||||
|
||||
const ModelSummaryRow = z.object({
|
||||
response_model: z.string(),
|
||||
gen_ai_system: z.string(),
|
||||
call_count: z.coerce.number(),
|
||||
total_input_tokens: z.coerce.number(),
|
||||
total_output_tokens: z.coerce.number(),
|
||||
total_cost: z.coerce.number(),
|
||||
ttfc_p50: z.coerce.number(),
|
||||
ttfc_p90: z.coerce.number(),
|
||||
tps_p50: z.coerce.number(),
|
||||
tps_p90: z.coerce.number(),
|
||||
});
|
||||
|
||||
const PopularModelRow = z.object({
|
||||
response_model: z.string(),
|
||||
gen_ai_system: z.string(),
|
||||
call_count: z.coerce.number(),
|
||||
total_cost: z.coerce.number(),
|
||||
ttfc_p50: z.coerce.number(),
|
||||
});
|
||||
|
||||
// --- Query builders ---
|
||||
|
||||
/** Get per-minute metrics for a specific model over a date range. */
|
||||
export function getGlobalModelMetrics(reader: ClickhouseReader) {
|
||||
return new ClickhouseQueryBuilder(
|
||||
"getGlobalModelMetrics",
|
||||
`SELECT
|
||||
response_model,
|
||||
gen_ai_system,
|
||||
minute,
|
||||
sum(call_count) AS call_count,
|
||||
sum(total_input_tokens) AS total_input_tokens,
|
||||
sum(total_output_tokens) AS total_output_tokens,
|
||||
sum(total_cost) AS total_cost,
|
||||
quantilesMerge(0.5, 0.9, 0.95, 0.99)(ttfc_quantiles) AS ttfc_arr,
|
||||
ttfc_arr[1] AS ttfc_p50,
|
||||
ttfc_arr[2] AS ttfc_p90,
|
||||
ttfc_arr[3] AS ttfc_p95,
|
||||
ttfc_arr[4] AS ttfc_p99,
|
||||
quantilesMerge(0.5, 0.9, 0.95, 0.99)(tps_quantiles) AS tps_arr,
|
||||
tps_arr[1] AS tps_p50,
|
||||
tps_arr[2] AS tps_p90,
|
||||
tps_arr[3] AS tps_p95,
|
||||
tps_arr[4] AS tps_p99,
|
||||
quantilesMerge(0.5, 0.9, 0.95, 0.99)(duration_quantiles) AS dur_arr,
|
||||
dur_arr[1] AS duration_p50,
|
||||
dur_arr[2] AS duration_p90,
|
||||
dur_arr[3] AS duration_p95,
|
||||
dur_arr[4] AS duration_p99
|
||||
FROM trigger_dev.llm_model_aggregates_v1
|
||||
WHERE response_model = {responseModel: String}
|
||||
AND minute >= {startTime: DateTime}
|
||||
AND minute <= {endTime: DateTime}
|
||||
GROUP BY response_model, gen_ai_system, minute
|
||||
ORDER BY minute`,
|
||||
reader,
|
||||
ModelMetricsRow
|
||||
);
|
||||
}
|
||||
|
||||
/** Get summary metrics for multiple models (for comparison). */
|
||||
export function getGlobalModelComparison(reader: ClickhouseReader) {
|
||||
return new ClickhouseQueryBuilder(
|
||||
"getGlobalModelComparison",
|
||||
`SELECT
|
||||
response_model,
|
||||
gen_ai_system,
|
||||
sum(call_count) AS call_count,
|
||||
sum(total_input_tokens) AS total_input_tokens,
|
||||
sum(total_output_tokens) AS total_output_tokens,
|
||||
sum(total_cost) AS total_cost,
|
||||
quantilesMerge(0.5, 0.9)(ttfc_quantiles) AS ttfc_arr,
|
||||
ttfc_arr[1] AS ttfc_p50,
|
||||
ttfc_arr[2] AS ttfc_p90,
|
||||
quantilesMerge(0.5, 0.9)(tps_quantiles) AS tps_arr,
|
||||
tps_arr[1] AS tps_p50,
|
||||
tps_arr[2] AS tps_p90
|
||||
FROM trigger_dev.llm_model_aggregates_v1
|
||||
WHERE response_model IN {responseModels: Array(String)}
|
||||
AND minute >= {startTime: DateTime}
|
||||
AND minute <= {endTime: DateTime}
|
||||
GROUP BY response_model, gen_ai_system
|
||||
ORDER BY call_count DESC`,
|
||||
reader,
|
||||
ModelSummaryRow
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the most popular models by call count. */
|
||||
export function getPopularModels(reader: ClickhouseReader) {
|
||||
return new ClickhouseQueryBuilder(
|
||||
"getPopularModels",
|
||||
`SELECT
|
||||
response_model,
|
||||
gen_ai_system,
|
||||
sum(call_count) AS call_count,
|
||||
sum(total_cost) AS total_cost,
|
||||
quantilesMerge(0.5)(ttfc_quantiles) AS ttfc_arr,
|
||||
ttfc_arr[1] AS ttfc_p50
|
||||
FROM trigger_dev.llm_model_aggregates_v1
|
||||
WHERE minute >= {startTime: DateTime}
|
||||
AND minute <= {endTime: DateTime}
|
||||
GROUP BY response_model, gen_ai_system
|
||||
ORDER BY call_count DESC
|
||||
LIMIT {limit: UInt32}`,
|
||||
reader,
|
||||
PopularModelRow
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const MetricsV1Input = z.object({
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
environment_id: z.string(),
|
||||
metric_name: z.string(),
|
||||
metric_type: z.string(),
|
||||
metric_subject: z.string(),
|
||||
bucket_start: z.string(),
|
||||
value: z.number(),
|
||||
attributes: z.unknown(),
|
||||
});
|
||||
|
||||
export type MetricsV1Input = z.input<typeof MetricsV1Input>;
|
||||
|
||||
export function insertMetrics(ch: ClickhouseWriter) {
|
||||
return ch.insertUnsafe<MetricsV1Input>({
|
||||
name: "insertMetrics",
|
||||
table: "trigger_dev.metrics_v1",
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
input_format_json_throw_on_bad_escape_sequence: 0,
|
||||
input_format_json_use_string_type_for_ambiguous_paths_in_named_tuples_inference_from_objects: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseReader, ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const SessionV1 = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
session_id: z.string(),
|
||||
environment_type: z.string(),
|
||||
friendly_id: z.string(),
|
||||
external_id: z.string().default(""),
|
||||
type: z.string(),
|
||||
task_identifier: z.string().default(""),
|
||||
tags: z.array(z.string()).default([]),
|
||||
metadata: z.unknown(),
|
||||
closed_at: z.number().int().nullish(),
|
||||
closed_reason: z.string().default(""),
|
||||
expires_at: z.number().int().nullish(),
|
||||
created_at: z.number().int(),
|
||||
updated_at: z.number().int(),
|
||||
is_test: z.boolean().default(false),
|
||||
_version: z.string(),
|
||||
_is_deleted: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type SessionV1 = z.input<typeof SessionV1>;
|
||||
|
||||
// Column order for compact format - must match ClickHouse table schema
|
||||
export const SESSION_COLUMNS = [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"session_id",
|
||||
"environment_type",
|
||||
"friendly_id",
|
||||
"external_id",
|
||||
"type",
|
||||
"task_identifier",
|
||||
"tags",
|
||||
"metadata",
|
||||
"closed_at",
|
||||
"closed_reason",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_test",
|
||||
"_version",
|
||||
"_is_deleted",
|
||||
] as const;
|
||||
|
||||
export type SessionColumnName = (typeof SESSION_COLUMNS)[number];
|
||||
|
||||
export const SESSION_INDEX = Object.fromEntries(SESSION_COLUMNS.map((col, idx) => [col, idx])) as {
|
||||
readonly [K in SessionColumnName]: number;
|
||||
};
|
||||
|
||||
export type SessionFieldTypes = {
|
||||
environment_id: string;
|
||||
organization_id: string;
|
||||
project_id: string;
|
||||
session_id: string;
|
||||
environment_type: string;
|
||||
friendly_id: string;
|
||||
external_id: string;
|
||||
type: string;
|
||||
task_identifier: string;
|
||||
tags: string[];
|
||||
metadata: { data: unknown };
|
||||
closed_at: number | null;
|
||||
closed_reason: string;
|
||||
expires_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
is_test: boolean;
|
||||
_version: string;
|
||||
_is_deleted: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type-safe tuple representing a Session insert array.
|
||||
* Order matches {@link SESSION_COLUMNS} exactly.
|
||||
*/
|
||||
export type SessionInsertArray = [
|
||||
environment_id: string,
|
||||
organization_id: string,
|
||||
project_id: string,
|
||||
session_id: string,
|
||||
environment_type: string,
|
||||
friendly_id: string,
|
||||
external_id: string,
|
||||
type: string,
|
||||
task_identifier: string,
|
||||
tags: string[],
|
||||
metadata: { data: unknown },
|
||||
closed_at: number | null,
|
||||
closed_reason: string,
|
||||
expires_at: number | null,
|
||||
created_at: number,
|
||||
updated_at: number,
|
||||
is_test: boolean,
|
||||
_version: string,
|
||||
_is_deleted: number,
|
||||
];
|
||||
|
||||
export function getSessionField<K extends SessionColumnName>(
|
||||
session: SessionInsertArray,
|
||||
field: K
|
||||
): SessionFieldTypes[K] {
|
||||
return session[SESSION_INDEX[field]] as SessionFieldTypes[K];
|
||||
}
|
||||
|
||||
export function insertSessionsCompactArrays(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insertCompactRaw({
|
||||
name: "insertSessionsCompactArrays",
|
||||
table: "trigger_dev.sessions_v1",
|
||||
columns: SESSION_COLUMNS,
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function insertSessions(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insert({
|
||||
name: "insertSessions",
|
||||
table: "trigger_dev.sessions_v1",
|
||||
schema: SessionV1,
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── read path ───────────────────────────────────────────────────
|
||||
|
||||
export const SessionV1QueryResult = z.object({
|
||||
session_id: z.string(),
|
||||
});
|
||||
|
||||
export type SessionV1QueryResult = z.infer<typeof SessionV1QueryResult>;
|
||||
|
||||
/**
|
||||
* Base query builder for listing Sessions. Filters + pagination are composed
|
||||
* on top of this; callers can chain `.where(...).orderBy(...).limit(...)`.
|
||||
*/
|
||||
export function getSessionsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getSessions",
|
||||
baseQuery: "SELECT session_id FROM trigger_dev.sessions_v1 FINAL",
|
||||
schema: SessionV1QueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionsCountQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getSessionsCount",
|
||||
baseQuery: "SELECT count() as count FROM trigger_dev.sessions_v1 FINAL",
|
||||
schema: z.object({ count: z.number().int() }),
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const SessionTagsQueryResult = z.object({
|
||||
tag: z.string(),
|
||||
});
|
||||
|
||||
export type SessionTagsQueryResult = z.infer<typeof SessionTagsQueryResult>;
|
||||
|
||||
export function getSessionTagsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getSessionTags",
|
||||
baseQuery: "SELECT DISTINCT arrayJoin(tags) as tag FROM trigger_dev.sessions_v1",
|
||||
schema: SessionTagsQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseReader, ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const TaskEventV1Input = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
duration: z.string(),
|
||||
trace_id: z.string(),
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
message: z.string(),
|
||||
kind: z.string(),
|
||||
status: z.string(),
|
||||
attributes: z.unknown(),
|
||||
metadata: z.string(),
|
||||
expires_at: z.string(),
|
||||
machine_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskEventV1Input = z.input<typeof TaskEventV1Input>;
|
||||
|
||||
export function insertTaskEvents(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insertUnsafe<TaskEventV1Input>({
|
||||
name: "insertTaskEvents",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
input_format_json_throw_on_bad_escape_sequence: 0,
|
||||
input_format_json_use_string_type_for_ambiguous_paths_in_named_tuples_inference_from_objects: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskEventSummaryV1Result = z.object({
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
duration: z.number().or(z.string()),
|
||||
status: z.string(),
|
||||
kind: z.string(),
|
||||
metadata: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type TaskEventSummaryV1Result = z.output<typeof TaskEventSummaryV1Result>;
|
||||
|
||||
export function getTraceSummaryQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilderFast<TaskEventSummaryV1Result>({
|
||||
name: "getTraceEvents",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
columns: [
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"duration",
|
||||
"status",
|
||||
"kind",
|
||||
"metadata",
|
||||
{ name: "message", expression: "LEFT(message, 256)" },
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskEventDetailedSummaryV1Result = z.object({
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
duration: z.number().or(z.string()),
|
||||
status: z.string(),
|
||||
kind: z.string(),
|
||||
metadata: z.string(),
|
||||
message: z.string(),
|
||||
attributes_text: z.string(),
|
||||
});
|
||||
|
||||
export type TaskEventDetailedSummaryV1Result = z.output<typeof TaskEventDetailedSummaryV1Result>;
|
||||
|
||||
export function getTraceDetailedSummaryQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilderFast<TaskEventDetailedSummaryV1Result>({
|
||||
name: "getTaskEventDetailedSummary",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
columns: [
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"duration",
|
||||
"status",
|
||||
"kind",
|
||||
"metadata",
|
||||
{ name: "message", expression: "LEFT(message, 256)" },
|
||||
"attributes_text",
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// Row shape for streaming a whole trace out for export (the "Download trace"
|
||||
// feature). Unlike the detailed-summary builders this keeps the FULL message
|
||||
// (not LEFT(message, 256)) since the export is the source of truth, and it's
|
||||
// consumed via executeStream() so the trace is never fully materialised.
|
||||
export type TaskEventExportRow = {
|
||||
span_id: string;
|
||||
parent_span_id: string;
|
||||
start_time: string;
|
||||
duration: number | string;
|
||||
status: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
attributes_text: string;
|
||||
};
|
||||
|
||||
const TASK_EVENT_EXPORT_COLUMNS = [
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"start_time",
|
||||
"duration",
|
||||
"status",
|
||||
"kind",
|
||||
"message",
|
||||
"attributes_text",
|
||||
] as const;
|
||||
|
||||
export function getTraceEventsForExportQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilderFast<TaskEventExportRow>({
|
||||
name: "getTraceEventsForExport",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
columns: [...TASK_EVENT_EXPORT_COLUMNS],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskEventDetailsV1Result = z.object({
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
start_time: z.string(),
|
||||
duration: z.number().or(z.string()),
|
||||
status: z.string(),
|
||||
kind: z.string(),
|
||||
metadata: z.string(),
|
||||
message: z.string(),
|
||||
attributes_text: z.string(),
|
||||
});
|
||||
|
||||
export type TaskEventDetailsV1Result = z.input<typeof TaskEventDetailsV1Result>;
|
||||
|
||||
export function getSpanDetailsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getSpanDetails",
|
||||
baseQuery:
|
||||
"SELECT span_id, parent_span_id, start_time, duration, status, kind, metadata, message, attributes_text FROM trigger_dev.task_events_v1",
|
||||
schema: TaskEventDetailsV1Result,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V2 Table Functions (partitioned by inserted_at instead of start_time)
|
||||
// ============================================================================
|
||||
|
||||
export const TaskEventV2Input = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
duration: z.string(),
|
||||
trace_id: z.string(),
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
message: z.string(),
|
||||
kind: z.string(),
|
||||
status: z.string(),
|
||||
attributes: z.unknown(),
|
||||
metadata: z.string(),
|
||||
expires_at: z.string(),
|
||||
machine_id: z.string().optional(),
|
||||
// inserted_at has a default value in the table, so it's optional for inserts
|
||||
inserted_at: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskEventV2Input = z.input<typeof TaskEventV2Input>;
|
||||
|
||||
export function insertTaskEventsV2(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insertUnsafe<TaskEventV2Input>({
|
||||
name: "insertTaskEventsV2",
|
||||
table: "trigger_dev.task_events_v2",
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
input_format_json_throw_on_bad_escape_sequence: 0,
|
||||
input_format_json_use_string_type_for_ambiguous_paths_in_named_tuples_inference_from_objects: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getTraceSummaryQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilderFast<TaskEventSummaryV1Result>({
|
||||
name: "getTraceEventsV2",
|
||||
table: "trigger_dev.task_events_v2",
|
||||
columns: [
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"duration",
|
||||
"status",
|
||||
"kind",
|
||||
"metadata",
|
||||
{ name: "message", expression: "LEFT(message, 256)" },
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTraceDetailedSummaryQueryBuilderV2(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilderFast<TaskEventDetailedSummaryV1Result>({
|
||||
name: "getTaskEventDetailedSummaryV2",
|
||||
table: "trigger_dev.task_events_v2",
|
||||
columns: [
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"duration",
|
||||
"status",
|
||||
"kind",
|
||||
"metadata",
|
||||
{ name: "message", expression: "LEFT(message, 256)" },
|
||||
"attributes_text",
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSpanDetailsQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getSpanDetailsV2",
|
||||
baseQuery:
|
||||
"SELECT span_id, parent_span_id, start_time, duration, status, kind, metadata, message, attributes_text FROM trigger_dev.task_events_v2",
|
||||
schema: TaskEventDetailsV1Result,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTraceEventsForExportQueryBuilderV2(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilderFast<TaskEventExportRow>({
|
||||
name: "getTraceEventsForExportV2",
|
||||
table: "trigger_dev.task_events_v2",
|
||||
columns: [...TASK_EVENT_EXPORT_COLUMNS],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Table Query Builders (for logs page, using task_events_search_v1)
|
||||
// ============================================================================
|
||||
|
||||
export const LogsSearchListResult = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
trace_id: z.string(),
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
message: z.string(),
|
||||
kind: z.string(),
|
||||
status: z.string(),
|
||||
duration: z.number().or(z.string()),
|
||||
attributes_text: z.string(),
|
||||
triggered_timestamp: z.string(),
|
||||
});
|
||||
|
||||
export type LogsSearchListResult = z.output<typeof LogsSearchListResult>;
|
||||
|
||||
export function getLogsSearchListQueryBuilder(ch: ClickhouseReader) {
|
||||
return ch.queryBuilderFast<LogsSearchListResult>({
|
||||
name: "getLogsSearchList",
|
||||
table: "trigger_dev.task_events_search_v1",
|
||||
columns: [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"task_identifier",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
{ name: "message", expression: "LEFT(message, 512)" },
|
||||
"kind",
|
||||
"status",
|
||||
"duration",
|
||||
"attributes_text",
|
||||
"triggered_timestamp",
|
||||
],
|
||||
settings: {
|
||||
use_query_condition_cache: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Single log detail query builder (for side panel)
|
||||
export const LogDetailV2Result = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
task_identifier: z.string(),
|
||||
run_id: z.string(),
|
||||
start_time: z.string(),
|
||||
trace_id: z.string(),
|
||||
span_id: z.string(),
|
||||
parent_span_id: z.string(),
|
||||
message: z.string(),
|
||||
kind: z.string(),
|
||||
status: z.string(),
|
||||
duration: z.number().or(z.string()),
|
||||
attributes_text: z.string(),
|
||||
});
|
||||
|
||||
export type LogDetailV2Result = z.output<typeof LogDetailV2Result>;
|
||||
|
||||
export function getLogDetailQueryBuilderV2(ch: ClickhouseReader) {
|
||||
return ch.queryBuilderFast<LogDetailV2Result>({
|
||||
name: "getLogDetail",
|
||||
table: "trigger_dev.task_events_v2",
|
||||
columns: [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"task_identifier",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"message",
|
||||
"kind",
|
||||
"status",
|
||||
"duration",
|
||||
"attributes_text",
|
||||
],
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import type { ClickhouseReader, ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const TaskRunV2 = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
run_id: z.string(),
|
||||
updated_at: z.number().int(),
|
||||
created_at: z.number().int(),
|
||||
status: z.string(),
|
||||
environment_type: z.string(),
|
||||
friendly_id: z.string(),
|
||||
attempt: z.number().int().default(1),
|
||||
engine: z.string(),
|
||||
task_identifier: z.string(),
|
||||
queue: z.string(),
|
||||
schedule_id: z.string(),
|
||||
batch_id: z.string(),
|
||||
completed_at: z.number().int().nullish(),
|
||||
started_at: z.number().int().nullish(),
|
||||
executed_at: z.number().int().nullish(),
|
||||
delay_until: z.number().int().nullish(),
|
||||
queued_at: z.number().int().nullish(),
|
||||
expired_at: z.number().int().nullish(),
|
||||
usage_duration_ms: z.number().int().default(0),
|
||||
cost_in_cents: z.number().default(0),
|
||||
base_cost_in_cents: z.number().default(0),
|
||||
output: z.unknown(),
|
||||
error: z.unknown(),
|
||||
error_fingerprint: z.string().default(""),
|
||||
tags: z.array(z.string()).default([]),
|
||||
task_version: z.string(),
|
||||
sdk_version: z.string(),
|
||||
cli_version: z.string(),
|
||||
machine_preset: z.string(),
|
||||
root_run_id: z.string(),
|
||||
parent_run_id: z.string(),
|
||||
depth: z.number().int().default(0),
|
||||
span_id: z.string(),
|
||||
trace_id: z.string(),
|
||||
idempotency_key: z.string(),
|
||||
idempotency_key_user: z.string().default(""),
|
||||
idempotency_key_scope: z.string().default(""),
|
||||
expiration_ttl: z.string(),
|
||||
is_test: z.boolean().default(false),
|
||||
concurrency_key: z.string().default(""),
|
||||
bulk_action_group_ids: z.array(z.string()).default([]),
|
||||
worker_queue: z.string().default(""),
|
||||
region: z.string().default(""),
|
||||
plan_type: z.string().default(""),
|
||||
max_duration_in_seconds: z.number().int().nullish(),
|
||||
trigger_source: z.string().default(""),
|
||||
root_trigger_source: z.string().default(""),
|
||||
task_kind: z.string().default(""),
|
||||
is_warm_start: z.boolean().nullish(),
|
||||
_version: z.string(),
|
||||
_is_deleted: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type TaskRunV2 = z.input<typeof TaskRunV2>;
|
||||
|
||||
// Column order for compact format - must match ClickHouse table schema
|
||||
export const TASK_RUN_COLUMNS = [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"run_id",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"status",
|
||||
"environment_type",
|
||||
"friendly_id",
|
||||
"attempt",
|
||||
"engine",
|
||||
"task_identifier",
|
||||
"queue",
|
||||
"schedule_id",
|
||||
"batch_id",
|
||||
"completed_at",
|
||||
"started_at",
|
||||
"executed_at",
|
||||
"delay_until",
|
||||
"queued_at",
|
||||
"expired_at",
|
||||
"usage_duration_ms",
|
||||
"cost_in_cents",
|
||||
"base_cost_in_cents",
|
||||
"output",
|
||||
"error",
|
||||
"error_fingerprint",
|
||||
"tags",
|
||||
"task_version",
|
||||
"sdk_version",
|
||||
"cli_version",
|
||||
"machine_preset",
|
||||
"root_run_id",
|
||||
"parent_run_id",
|
||||
"depth",
|
||||
"span_id",
|
||||
"trace_id",
|
||||
"idempotency_key",
|
||||
"idempotency_key_user",
|
||||
"idempotency_key_scope",
|
||||
"expiration_ttl",
|
||||
"is_test",
|
||||
"_version",
|
||||
"_is_deleted",
|
||||
"concurrency_key",
|
||||
"bulk_action_group_ids",
|
||||
"worker_queue",
|
||||
"region",
|
||||
"plan_type",
|
||||
"max_duration_in_seconds",
|
||||
"trigger_source",
|
||||
"root_trigger_source",
|
||||
"task_kind",
|
||||
"is_warm_start",
|
||||
] as const;
|
||||
|
||||
export type TaskRunColumnName = (typeof TASK_RUN_COLUMNS)[number];
|
||||
|
||||
// Type-safe column indices generated from TASK_RUN_COLUMNS
|
||||
// This ensures indices stay in sync with column order automatically
|
||||
export const TASK_RUN_INDEX = Object.fromEntries(
|
||||
TASK_RUN_COLUMNS.map((col, idx) => [col, idx])
|
||||
) as { readonly [K in TaskRunColumnName]: number };
|
||||
|
||||
/**
|
||||
* Type mapping from column name to its type in TaskRunInsertArray.
|
||||
* This enables type-safe field access without manual casting.
|
||||
*/
|
||||
export type TaskRunFieldTypes = {
|
||||
environment_id: string;
|
||||
organization_id: string;
|
||||
project_id: string;
|
||||
run_id: string;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
status: string;
|
||||
environment_type: string;
|
||||
friendly_id: string;
|
||||
attempt: number;
|
||||
engine: string;
|
||||
task_identifier: string;
|
||||
queue: string;
|
||||
schedule_id: string;
|
||||
batch_id: string;
|
||||
completed_at: number | null;
|
||||
started_at: number | null;
|
||||
executed_at: number | null;
|
||||
delay_until: number | null;
|
||||
queued_at: number | null;
|
||||
expired_at: number | null;
|
||||
usage_duration_ms: number;
|
||||
cost_in_cents: number;
|
||||
base_cost_in_cents: number;
|
||||
output: { data: unknown };
|
||||
error: { data: unknown };
|
||||
error_fingerprint: string;
|
||||
tags: string[];
|
||||
task_version: string;
|
||||
sdk_version: string;
|
||||
cli_version: string;
|
||||
machine_preset: string;
|
||||
root_run_id: string;
|
||||
parent_run_id: string;
|
||||
depth: number;
|
||||
span_id: string;
|
||||
trace_id: string;
|
||||
idempotency_key: string;
|
||||
idempotency_key_user: string;
|
||||
idempotency_key_scope: string;
|
||||
expiration_ttl: string;
|
||||
is_test: boolean;
|
||||
_version: string;
|
||||
_is_deleted: number;
|
||||
concurrency_key: string;
|
||||
bulk_action_group_ids: string[];
|
||||
worker_queue: string;
|
||||
region: string;
|
||||
plan_type: string;
|
||||
max_duration_in_seconds: number | null;
|
||||
trigger_source: string;
|
||||
root_trigger_source: string;
|
||||
task_kind: string;
|
||||
is_warm_start: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type-safe accessor for TaskRunInsertArray fields.
|
||||
* Returns the correct type for each field without manual casting.
|
||||
*
|
||||
* @example
|
||||
* const orgId = getTaskRunField(run, "organization_id"); // type: string
|
||||
* const createdAt = getTaskRunField(run, "created_at"); // type: number
|
||||
*/
|
||||
export function getTaskRunField<K extends TaskRunColumnName>(
|
||||
run: TaskRunInsertArray,
|
||||
field: K
|
||||
): TaskRunFieldTypes[K] {
|
||||
return run[TASK_RUN_INDEX[field]] as TaskRunFieldTypes[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a globally-comparable ReplacingMergeTree version for task_runs_v2
|
||||
* when the same run can be replicated from more than one Postgres producer.
|
||||
*
|
||||
* Each producer has its own, mutually-incomparable LSN space, so the raw
|
||||
* LSN-derived version cannot be compared across producers. We reserve the top
|
||||
* 8 bits for an `originGeneration` epoch (monotonic across producers: the more
|
||||
* authoritative / later-cutover producer gets the higher generation) and keep
|
||||
* the producer's own LSN in the low 56 bits to preserve in-producer ordering.
|
||||
*
|
||||
* Self-host single-DB never calls this (one producer => generation is constant
|
||||
* and the existing raw LSN path is sufficient); the split gate skips it.
|
||||
*/
|
||||
export function composeTaskRunVersion(opts: {
|
||||
originGeneration: number;
|
||||
lsnVersion: bigint;
|
||||
}): bigint {
|
||||
const gen = BigInt(opts.originGeneration);
|
||||
if (gen < BigInt(0) || gen > BigInt(0xff)) {
|
||||
throw new Error(`originGeneration out of range (0-255): ${opts.originGeneration}`);
|
||||
}
|
||||
const LSN_BITS = BigInt(56);
|
||||
const LSN_MASK = (BigInt(1) << LSN_BITS) - BigInt(1); // low 56 bits
|
||||
return (gen << LSN_BITS) | (opts.lsnVersion & LSN_MASK);
|
||||
}
|
||||
|
||||
export function insertTaskRunsCompactArrays(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insertCompactRaw({
|
||||
name: "insertTaskRunsCompactArrays",
|
||||
table: "trigger_dev.task_runs_v2",
|
||||
columns: TASK_RUN_COLUMNS,
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Object-based insert function for tests and non-performance-critical code
|
||||
export function insertTaskRuns(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insert({
|
||||
name: "insertTaskRuns",
|
||||
table: "trigger_dev.task_runs_v2",
|
||||
schema: TaskRunV2,
|
||||
settings: {
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const RawTaskRunPayloadV1 = z.object({
|
||||
run_id: z.string(),
|
||||
created_at: z.number().int(),
|
||||
payload: z.unknown(),
|
||||
});
|
||||
|
||||
export type RawTaskRunPayloadV1 = z.infer<typeof RawTaskRunPayloadV1>;
|
||||
|
||||
export const PAYLOAD_COLUMNS = ["run_id", "created_at", "payload"] as const;
|
||||
|
||||
export type PayloadColumnName = (typeof PAYLOAD_COLUMNS)[number];
|
||||
|
||||
// Type-safe column indices generated from PAYLOAD_COLUMNS
|
||||
export const PAYLOAD_INDEX = Object.fromEntries(PAYLOAD_COLUMNS.map((col, idx) => [col, idx])) as {
|
||||
readonly [K in PayloadColumnName]: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type mapping from column name to its type in PayloadInsertArray.
|
||||
*/
|
||||
export type PayloadFieldTypes = {
|
||||
run_id: string;
|
||||
created_at: number;
|
||||
payload: { data: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Type-safe accessor for PayloadInsertArray fields.
|
||||
* Returns the correct type for each field without manual casting.
|
||||
*/
|
||||
export function getPayloadField<K extends PayloadColumnName>(
|
||||
payload: PayloadInsertArray,
|
||||
field: K
|
||||
): PayloadFieldTypes[K] {
|
||||
return payload[PAYLOAD_INDEX[field]] as PayloadFieldTypes[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe tuple representing a task run insert array.
|
||||
* Order matches TASK_RUN_COLUMNS exactly.
|
||||
*/
|
||||
export type TaskRunInsertArray = [
|
||||
environment_id: string,
|
||||
organization_id: string,
|
||||
project_id: string,
|
||||
run_id: string,
|
||||
updated_at: number,
|
||||
created_at: number,
|
||||
status: string,
|
||||
environment_type: string,
|
||||
friendly_id: string,
|
||||
attempt: number,
|
||||
engine: string,
|
||||
task_identifier: string,
|
||||
queue: string,
|
||||
schedule_id: string,
|
||||
batch_id: string,
|
||||
completed_at: number | null,
|
||||
started_at: number | null,
|
||||
executed_at: number | null,
|
||||
delay_until: number | null,
|
||||
queued_at: number | null,
|
||||
expired_at: number | null,
|
||||
usage_duration_ms: number,
|
||||
cost_in_cents: number,
|
||||
base_cost_in_cents: number,
|
||||
output: { data: unknown },
|
||||
error: { data: unknown },
|
||||
error_fingerprint: string,
|
||||
tags: string[],
|
||||
task_version: string,
|
||||
sdk_version: string,
|
||||
cli_version: string,
|
||||
machine_preset: string,
|
||||
root_run_id: string,
|
||||
parent_run_id: string,
|
||||
depth: number,
|
||||
span_id: string,
|
||||
trace_id: string,
|
||||
idempotency_key: string,
|
||||
idempotency_key_user: string,
|
||||
idempotency_key_scope: string,
|
||||
expiration_ttl: string,
|
||||
is_test: boolean,
|
||||
_version: string,
|
||||
_is_deleted: number,
|
||||
concurrency_key: string,
|
||||
bulk_action_group_ids: string[],
|
||||
worker_queue: string,
|
||||
region: string,
|
||||
plan_type: string,
|
||||
max_duration_in_seconds: number | null,
|
||||
trigger_source: string,
|
||||
root_trigger_source: string,
|
||||
task_kind: string,
|
||||
is_warm_start: boolean | null,
|
||||
];
|
||||
|
||||
/**
|
||||
* Type-safe tuple representing a payload insert array.
|
||||
* Order matches PAYLOAD_COLUMNS exactly.
|
||||
*/
|
||||
export type PayloadInsertArray = [run_id: string, created_at: number, payload: { data: unknown }];
|
||||
|
||||
export function insertRawTaskRunPayloadsCompactArrays(
|
||||
ch: ClickhouseWriter,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.insertCompactRaw({
|
||||
name: "insertRawTaskRunPayloadsCompactArrays",
|
||||
table: "trigger_dev.raw_task_runs_payload_v1",
|
||||
columns: PAYLOAD_COLUMNS,
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_max_data_size: "1000000",
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Object-based insert function for tests and non-performance-critical code
|
||||
export function insertRawTaskRunPayloads(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insert({
|
||||
name: "insertRawTaskRunPayloads",
|
||||
table: "trigger_dev.raw_task_runs_payload_v1",
|
||||
schema: RawTaskRunPayloadV1,
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_max_data_size: "1000000",
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
enable_json_type: 1,
|
||||
type_json_skip_duplicated_paths: 1,
|
||||
input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskRunV2QueryResult = z.object({
|
||||
run_id: z.string(),
|
||||
});
|
||||
|
||||
export type TaskRunV2QueryResult = z.infer<typeof TaskRunV2QueryResult>;
|
||||
|
||||
// Adds the created_at timestamp (ms since epoch) needed to build composite
|
||||
// keyset cursors over (created_at, run_id) — see runsRepository.server.ts.
|
||||
// Returned as a JSON number because the client sets
|
||||
// output_format_json_quote_64bit_integers: 0. Kept separate from
|
||||
// TaskRunV2QueryResult so run_id-only consumers (e.g. the pending-version
|
||||
// lookup) aren't forced to select a column they don't need.
|
||||
export const TaskRunListQueryResult = z.object({
|
||||
run_id: z.string(),
|
||||
created_at_ms: z.number().int(),
|
||||
});
|
||||
|
||||
export type TaskRunListQueryResult = z.infer<typeof TaskRunListQueryResult>;
|
||||
|
||||
export function getTaskRunsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getTaskRuns",
|
||||
baseQuery:
|
||||
"SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL",
|
||||
schema: TaskRunListQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup builder for the run-engine `PendingVersionSystem`. Returns just
|
||||
* `run_id` from `task_runs_v2`. No `FINAL` — the run-engine re-validates
|
||||
* each candidate against Postgres by primary key, so a stale
|
||||
* `PENDING_VERSION` row from a not-yet-merged part is harmless and
|
||||
* `FINAL` would be too expensive for this hot path.
|
||||
*/
|
||||
export function getPendingVersionIdsQueryBuilder(
|
||||
ch: ClickhouseReader,
|
||||
settings?: ClickHouseSettings
|
||||
) {
|
||||
return ch.queryBuilder({
|
||||
name: "getPendingVersionIds",
|
||||
baseQuery: "SELECT run_id FROM trigger_dev.task_runs_v2",
|
||||
schema: TaskRunV2QueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskRunsCountQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getTaskRunsCount",
|
||||
baseQuery: "SELECT count() as count FROM trigger_dev.task_runs_v2 FINAL",
|
||||
schema: z.object({
|
||||
count: z.number().int(),
|
||||
}),
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskRunExistsQueryResult = z.object({
|
||||
run_exists: z.number().int(),
|
||||
});
|
||||
|
||||
export type TaskRunExistsQueryResult = z.infer<typeof TaskRunExistsQueryResult>;
|
||||
|
||||
// Empty-state existence probe. No FINAL (a stale/dup row still answers "a run exists").
|
||||
// Callers must filter organization_id + project_id + environment_id (the sort-key prefix).
|
||||
export function getTaskRunExistsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getTaskRunExists",
|
||||
baseQuery: "SELECT 1 AS run_exists FROM trigger_dev.task_runs_v2",
|
||||
schema: TaskRunExistsQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskRunTagsQueryResult = z.object({
|
||||
tag: z.string(),
|
||||
});
|
||||
|
||||
export type TaskRunTagsQueryResult = z.infer<typeof TaskRunTagsQueryResult>;
|
||||
|
||||
export function getTaskRunTagsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getTaskRunTags",
|
||||
baseQuery: "SELECT DISTINCT arrayJoin(tags) as tag FROM trigger_dev.task_runs_v2",
|
||||
schema: TaskRunTagsQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskActivityQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
status: z.string(),
|
||||
day: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export type TaskActivityQueryResult = z.infer<typeof TaskActivityQueryResult>;
|
||||
|
||||
export const TaskActivityQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
days: z.number().int(),
|
||||
});
|
||||
|
||||
export function getTaskActivityQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getTaskActivity",
|
||||
query: `
|
||||
SELECT
|
||||
task_identifier,
|
||||
status,
|
||||
toDate(created_at) as day,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND created_at >= today() - {days: Int64}
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
task_identifier,
|
||||
status,
|
||||
day
|
||||
ORDER BY
|
||||
task_identifier ASC,
|
||||
day ASC,
|
||||
status ASC
|
||||
`,
|
||||
schema: TaskActivityQueryResult,
|
||||
params: TaskActivityQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const CurrentRunningStatsQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
status: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export type CurrentRunningStatsQueryResult = z.infer<typeof CurrentRunningStatsQueryResult>;
|
||||
|
||||
export const CurrentRunningStatsQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
days: z.number().int(),
|
||||
});
|
||||
|
||||
export function getCurrentRunningStats(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getCurrentRunningStats",
|
||||
query: `
|
||||
SELECT
|
||||
task_identifier,
|
||||
status,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND status IN ('PENDING', 'WAITING_FOR_DEPLOY', 'WAITING_TO_RESUME', 'QUEUED', 'EXECUTING', 'DELAYED')
|
||||
AND _is_deleted = 0
|
||||
AND created_at >= now() - INTERVAL {days: Int64} DAY
|
||||
GROUP BY
|
||||
task_identifier,
|
||||
status
|
||||
ORDER BY
|
||||
task_identifier ASC
|
||||
`,
|
||||
schema: CurrentRunningStatsQueryResult,
|
||||
params: CurrentRunningStatsQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const ChildRunStatusCountsQueryResult = z.object({
|
||||
root_run_id: z.string(),
|
||||
status: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export type ChildRunStatusCountsQueryResult = z.infer<typeof ChildRunStatusCountsQueryResult>;
|
||||
|
||||
export const ChildRunStatusCountsQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
rootRunIds: z.array(z.string()).min(1),
|
||||
since: z.number().int(),
|
||||
});
|
||||
|
||||
export function getChildRunStatusCounts(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getChildRunStatusCounts",
|
||||
query: `
|
||||
SELECT
|
||||
root_run_id,
|
||||
status,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND root_run_id IN {rootRunIds: Array(String)}
|
||||
AND created_at >= fromUnixTimestamp64Milli({since: Int64})
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
root_run_id,
|
||||
status
|
||||
ORDER BY
|
||||
root_run_id ASC,
|
||||
status ASC
|
||||
`,
|
||||
schema: ChildRunStatusCountsQueryResult,
|
||||
params: ChildRunStatusCountsQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const AverageDurationsQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
duration: z.number(),
|
||||
});
|
||||
|
||||
export type AverageDurationsQueryResult = z.infer<typeof AverageDurationsQueryResult>;
|
||||
|
||||
export const AverageDurationsQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
days: z.number().int(),
|
||||
});
|
||||
|
||||
export function getAverageDurations(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getAverageDurations",
|
||||
query: `
|
||||
SELECT
|
||||
task_identifier,
|
||||
avg(toUnixTimestamp(completed_at) - toUnixTimestamp(started_at)) as duration
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND created_at >= today() - {days: Int64}
|
||||
AND status IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS')
|
||||
AND started_at IS NOT NULL
|
||||
AND completed_at IS NOT NULL
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
task_identifier
|
||||
`,
|
||||
schema: AverageDurationsQueryResult,
|
||||
params: AverageDurationsQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskUsageByOrganizationQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
run_count: z.number(),
|
||||
average_duration: z.number(),
|
||||
total_duration: z.number(),
|
||||
average_cost: z.number(),
|
||||
total_cost: z.number(),
|
||||
total_base_cost: z.number(),
|
||||
});
|
||||
|
||||
export const TaskUsageByOrganizationQueryParams = z.object({
|
||||
startTime: z.number().int(),
|
||||
endTime: z.number().int(),
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
export function getTaskUsageByOrganization(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getTaskUsageByOrganization",
|
||||
query: `
|
||||
SELECT
|
||||
task_identifier,
|
||||
count() AS run_count,
|
||||
avg(usage_duration_ms) AS average_duration,
|
||||
sum(usage_duration_ms) AS total_duration,
|
||||
avg(cost_in_cents) / 100.0 AS average_cost,
|
||||
sum(cost_in_cents) / 100.0 AS total_cost,
|
||||
sum(base_cost_in_cents) / 100.0 AS total_base_cost
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
environment_type != 'DEVELOPMENT'
|
||||
AND created_at >= fromUnixTimestamp64Milli({startTime: Int64})
|
||||
AND created_at < fromUnixTimestamp64Milli({endTime: Int64})
|
||||
AND organization_id = {organizationId: String}
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
task_identifier
|
||||
ORDER BY
|
||||
total_cost DESC
|
||||
`,
|
||||
schema: TaskUsageByOrganizationQueryResult,
|
||||
params: TaskUsageByOrganizationQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,864 @@
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { z } from "zod";
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import { executeTSQL, type TableSchema } from "./client/tsql.js";
|
||||
import { insertTaskRuns } from "./taskRuns.js";
|
||||
import { column } from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* Schema definition for task_runs table used in function tests.
|
||||
* Includes numeric, string, datetime, and array columns for exercising all function categories.
|
||||
*/
|
||||
const taskRunsSchema: TableSchema = {
|
||||
name: "task_runs",
|
||||
clickhouseName: "trigger_dev.task_runs_v2",
|
||||
columns: {
|
||||
run_id: { name: "run_id", ...column("String") },
|
||||
friendly_id: { name: "friendly_id", ...column("String") },
|
||||
status: { name: "status", ...column("String") },
|
||||
task_identifier: { name: "task_identifier", ...column("String") },
|
||||
queue: { name: "queue", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
environment_type: { name: "environment_type", ...column("String") },
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
created_at: { name: "created_at", ...column("DateTime64") },
|
||||
updated_at: { name: "updated_at", ...column("DateTime64") },
|
||||
started_at: { name: "started_at", ...column("Nullable(DateTime64)") },
|
||||
completed_at: { name: "completed_at", ...column("Nullable(DateTime64)") },
|
||||
is_test: { name: "is_test", ...column("UInt8") },
|
||||
tags: { name: "tags", ...column("Array(String)") },
|
||||
output: {
|
||||
name: "output",
|
||||
...column("JSON"),
|
||||
nullValue: "'{}'",
|
||||
textColumn: "output_text",
|
||||
dataPrefix: "data",
|
||||
},
|
||||
usage_duration_ms: { name: "usage_duration_ms", ...column("UInt32") },
|
||||
cost_in_cents: { name: "cost_in_cents", ...column("Float64") },
|
||||
attempt: { name: "attempt", ...column("UInt8") },
|
||||
depth: { name: "depth", ...column("UInt8") },
|
||||
},
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
};
|
||||
|
||||
const enforcedWhereClause = {
|
||||
organization_id: { op: "eq" as const, value: "org_tenant1" },
|
||||
project_id: { op: "eq" as const, value: "proj_tenant1" },
|
||||
environment_id: { op: "eq" as const, value: "env_tenant1" },
|
||||
};
|
||||
|
||||
const defaultTaskRun = {
|
||||
environment_id: "env_tenant1",
|
||||
environment_type: "DEVELOPMENT",
|
||||
organization_id: "org_tenant1",
|
||||
project_id: "proj_tenant1",
|
||||
run_id: "run_func_test_1",
|
||||
friendly_id: "friendly_func_test_1",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
task_identifier: "my-task",
|
||||
queue: "my-queue",
|
||||
schedule_id: "",
|
||||
batch_id: "",
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
started_at: Date.now() - 5000,
|
||||
completed_at: Date.now(),
|
||||
tags: ["tag-a", "tag-b"],
|
||||
output: { data: { count: 42, label: "ok", ratio: 1.5, enabled: true, items: [1, 2, 3] } },
|
||||
error: null,
|
||||
usage_duration_ms: 4500,
|
||||
cost_in_cents: 1.5,
|
||||
base_cost_in_cents: 0.5,
|
||||
task_version: "1.0.0",
|
||||
sdk_version: "4.0.0",
|
||||
cli_version: "4.0.0",
|
||||
machine_preset: "small-1x",
|
||||
is_test: false,
|
||||
span_id: "span_123",
|
||||
trace_id: "trace_123",
|
||||
idempotency_key: "idem_123",
|
||||
expiration_ttl: "",
|
||||
root_run_id: "",
|
||||
parent_run_id: "",
|
||||
depth: 2,
|
||||
concurrency_key: "",
|
||||
bulk_action_group_ids: [] as string[],
|
||||
_version: "1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: execute a TSQL query and assert no errors.
|
||||
*/
|
||||
async function assertQueryExecutes(client: ClickhouseClient, tsqlQuery: string): Promise<void> {
|
||||
const [error] = await executeTSQL(client, {
|
||||
name: "func-test",
|
||||
query: tsqlQuery,
|
||||
schema: z.record(z.any()),
|
||||
enforcedWhereClause,
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Query failed: ${tsqlQuery}\n\nError: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: set up a client with test data inserted.
|
||||
*/
|
||||
async function setupClient(clickhouseContainer: { getConnectionUrl(): string }) {
|
||||
const client = new ClickhouseClient({
|
||||
name: "func-test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insert = insertTaskRuns(client, { async_insert: 0 });
|
||||
const [insertError] = await insert([defaultTaskRun]);
|
||||
expect(insertError).toBeNull();
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: run all test cases in a single ClickHouse container.
|
||||
* Each case is a [name, tsqlQuery] tuple.
|
||||
*/
|
||||
async function runCases(client: ClickhouseClient, cases: [string, string][]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [name, query] of cases) {
|
||||
try {
|
||||
await assertQueryExecutes(client, query);
|
||||
} catch (e) {
|
||||
failures.push(` ${name}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`${failures.length}/${cases.length} function(s) failed:\n${failures.join("\n")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = "https://user:pass@www.example.com:8080/path/page?q=1&r=2#frag";
|
||||
|
||||
describe("TSQL Function Smoke Tests", () => {
|
||||
// ─── Arithmetic functions ─────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Arithmetic functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["plus", "SELECT plus(usage_duration_ms, 1) AS r FROM task_runs"],
|
||||
["minus", "SELECT minus(usage_duration_ms, 1) AS r FROM task_runs"],
|
||||
["multiply", "SELECT multiply(usage_duration_ms, 2) AS r FROM task_runs"],
|
||||
["divide", "SELECT divide(usage_duration_ms, 2) AS r FROM task_runs"],
|
||||
["intDiv", "SELECT intDiv(usage_duration_ms, 2) AS r FROM task_runs"],
|
||||
["intDivOrZero", "SELECT intDivOrZero(usage_duration_ms, 0) AS r FROM task_runs"],
|
||||
["modulo", "SELECT modulo(usage_duration_ms, 3) AS r FROM task_runs"],
|
||||
["moduloOrZero", "SELECT moduloOrZero(usage_duration_ms, 0) AS r FROM task_runs"],
|
||||
["positiveModulo", "SELECT positiveModulo(usage_duration_ms, 3) AS r FROM task_runs"],
|
||||
["negate", "SELECT negate(cost_in_cents) AS r FROM task_runs"],
|
||||
["abs", "SELECT abs(cost_in_cents) AS r FROM task_runs"],
|
||||
["gcd", "SELECT gcd(12, 8) AS r FROM task_runs"],
|
||||
["lcm", "SELECT lcm(12, 8) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Mathematical functions ───────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Mathematical functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["exp", "SELECT exp(1) AS r FROM task_runs"],
|
||||
["log", "SELECT log(2.718) AS r FROM task_runs"],
|
||||
["ln", "SELECT ln(2.718) AS r FROM task_runs"],
|
||||
["exp2", "SELECT exp2(3) AS r FROM task_runs"],
|
||||
["log2", "SELECT log2(8) AS r FROM task_runs"],
|
||||
["exp10", "SELECT exp10(2) AS r FROM task_runs"],
|
||||
["log10", "SELECT log10(100) AS r FROM task_runs"],
|
||||
["sqrt", "SELECT sqrt(16) AS r FROM task_runs"],
|
||||
["cbrt", "SELECT cbrt(27) AS r FROM task_runs"],
|
||||
["erf", "SELECT erf(1) AS r FROM task_runs"],
|
||||
["erfc", "SELECT erfc(1) AS r FROM task_runs"],
|
||||
["lgamma", "SELECT lgamma(5) AS r FROM task_runs"],
|
||||
["tgamma", "SELECT tgamma(5) AS r FROM task_runs"],
|
||||
["sin", "SELECT sin(1) AS r FROM task_runs"],
|
||||
["cos", "SELECT cos(1) AS r FROM task_runs"],
|
||||
["tan", "SELECT tan(1) AS r FROM task_runs"],
|
||||
["asin", "SELECT asin(0.5) AS r FROM task_runs"],
|
||||
["acos", "SELECT acos(0.5) AS r FROM task_runs"],
|
||||
["atan", "SELECT atan(1) AS r FROM task_runs"],
|
||||
["pow", "SELECT pow(2, 3) AS r FROM task_runs"],
|
||||
["power", "SELECT power(2, 3) AS r FROM task_runs"],
|
||||
["round", "SELECT round(3.14159, 2) AS r FROM task_runs"],
|
||||
["floor", "SELECT floor(3.7) AS r FROM task_runs"],
|
||||
["ceil", "SELECT ceil(3.2) AS r FROM task_runs"],
|
||||
["ceiling", "SELECT ceiling(3.2) AS r FROM task_runs"],
|
||||
["trunc", "SELECT trunc(3.7) AS r FROM task_runs"],
|
||||
["truncate", "SELECT truncate(3.7) AS r FROM task_runs"],
|
||||
["sign", "SELECT sign(-5) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── String functions ─────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("String functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["empty", "SELECT empty(status) AS r FROM task_runs"],
|
||||
["notEmpty", "SELECT notEmpty(status) AS r FROM task_runs"],
|
||||
["length", "SELECT length(status) AS r FROM task_runs"],
|
||||
["lengthUTF8", "SELECT lengthUTF8(status) AS r FROM task_runs"],
|
||||
["char_length", "SELECT char_length(status) AS r FROM task_runs"],
|
||||
["character_length", "SELECT character_length(status) AS r FROM task_runs"],
|
||||
["lower", "SELECT lower(status) AS r FROM task_runs"],
|
||||
["upper", "SELECT upper(status) AS r FROM task_runs"],
|
||||
["lowerUTF8", "SELECT lowerUTF8(status) AS r FROM task_runs"],
|
||||
["upperUTF8", "SELECT upperUTF8(status) AS r FROM task_runs"],
|
||||
["reverse", "SELECT reverse(status) AS r FROM task_runs"],
|
||||
["reverseUTF8", "SELECT reverseUTF8(status) AS r FROM task_runs"],
|
||||
["concat", "SELECT concat(status, '-', run_id) AS r FROM task_runs"],
|
||||
["substring", "SELECT substring(status, 1, 3) AS r FROM task_runs"],
|
||||
["substr", "SELECT substr(status, 1, 3) AS r FROM task_runs"],
|
||||
["mid", "SELECT mid(status, 1, 3) AS r FROM task_runs"],
|
||||
["substringUTF8", "SELECT substringUTF8(status, 1, 3) AS r FROM task_runs"],
|
||||
[
|
||||
"appendTrailingCharIfAbsent",
|
||||
"SELECT appendTrailingCharIfAbsent(status, '!') AS r FROM task_runs",
|
||||
],
|
||||
["base64Encode", "SELECT base64Encode(status) AS r FROM task_runs"],
|
||||
["base64Decode", "SELECT base64Decode(base64Encode(status)) AS r FROM task_runs"],
|
||||
["tryBase64Decode", "SELECT tryBase64Decode('aGVsbG8=') AS r FROM task_runs"],
|
||||
["endsWith", "SELECT endsWith(status, 'LY') AS r FROM task_runs"],
|
||||
["startsWith", "SELECT startsWith(status, 'COM') AS r FROM task_runs"],
|
||||
["trim", "SELECT trim(status) AS r FROM task_runs"],
|
||||
["trimLeft", "SELECT trimLeft(status) AS r FROM task_runs"],
|
||||
["trimRight", "SELECT trimRight(status) AS r FROM task_runs"],
|
||||
["ltrim", "SELECT ltrim(status) AS r FROM task_runs"],
|
||||
["rtrim", "SELECT rtrim(status) AS r FROM task_runs"],
|
||||
["leftPad", "SELECT leftPad(status, 30, '*') AS r FROM task_runs"],
|
||||
["rightPad", "SELECT rightPad(status, 30, '*') AS r FROM task_runs"],
|
||||
["leftPadUTF8", "SELECT leftPadUTF8(status, 30, '*') AS r FROM task_runs"],
|
||||
["rightPadUTF8", "SELECT rightPadUTF8(status, 30, '*') AS r FROM task_runs"],
|
||||
["left", "SELECT left(status, 3) AS r FROM task_runs"],
|
||||
["right", "SELECT right(status, 3) AS r FROM task_runs"],
|
||||
["repeat", "SELECT repeat(status, 2) AS r FROM task_runs"],
|
||||
["space", "SELECT space(5) AS r FROM task_runs"],
|
||||
["replace", "SELECT replace(status, 'COMPLETED', 'DONE') AS r FROM task_runs"],
|
||||
["replaceOne", "SELECT replaceOne(status, 'COMPLETED', 'DONE') AS r FROM task_runs"],
|
||||
["replaceAll", "SELECT replaceAll(status, 'COMPLETED', 'DONE') AS r FROM task_runs"],
|
||||
["replaceRegexpOne", "SELECT replaceRegexpOne(status, '[A-Z]+', 'X') AS r FROM task_runs"],
|
||||
["replaceRegexpAll", "SELECT replaceRegexpAll(status, '[A-Z]', 'x') AS r FROM task_runs"],
|
||||
["position", "SELECT position(status, 'COM') AS r FROM task_runs"],
|
||||
[
|
||||
"positionCaseInsensitive",
|
||||
"SELECT positionCaseInsensitive(status, 'com') AS r FROM task_runs",
|
||||
],
|
||||
["positionUTF8", "SELECT positionUTF8(status, 'COM') AS r FROM task_runs"],
|
||||
[
|
||||
"positionCaseInsensitiveUTF8",
|
||||
"SELECT positionCaseInsensitiveUTF8(status, 'com') AS r FROM task_runs",
|
||||
],
|
||||
["locate", "SELECT locate(status, 'COM') AS r FROM task_runs"],
|
||||
["match", "SELECT match(status, 'COMPLETED.*') AS r FROM task_runs"],
|
||||
["like", "SELECT like(status, '%COMPLETED%') AS r FROM task_runs"],
|
||||
["ilike", "SELECT ilike(status, '%completed%') AS r FROM task_runs"],
|
||||
["notLike", "SELECT notLike(status, '%PENDING%') AS r FROM task_runs"],
|
||||
["notILike", "SELECT notILike(status, '%pending%') AS r FROM task_runs"],
|
||||
["splitByChar", "SELECT splitByChar('_', status) AS r FROM task_runs"],
|
||||
["splitByString", "SELECT splitByString('_', status) AS r FROM task_runs"],
|
||||
["splitByRegexp", "SELECT splitByRegexp('_', status) AS r FROM task_runs"],
|
||||
["arrayStringConcat", "SELECT arrayStringConcat(tags, ',') AS r FROM task_runs"],
|
||||
["format", "SELECT format('{0}-{1}', status, run_id) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Null functions ───────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Null functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["coalesce", "SELECT coalesce(started_at, now()) AS r FROM task_runs"],
|
||||
["ifNull", "SELECT ifNull(started_at, now()) AS r FROM task_runs"],
|
||||
["nullIf", "SELECT nullIf(status, 'PENDING') AS r FROM task_runs"],
|
||||
["assumeNotNull", "SELECT assumeNotNull(started_at) AS r FROM task_runs"],
|
||||
["toNullable", "SELECT toNullable(status) AS r FROM task_runs"],
|
||||
["isNull", "SELECT isNull(started_at) AS r FROM task_runs"],
|
||||
["isNotNull", "SELECT isNotNull(started_at) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Conditional functions ────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Conditional functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["if", "SELECT if(usage_duration_ms > 1000, 'slow', 'fast') AS r FROM task_runs"],
|
||||
[
|
||||
"multiIf",
|
||||
"SELECT multiIf(usage_duration_ms > 5000, 'slow', usage_duration_ms > 1000, 'medium', 'fast') AS r FROM task_runs",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Comparison functions ─────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Comparison functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["equals", "SELECT equals(status, 'PENDING') AS r FROM task_runs"],
|
||||
["notEquals", "SELECT notEquals(status, 'PENDING') AS r FROM task_runs"],
|
||||
["less", "SELECT less(usage_duration_ms, 9999) AS r FROM task_runs"],
|
||||
["greater", "SELECT greater(usage_duration_ms, 0) AS r FROM task_runs"],
|
||||
["lessOrEquals", "SELECT lessOrEquals(usage_duration_ms, 9999) AS r FROM task_runs"],
|
||||
["greaterOrEquals", "SELECT greaterOrEquals(usage_duration_ms, 0) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Logical functions ────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Logical functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["and", "SELECT and(usage_duration_ms > 0, is_test = 0) AS r FROM task_runs"],
|
||||
["or", "SELECT or(usage_duration_ms > 9999, is_test = 0) AS r FROM task_runs"],
|
||||
["xor", "SELECT xor(usage_duration_ms > 0, is_test = 1) AS r FROM task_runs"],
|
||||
["not", "SELECT not(is_test) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Type conversion functions ────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Type conversion functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["toString", "SELECT toString(usage_duration_ms) AS r FROM task_runs"],
|
||||
["toFixedString", "SELECT toFixedString(status, 30) AS r FROM task_runs"],
|
||||
["toUInt8", "SELECT toUInt8(is_test) AS r FROM task_runs"],
|
||||
["toUInt16", "SELECT toUInt16(usage_duration_ms) AS r FROM task_runs"],
|
||||
["toUInt32", "SELECT toUInt32(usage_duration_ms) AS r FROM task_runs"],
|
||||
["toUInt64", "SELECT toUInt64(usage_duration_ms) AS r FROM task_runs"],
|
||||
["toInt8", "SELECT toInt8(1) AS r FROM task_runs"],
|
||||
["toInt16", "SELECT toInt16(1) AS r FROM task_runs"],
|
||||
["toInt32", "SELECT toInt32(1) AS r FROM task_runs"],
|
||||
["toInt64", "SELECT toInt64(usage_duration_ms) AS r FROM task_runs"],
|
||||
["toInt128", "SELECT toInt128(1) AS r FROM task_runs"],
|
||||
["toInt256", "SELECT toInt256(1) AS r FROM task_runs"],
|
||||
["toUInt128", "SELECT toUInt128(1) AS r FROM task_runs"],
|
||||
["toUInt256", "SELECT toUInt256(1) AS r FROM task_runs"],
|
||||
["toFloat32", "SELECT toFloat32(cost_in_cents) AS r FROM task_runs"],
|
||||
["toFloat64", "SELECT toFloat64(cost_in_cents) AS r FROM task_runs"],
|
||||
["toDecimal32", "SELECT toDecimal32(cost_in_cents, 2) AS r FROM task_runs"],
|
||||
["toDecimal64", "SELECT toDecimal64(cost_in_cents, 2) AS r FROM task_runs"],
|
||||
["toDecimal128", "SELECT toDecimal128(cost_in_cents, 2) AS r FROM task_runs"],
|
||||
["toDecimal256", "SELECT toDecimal256(cost_in_cents, 2) AS r FROM task_runs"],
|
||||
["toDate", "SELECT toDate(created_at) AS r FROM task_runs"],
|
||||
["toDateOrNull", "SELECT toDateOrNull('2024-01-01') AS r FROM task_runs"],
|
||||
["toDateOrZero", "SELECT toDateOrZero('invalid') AS r FROM task_runs"],
|
||||
["toDate32", "SELECT toDate32(created_at) AS r FROM task_runs"],
|
||||
["toDate32OrNull", "SELECT toDate32OrNull('2024-01-01') AS r FROM task_runs"],
|
||||
["toDate32OrZero", "SELECT toDate32OrZero('invalid') AS r FROM task_runs"],
|
||||
["toDateTime", "SELECT toDateTime(created_at) AS r FROM task_runs"],
|
||||
["toDateTimeOrNull", "SELECT toDateTimeOrNull('2024-01-01 00:00:00') AS r FROM task_runs"],
|
||||
["toDateTimeOrZero", "SELECT toDateTimeOrZero('invalid') AS r FROM task_runs"],
|
||||
["toDateTime64", "SELECT toDateTime64(created_at, 3) AS r FROM task_runs"],
|
||||
[
|
||||
"toDateTime64OrNull",
|
||||
"SELECT toDateTime64OrNull('2024-01-01 00:00:00.000', 3) AS r FROM task_runs",
|
||||
],
|
||||
["toDateTime64OrZero", "SELECT toDateTime64OrZero('invalid', 3) AS r FROM task_runs"],
|
||||
["toTypeName", "SELECT toTypeName(status) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Date/time functions ──────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Date/time functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["now", "SELECT now() AS r FROM task_runs"],
|
||||
["now64", "SELECT now64() AS r FROM task_runs"],
|
||||
["today", "SELECT today() AS r FROM task_runs"],
|
||||
["yesterday", "SELECT yesterday() AS r FROM task_runs"],
|
||||
["toYear", "SELECT toYear(created_at) AS r FROM task_runs"],
|
||||
["toQuarter", "SELECT toQuarter(created_at) AS r FROM task_runs"],
|
||||
["toMonth", "SELECT toMonth(created_at) AS r FROM task_runs"],
|
||||
["toDayOfYear", "SELECT toDayOfYear(created_at) AS r FROM task_runs"],
|
||||
["toDayOfMonth", "SELECT toDayOfMonth(created_at) AS r FROM task_runs"],
|
||||
["toDayOfWeek", "SELECT toDayOfWeek(created_at) AS r FROM task_runs"],
|
||||
["toHour", "SELECT toHour(created_at) AS r FROM task_runs"],
|
||||
["toMinute", "SELECT toMinute(created_at) AS r FROM task_runs"],
|
||||
["toSecond", "SELECT toSecond(created_at) AS r FROM task_runs"],
|
||||
["toUnixTimestamp", "SELECT toUnixTimestamp(created_at) AS r FROM task_runs"],
|
||||
["toStartOfYear", "SELECT toStartOfYear(created_at) AS r FROM task_runs"],
|
||||
["toStartOfQuarter", "SELECT toStartOfQuarter(created_at) AS r FROM task_runs"],
|
||||
["toStartOfMonth", "SELECT toStartOfMonth(created_at) AS r FROM task_runs"],
|
||||
["toMonday", "SELECT toMonday(created_at) AS r FROM task_runs"],
|
||||
["toStartOfWeek", "SELECT toStartOfWeek(created_at) AS r FROM task_runs"],
|
||||
["toStartOfDay", "SELECT toStartOfDay(created_at) AS r FROM task_runs"],
|
||||
["toStartOfHour", "SELECT toStartOfHour(created_at) AS r FROM task_runs"],
|
||||
["toStartOfMinute", "SELECT toStartOfMinute(created_at) AS r FROM task_runs"],
|
||||
["toStartOfSecond", "SELECT toStartOfSecond(created_at) AS r FROM task_runs"],
|
||||
["toStartOfFiveMinutes", "SELECT toStartOfFiveMinutes(created_at) AS r FROM task_runs"],
|
||||
["toStartOfTenMinutes", "SELECT toStartOfTenMinutes(created_at) AS r FROM task_runs"],
|
||||
["toStartOfFifteenMinutes", "SELECT toStartOfFifteenMinutes(created_at) AS r FROM task_runs"],
|
||||
[
|
||||
"toStartOfInterval",
|
||||
"SELECT toStartOfInterval(created_at, INTERVAL 1 hour) AS r FROM task_runs",
|
||||
],
|
||||
["toTime", "SELECT toTime(created_at) AS r FROM task_runs"],
|
||||
["toISOYear", "SELECT toISOYear(created_at) AS r FROM task_runs"],
|
||||
["toISOWeek", "SELECT toISOWeek(created_at) AS r FROM task_runs"],
|
||||
["toWeek", "SELECT toWeek(created_at) AS r FROM task_runs"],
|
||||
["toYearWeek", "SELECT toYearWeek(created_at) AS r FROM task_runs"],
|
||||
["dateAdd (string unit)", "SELECT dateAdd('day', 7, created_at) AS r FROM task_runs"],
|
||||
["dateAdd (keyword unit)", "SELECT dateAdd(day, 7, created_at) AS r FROM task_runs"],
|
||||
["dateSub (string unit)", "SELECT dateSub('hour', 1, created_at) AS r FROM task_runs"],
|
||||
[
|
||||
"dateDiff (string unit)",
|
||||
"SELECT dateDiff('minute', created_at, updated_at) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"dateDiff (millisecond)",
|
||||
"SELECT dateDiff('millisecond', created_at, updated_at) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"dateDiff (microsecond)",
|
||||
"SELECT dateDiff('microsecond', created_at, updated_at) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"dateDiff (nanosecond)",
|
||||
"SELECT dateDiff('nanosecond', created_at, updated_at) AS r FROM task_runs",
|
||||
],
|
||||
["dateTrunc (string unit)", "SELECT dateTrunc('month', created_at) AS r FROM task_runs"],
|
||||
["date_add (string unit)", "SELECT date_add('day', 7, created_at) AS r FROM task_runs"],
|
||||
["date_sub (string unit)", "SELECT date_sub('hour', 1, created_at) AS r FROM task_runs"],
|
||||
[
|
||||
"date_diff (string unit)",
|
||||
"SELECT date_diff('minute', created_at, updated_at) AS r FROM task_runs",
|
||||
],
|
||||
["date_trunc (string unit)", "SELECT date_trunc('month', created_at) AS r FROM task_runs"],
|
||||
["addSeconds", "SELECT addSeconds(created_at, 10) AS r FROM task_runs"],
|
||||
["addMinutes", "SELECT addMinutes(created_at, 10) AS r FROM task_runs"],
|
||||
["addHours", "SELECT addHours(created_at, 1) AS r FROM task_runs"],
|
||||
["addDays", "SELECT addDays(created_at, 1) AS r FROM task_runs"],
|
||||
["addWeeks", "SELECT addWeeks(created_at, 1) AS r FROM task_runs"],
|
||||
["addMonths", "SELECT addMonths(created_at, 1) AS r FROM task_runs"],
|
||||
["addQuarters", "SELECT addQuarters(created_at, 1) AS r FROM task_runs"],
|
||||
["addYears", "SELECT addYears(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractSeconds", "SELECT subtractSeconds(created_at, 10) AS r FROM task_runs"],
|
||||
["subtractMinutes", "SELECT subtractMinutes(created_at, 10) AS r FROM task_runs"],
|
||||
["subtractHours", "SELECT subtractHours(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractDays", "SELECT subtractDays(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractWeeks", "SELECT subtractWeeks(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractMonths", "SELECT subtractMonths(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractQuarters", "SELECT subtractQuarters(created_at, 1) AS r FROM task_runs"],
|
||||
["subtractYears", "SELECT subtractYears(created_at, 1) AS r FROM task_runs"],
|
||||
["toTimeZone", "SELECT toTimeZone(created_at, 'America/New_York') AS r FROM task_runs"],
|
||||
["formatDateTime", "SELECT formatDateTime(created_at, '%Y-%m-%d') AS r FROM task_runs"],
|
||||
["parseDateTime", "SELECT parseDateTime('2024-01-15', '%Y-%m-%d') AS r FROM task_runs"],
|
||||
[
|
||||
"parseDateTimeBestEffort",
|
||||
"SELECT parseDateTimeBestEffort('2024-01-15 10:30:00') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"parseDateTimeBestEffortOrNull",
|
||||
"SELECT parseDateTimeBestEffortOrNull('invalid') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"parseDateTimeBestEffortOrZero",
|
||||
"SELECT parseDateTimeBestEffortOrZero('invalid') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"parseDateTime64BestEffort",
|
||||
"SELECT parseDateTime64BestEffort('2024-01-15 10:30:00.123') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"parseDateTime64BestEffortOrNull",
|
||||
"SELECT parseDateTime64BestEffortOrNull('invalid') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"parseDateTime64BestEffortOrZero",
|
||||
"SELECT parseDateTime64BestEffortOrZero('invalid') AS r FROM task_runs",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Interval functions ───────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Interval functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["toIntervalSecond", "SELECT toIntervalSecond(10) AS r FROM task_runs"],
|
||||
["toIntervalMinute", "SELECT toIntervalMinute(5) AS r FROM task_runs"],
|
||||
["toIntervalHour", "SELECT toIntervalHour(1) AS r FROM task_runs"],
|
||||
["toIntervalDay", "SELECT toIntervalDay(7) AS r FROM task_runs"],
|
||||
["toIntervalWeek", "SELECT toIntervalWeek(2) AS r FROM task_runs"],
|
||||
["toIntervalMonth", "SELECT toIntervalMonth(3) AS r FROM task_runs"],
|
||||
["toIntervalQuarter", "SELECT toIntervalQuarter(1) AS r FROM task_runs"],
|
||||
["toIntervalYear", "SELECT toIntervalYear(1) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Array functions ──────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Array functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["array", "SELECT array(1, 2, 3) AS r FROM task_runs"],
|
||||
["range", "SELECT range(5) AS r FROM task_runs"],
|
||||
["arrayElement", "SELECT arrayElement(tags, 1) AS r FROM task_runs"],
|
||||
["has", "SELECT has(tags, 'tag-a') AS r FROM task_runs"],
|
||||
["hasAll", "SELECT hasAll(tags, array('tag-a')) AS r FROM task_runs"],
|
||||
["hasAny", "SELECT hasAny(tags, array('tag-a', 'tag-c')) AS r FROM task_runs"],
|
||||
["hasSubstr", "SELECT hasSubstr(tags, array('tag-a')) AS r FROM task_runs"],
|
||||
["indexOf", "SELECT indexOf(tags, 'tag-a') AS r FROM task_runs"],
|
||||
["arrayCount", "SELECT arrayCount(array(1, 0, 1, 0)) AS r FROM task_runs"],
|
||||
["countEqual", "SELECT countEqual(tags, 'tag-a') AS r FROM task_runs"],
|
||||
["arrayEnumerate", "SELECT arrayEnumerate(tags) AS r FROM task_runs"],
|
||||
["arrayEnumerateDense", "SELECT arrayEnumerateDense(tags) AS r FROM task_runs"],
|
||||
["arrayEnumerateUniq", "SELECT arrayEnumerateUniq(tags) AS r FROM task_runs"],
|
||||
["arrayPopBack", "SELECT arrayPopBack(tags) AS r FROM task_runs"],
|
||||
["arrayPopFront", "SELECT arrayPopFront(tags) AS r FROM task_runs"],
|
||||
["arrayPushBack", "SELECT arrayPushBack(tags, 'tag-new') AS r FROM task_runs"],
|
||||
["arrayPushFront", "SELECT arrayPushFront(tags, 'tag-new') AS r FROM task_runs"],
|
||||
["arrayResize", "SELECT arrayResize(tags, 5, '') AS r FROM task_runs"],
|
||||
["arraySlice", "SELECT arraySlice(tags, 1, 1) AS r FROM task_runs"],
|
||||
["arraySort", "SELECT arraySort(tags) AS r FROM task_runs"],
|
||||
["arrayReverseSort", "SELECT arrayReverseSort(tags) AS r FROM task_runs"],
|
||||
["arrayShuffle", "SELECT arrayShuffle(tags) AS r FROM task_runs"],
|
||||
["arrayUniq", "SELECT arrayUniq(tags) AS r FROM task_runs"],
|
||||
["arrayDifference", "SELECT arrayDifference(array(1, 2, 5)) AS r FROM task_runs"],
|
||||
["arrayDistinct", "SELECT arrayDistinct(tags) AS r FROM task_runs"],
|
||||
["arrayIntersect", "SELECT arrayIntersect(tags, array('tag-a')) AS r FROM task_runs"],
|
||||
["arrayReduce", "SELECT arrayReduce('sum', array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arrayReverse", "SELECT arrayReverse(tags) AS r FROM task_runs"],
|
||||
["arrayFlatten", "SELECT arrayFlatten(array(array(1, 2), array(3))) AS r FROM task_runs"],
|
||||
["arrayCompact", "SELECT arrayCompact(array(1, 1, 2, 3, 3)) AS r FROM task_runs"],
|
||||
["arrayZip", "SELECT arrayZip(array(1, 2), array('a', 'b')) AS r FROM task_runs"],
|
||||
["arrayMin", "SELECT arrayMin(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arrayMax", "SELECT arrayMax(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arraySum", "SELECT arraySum(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arrayAvg", "SELECT arrayAvg(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arrayCumSum", "SELECT arrayCumSum(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
[
|
||||
"arrayCumSumNonNegative",
|
||||
"SELECT arrayCumSumNonNegative(array(1, -2, 3)) AS r FROM task_runs",
|
||||
],
|
||||
["arrayProduct", "SELECT arrayProduct(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
["arrayJoin", "SELECT arrayJoin(array(1, 2, 3)) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── JSON functions ───────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("JSON functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["JSONHas", `SELECT JSONHas('{"a": 1}', 'a') AS r FROM task_runs`],
|
||||
["JSONLength", `SELECT JSONLength('{"a": 1, "b": 2}') AS r FROM task_runs`],
|
||||
["JSONType", `SELECT JSONType('{"a": 1}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractUInt", `SELECT JSONExtractUInt('{"a": 1}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractInt", `SELECT JSONExtractInt('{"a": -1}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractFloat", `SELECT JSONExtractFloat('{"a": 1.5}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractBool", `SELECT JSONExtractBool('{"a": true}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractString", `SELECT JSONExtractString('{"a": "hello"}', 'a') AS r FROM task_runs`],
|
||||
["JSONExtractRaw", `SELECT JSONExtractRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`],
|
||||
[
|
||||
"JSONExtractArrayRaw",
|
||||
`SELECT JSONExtractArrayRaw('{"a": [1,2]}', 'a') AS r FROM task_runs`,
|
||||
],
|
||||
["JSONExtractKeys", `SELECT JSONExtractKeys('{"a": 1, "b": 2}') AS r FROM task_runs`],
|
||||
["toJSONString", "SELECT toJSONString(map('a', 1)) AS r FROM task_runs"],
|
||||
// The `output` column is a native JSON type, so JSON functions must read its
|
||||
// String companion (output_text). Without the printer fix these fail with
|
||||
// "should be a string containing JSON, illegal type: JSON".
|
||||
["JSONHas(output)", "SELECT JSONHas(output, 'count') AS r FROM task_runs"],
|
||||
["JSONLength(output)", "SELECT JSONLength(output) AS r FROM task_runs"],
|
||||
["JSONType(output)", "SELECT JSONType(output, 'count') AS r FROM task_runs"],
|
||||
["JSONExtractInt(output)", "SELECT JSONExtractInt(output, 'count') AS r FROM task_runs"],
|
||||
["JSONExtractUInt(output)", "SELECT JSONExtractUInt(output, 'count') AS r FROM task_runs"],
|
||||
["JSONExtractFloat(output)", "SELECT JSONExtractFloat(output, 'ratio') AS r FROM task_runs"],
|
||||
["JSONExtractBool(output)", "SELECT JSONExtractBool(output, 'enabled') AS r FROM task_runs"],
|
||||
[
|
||||
"JSONExtractString(output)",
|
||||
"SELECT JSONExtractString(output, 'label') AS r FROM task_runs",
|
||||
],
|
||||
["JSONExtractRaw(output)", "SELECT JSONExtractRaw(output, 'count') AS r FROM task_runs"],
|
||||
["JSONExtractKeys(output)", "SELECT JSONExtractKeys(output) AS r FROM task_runs"],
|
||||
// The JSON field can be wrapped in a passthrough like assumeNotNull; the swap
|
||||
// still has to reach the native column underneath.
|
||||
[
|
||||
"JSONExtractArrayRaw(assumeNotNull(output))",
|
||||
"SELECT JSONExtractArrayRaw(assumeNotNull(output), 'items') AS r FROM task_runs",
|
||||
],
|
||||
// toJSONString(output) is already a String, so it stays on the native column.
|
||||
[
|
||||
"JSONExtractString(toJSONString(output))",
|
||||
"SELECT JSONExtractString(toJSONString(output), 'data', 'label') AS r FROM task_runs",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Tuple functions ──────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Tuple functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["tuple", "SELECT tuple(1, 'a', 3.14) AS r FROM task_runs"],
|
||||
["tupleElement", "SELECT tupleElement(tuple(1, 'a'), 1) AS r FROM task_runs"],
|
||||
["untuple", "SELECT untuple(tuple(1, 'a')) FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Map functions ────────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Map functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["map", "SELECT map('a', 1, 'b', 2) AS r FROM task_runs"],
|
||||
["mapFromArrays", "SELECT mapFromArrays(array('a', 'b'), array(1, 2)) AS r FROM task_runs"],
|
||||
["mapContains", "SELECT mapContains(map('a', 1), 'a') AS r FROM task_runs"],
|
||||
["mapKeys", "SELECT mapKeys(map('a', 1, 'b', 2)) AS r FROM task_runs"],
|
||||
["mapValues", "SELECT mapValues(map('a', 1, 'b', 2)) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Hash functions ───────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Hash functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["MD5", "SELECT hex(MD5('hello')) AS r FROM task_runs"],
|
||||
["SHA1", "SELECT hex(SHA1('hello')) AS r FROM task_runs"],
|
||||
["SHA224", "SELECT hex(SHA224('hello')) AS r FROM task_runs"],
|
||||
["SHA256", "SELECT hex(SHA256('hello')) AS r FROM task_runs"],
|
||||
["SHA384", "SELECT hex(SHA384('hello')) AS r FROM task_runs"],
|
||||
["SHA512", "SELECT hex(SHA512('hello')) AS r FROM task_runs"],
|
||||
["sipHash64", "SELECT sipHash64('hello') AS r FROM task_runs"],
|
||||
["sipHash128", "SELECT hex(sipHash128('hello')) AS r FROM task_runs"],
|
||||
["cityHash64", "SELECT cityHash64('hello') AS r FROM task_runs"],
|
||||
["intHash32", "SELECT intHash32(42) AS r FROM task_runs"],
|
||||
["intHash64", "SELECT intHash64(42) AS r FROM task_runs"],
|
||||
["farmHash64", "SELECT farmHash64('hello') AS r FROM task_runs"],
|
||||
["farmFingerprint64", "SELECT farmFingerprint64('hello') AS r FROM task_runs"],
|
||||
["xxHash32", "SELECT xxHash32('hello') AS r FROM task_runs"],
|
||||
["xxHash64", "SELECT xxHash64('hello') AS r FROM task_runs"],
|
||||
["murmurHash2_32", "SELECT murmurHash2_32('hello') AS r FROM task_runs"],
|
||||
["murmurHash2_64", "SELECT murmurHash2_64('hello') AS r FROM task_runs"],
|
||||
["murmurHash3_32", "SELECT murmurHash3_32('hello') AS r FROM task_runs"],
|
||||
["murmurHash3_64", "SELECT murmurHash3_64('hello') AS r FROM task_runs"],
|
||||
["murmurHash3_128", "SELECT hex(murmurHash3_128('hello')) AS r FROM task_runs"],
|
||||
["hex", "SELECT hex(255) AS r FROM task_runs"],
|
||||
["unhex", "SELECT unhex('48656C6C6F') AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── URL functions ────────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("URL functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["protocol", `SELECT protocol('${url}') AS r FROM task_runs`],
|
||||
["domain", `SELECT domain('${url}') AS r FROM task_runs`],
|
||||
["domainWithoutWWW", `SELECT domainWithoutWWW('${url}') AS r FROM task_runs`],
|
||||
["topLevelDomain", `SELECT topLevelDomain('${url}') AS r FROM task_runs`],
|
||||
[
|
||||
"firstSignificantSubdomain",
|
||||
`SELECT firstSignificantSubdomain('${url}') AS r FROM task_runs`,
|
||||
],
|
||||
[
|
||||
"cutToFirstSignificantSubdomain",
|
||||
`SELECT cutToFirstSignificantSubdomain('${url}') AS r FROM task_runs`,
|
||||
],
|
||||
[
|
||||
"cutToFirstSignificantSubdomainWithWWW",
|
||||
`SELECT cutToFirstSignificantSubdomainWithWWW('${url}') AS r FROM task_runs`,
|
||||
],
|
||||
["port", `SELECT port('${url}') AS r FROM task_runs`],
|
||||
["path", `SELECT path('${url}') AS r FROM task_runs`],
|
||||
["pathFull", `SELECT pathFull('${url}') AS r FROM task_runs`],
|
||||
["queryString", `SELECT queryString('${url}') AS r FROM task_runs`],
|
||||
["fragment", `SELECT fragment('${url}') AS r FROM task_runs`],
|
||||
["extractURLParameter", `SELECT extractURLParameter('${url}', 'q') AS r FROM task_runs`],
|
||||
["extractURLParameters", `SELECT extractURLParameters('${url}') AS r FROM task_runs`],
|
||||
["encodeURLComponent", "SELECT encodeURLComponent('hello world') AS r FROM task_runs"],
|
||||
["decodeURLComponent", "SELECT decodeURLComponent('hello%20world') AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── UUID functions ───────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("UUID functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["generateUUIDv4", "SELECT generateUUIDv4() AS r FROM task_runs"],
|
||||
[
|
||||
"UUIDStringToNum",
|
||||
"SELECT UUIDStringToNum('00000000-0000-0000-0000-000000000000') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"UUIDNumToString",
|
||||
"SELECT UUIDNumToString(UUIDStringToNum('00000000-0000-0000-0000-000000000000')) AS r FROM task_runs",
|
||||
],
|
||||
["toUUID", "SELECT toUUID('00000000-0000-0000-0000-000000000000') AS r FROM task_runs"],
|
||||
["toUUIDOrNull", "SELECT toUUIDOrNull('not-a-uuid') AS r FROM task_runs"],
|
||||
["toUUIDOrZero", "SELECT toUUIDOrZero('not-a-uuid') AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Misc functions ───────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Misc functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["isFinite", "SELECT isFinite(1.0) AS r FROM task_runs"],
|
||||
["isInfinite", "SELECT isInfinite(1.0 / 0) AS r FROM task_runs"],
|
||||
["ifNotFinite", "SELECT ifNotFinite(1.0 / 0, 0) AS r FROM task_runs"],
|
||||
["isNaN", "SELECT isNaN(0.0 / 0) AS r FROM task_runs"],
|
||||
["bar", "SELECT bar(usage_duration_ms, 0, 10000, 20) AS r FROM task_runs"],
|
||||
[
|
||||
"transform",
|
||||
"SELECT transform(status, array('PENDING', 'COMPLETED_SUCCESSFULLY'), array('P', 'C'), 'X') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"formatReadableDecimalSize",
|
||||
"SELECT formatReadableDecimalSize(1000000) AS r FROM task_runs",
|
||||
],
|
||||
["formatReadableSize", "SELECT formatReadableSize(1000000) AS r FROM task_runs"],
|
||||
["formatReadableQuantity", "SELECT formatReadableQuantity(1000000) AS r FROM task_runs"],
|
||||
["formatReadableTimeDelta", "SELECT formatReadableTimeDelta(3661) AS r FROM task_runs"],
|
||||
["least", "SELECT least(1, 2) AS r FROM task_runs"],
|
||||
["greatest", "SELECT greatest(1, 2) AS r FROM task_runs"],
|
||||
["min2", "SELECT min2(1, 2) AS r FROM task_runs"],
|
||||
["max2", "SELECT max2(1, 2) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Aggregate functions ──────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Aggregate functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["count()", "SELECT count() AS r FROM task_runs"],
|
||||
["count(col)", "SELECT count(run_id) AS r FROM task_runs"],
|
||||
["countDistinct", "SELECT countDistinct(status) AS r FROM task_runs"],
|
||||
["min", "SELECT min(usage_duration_ms) AS r FROM task_runs"],
|
||||
["max", "SELECT max(usage_duration_ms) AS r FROM task_runs"],
|
||||
["sum", "SELECT sum(usage_duration_ms) AS r FROM task_runs"],
|
||||
["avg", "SELECT avg(usage_duration_ms) AS r FROM task_runs"],
|
||||
["any", "SELECT any(status) AS r FROM task_runs"],
|
||||
["anyLast", "SELECT anyLast(status) AS r FROM task_runs"],
|
||||
["anyHeavy", "SELECT anyHeavy(status) AS r FROM task_runs"],
|
||||
["argMin", "SELECT argMin(run_id, usage_duration_ms) AS r FROM task_runs"],
|
||||
["argMax", "SELECT argMax(run_id, usage_duration_ms) AS r FROM task_runs"],
|
||||
["stddevPop", "SELECT stddevPop(usage_duration_ms) AS r FROM task_runs"],
|
||||
["stddevSamp", "SELECT stddevSamp(usage_duration_ms) AS r FROM task_runs"],
|
||||
["varPop", "SELECT varPop(usage_duration_ms) AS r FROM task_runs"],
|
||||
["varSamp", "SELECT varSamp(usage_duration_ms) AS r FROM task_runs"],
|
||||
["covarPop", "SELECT covarPop(usage_duration_ms, cost_in_cents) AS r FROM task_runs"],
|
||||
["covarSamp", "SELECT covarSamp(usage_duration_ms, cost_in_cents) AS r FROM task_runs"],
|
||||
["corr", "SELECT corr(usage_duration_ms, cost_in_cents) AS r FROM task_runs"],
|
||||
["groupArray", "SELECT groupArray(status) AS r FROM task_runs"],
|
||||
["groupUniqArray", "SELECT groupUniqArray(status) AS r FROM task_runs"],
|
||||
["groupArrayMovingAvg", "SELECT groupArrayMovingAvg(usage_duration_ms) AS r FROM task_runs"],
|
||||
["groupArrayMovingSum", "SELECT groupArrayMovingSum(usage_duration_ms) AS r FROM task_runs"],
|
||||
["uniq", "SELECT uniq(status) AS r FROM task_runs"],
|
||||
["uniqExact", "SELECT uniqExact(status) AS r FROM task_runs"],
|
||||
["uniqHLL12", "SELECT uniqHLL12(status) AS r FROM task_runs"],
|
||||
["uniqTheta", "SELECT uniqTheta(status) AS r FROM task_runs"],
|
||||
["median", "SELECT median(usage_duration_ms) AS r FROM task_runs"],
|
||||
["medianExact", "SELECT medianExact(usage_duration_ms) AS r FROM task_runs"],
|
||||
["quantile", "SELECT quantile(0.95)(usage_duration_ms) AS r FROM task_runs"],
|
||||
["quantiles", "SELECT quantiles(0.5, 0.9, 0.99)(usage_duration_ms) AS r FROM task_runs"],
|
||||
["topK", "SELECT topK(3)(status) AS r FROM task_runs"],
|
||||
[
|
||||
"simpleLinearRegression",
|
||||
"SELECT simpleLinearRegression(usage_duration_ms, cost_in_cents) AS r FROM task_runs",
|
||||
],
|
||||
["groupArraySample", "SELECT groupArraySample(2)(status) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Conditional aggregate functions ──────────────────────────────────────
|
||||
|
||||
clickhouseTest("Conditional aggregate functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
["countIf", "SELECT countIf(usage_duration_ms > 1000) AS r FROM task_runs"],
|
||||
[
|
||||
"countDistinctIf",
|
||||
"SELECT countDistinctIf(status, usage_duration_ms > 0) AS r FROM task_runs",
|
||||
],
|
||||
["minIf", "SELECT minIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["maxIf", "SELECT maxIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["sumIf", "SELECT sumIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["avgIf", "SELECT avgIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["anyIf", "SELECT anyIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
["anyLastIf", "SELECT anyLastIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
["anyHeavyIf", "SELECT anyHeavyIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
["groupArrayIf", "SELECT groupArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
[
|
||||
"groupUniqArrayIf",
|
||||
"SELECT groupUniqArrayIf(status, usage_duration_ms > 0) AS r FROM task_runs",
|
||||
],
|
||||
["uniqIf", "SELECT uniqIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
["uniqExactIf", "SELECT uniqExactIf(status, usage_duration_ms > 0) AS r FROM task_runs"],
|
||||
["medianIf", "SELECT medianIf(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["quantileIf", "SELECT quantileIf(0.95)(usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["argMinIf", "SELECT argMinIf(run_id, usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
["argMaxIf", "SELECT argMaxIf(run_id, usage_duration_ms, is_test = 0) AS r FROM task_runs"],
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Search functions ─────────────────────────────────────────────────────
|
||||
|
||||
clickhouseTest("Search functions", async ({ clickhouseContainer }) => {
|
||||
const client = await setupClient(clickhouseContainer);
|
||||
await runCases(client, [
|
||||
[
|
||||
"multiMatchAny",
|
||||
"SELECT multiMatchAny(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"multiMatchAnyIndex",
|
||||
"SELECT multiMatchAnyIndex(status, array('COMPLETED.*', 'PENDING')) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"multiMatchAllIndices",
|
||||
"SELECT multiMatchAllIndices(status, array('COMPLETED.*', 'PEND.*')) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"multiSearchFirstPosition",
|
||||
"SELECT multiSearchFirstPosition(status, array('COMP', 'PEND')) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"multiSearchFirstIndex",
|
||||
"SELECT multiSearchFirstIndex(status, array('COMP', 'PEND')) AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"multiSearchAny",
|
||||
"SELECT multiSearchAny(status, array('COMP', 'PEND')) AS r FROM task_runs",
|
||||
],
|
||||
["extract", "SELECT extract(status, '[A-Z]+') AS r FROM task_runs"],
|
||||
["extractAll", "SELECT extractAll(status, '[A-Z]+') AS r FROM task_runs"],
|
||||
[
|
||||
"extractAllGroupsHorizontal",
|
||||
"SELECT extractAllGroupsHorizontal(status, '([A-Z]+)') AS r FROM task_runs",
|
||||
],
|
||||
[
|
||||
"extractAllGroupsVertical",
|
||||
"SELECT extractAllGroupsVertical(status, '([A-Z]+)') AS r FROM task_runs",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Node16",
|
||||
"module": "Node16",
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["src/**/*.test.ts", "vitest.config.ts"],
|
||||
"references": [{ "path": "./tsconfig.src.json" }],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vitest/globals"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { DurationShardingSequencer } from "@internal/testcontainers/sequencer";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
sequence: { sequencer: DurationShardingSequencer },
|
||||
include: ["**/*.test.ts"],
|
||||
globals: true,
|
||||
isolate: true,
|
||||
fileParallelism: false,
|
||||
testTimeout: 60_000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user