Update queue metrics to 5-second resolution, add ABR analysis

- Change primary aggregation tier from per-minute to per-5-second
  buckets for near-real-time dashboard resolution
- Add 3-tier aggregation: 5s (2-day TTL) → 1m (31-day) → 1h (400-day)
- Add ABR-inspired query routing section explaining why Cloudflare's
  sampling approach isn't needed for low-cardinality queue metrics but
  the adaptive resolution selection spirit applies
- Update alert evaluator to run every 30s querying the 5s table
- Update API shape to support resolution=5s

https://claude.ai/code/session_01PkVYeZ8tQzgcT4YQsctGo1
This commit is contained in:
Claude
2026-02-07 08:57:24 +00:00
parent 1872394501
commit 7c748e0223
+137 -43
View File
@@ -184,10 +184,12 @@ TTL timestamp + INTERVAL 8 DAY;
- MergeTree has the best insert performance and simplest query semantics.
- TTL of 8 days keeps raw data manageable; aggregated data lives longer.
### 3.2 Minute-level aggregation (materialized view)
### 3.2 5-second aggregation (primary tier, materialized view)
5-second buckets are the finest aggregation tier — they provide near-real-time resolution for dashboards while dramatically reducing row count vs. querying raw data. ClickHouse's `toStartOfFiveSeconds()` aligns naturally to 5s boundaries.
```sql
CREATE TABLE trigger_dev.queue_metrics_by_minute_v1
CREATE TABLE trigger_dev.queue_metrics_5s_v1
(
organization_id String,
project_id String,
@@ -203,15 +205,78 @@ CREATE TABLE trigger_dev.queue_metrics_by_minute_v1
dlq_count UInt64,
ttl_expire_count UInt64,
-- Gauges (use AggregateFunction for proper max/avg)
-- Gauges (use SimpleAggregateFunction for proper max)
max_queue_length SimpleAggregateFunction(max, UInt32),
max_concurrency_current SimpleAggregateFunction(max, UInt32),
max_env_queue_length SimpleAggregateFunction(max, UInt32),
max_env_concurrency SimpleAggregateFunction(max, UInt32),
max_oldest_message_age_ms SimpleAggregateFunction(max, UInt64),
-- For computing averages at query time
total_wait_duration_ms UInt64,
wait_duration_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(bucket_start)
ORDER BY (organization_id, project_id, environment_id, queue_name, bucket_start)
TTL bucket_start + INTERVAL 2 DAY;
CREATE MATERIALIZED VIEW trigger_dev.queue_metrics_5s_mv_v1
TO trigger_dev.queue_metrics_5s_v1 AS
SELECT
organization_id,
project_id,
environment_id,
queue_name,
toStartOfFiveSeconds(timestamp) AS bucket_start,
sum(enqueue_count) AS enqueue_count,
sum(dequeue_count) AS dequeue_count,
sum(ack_count) AS ack_count,
sum(nack_count) AS nack_count,
sum(dlq_count) AS dlq_count,
sum(ttl_expire_count) AS ttl_expire_count,
max(queue_length) AS max_queue_length,
max(concurrency_current) AS max_concurrency_current,
max(env_queue_length) AS max_env_queue_length,
max(env_concurrency) AS max_env_concurrency,
max(oldest_message_age_ms) AS max_oldest_message_age_ms,
sum(wait_duration_ms) AS total_wait_duration_ms,
countIf(wait_duration_ms > 0) AS wait_duration_count
FROM trigger_dev.raw_queue_metrics_v1
GROUP BY organization_id, project_id, environment_id, queue_name, bucket_start;
```
**Why 5-second buckets with 2-day TTL?**
- 5s gives 12 data points per minute — smooth enough for real-time graphs, coarse enough to keep row counts manageable
- At 100 queues, that's 100 × 17,280 buckets/day = ~1.7M rows/day — trivial for ClickHouse
- 2-day TTL is sufficient since this tier is for real-time/recent dashboards; minute and hour tiers cover longer windows
### 3.3 Minute-level aggregation (middle tier)
Rolls up from the 5-second table. Used for 1-hour to 7-day dashboard views and alert evaluation.
```sql
CREATE TABLE trigger_dev.queue_metrics_by_minute_v1
(
organization_id String,
project_id String,
environment_id String,
queue_name String,
bucket_start DateTime,
enqueue_count UInt64,
dequeue_count UInt64,
ack_count UInt64,
nack_count UInt64,
dlq_count UInt64,
ttl_expire_count UInt64,
max_queue_length SimpleAggregateFunction(max, UInt32),
max_concurrency_current SimpleAggregateFunction(max, UInt32),
max_env_queue_length SimpleAggregateFunction(max, UInt32),
max_env_concurrency SimpleAggregateFunction(max, UInt32),
max_oldest_message_age_ms SimpleAggregateFunction(max, UInt64),
avg_wait_duration_ms AggregateFunction(avg, UInt64),
-- For computing averages
total_wait_duration_ms UInt64,
wait_duration_count UInt64
)
@@ -227,26 +292,27 @@ SELECT
project_id,
environment_id,
queue_name,
toStartOfMinute(timestamp) AS bucket_start,
toStartOfMinute(bucket_start) AS bucket_start,
sum(enqueue_count) AS enqueue_count,
sum(dequeue_count) AS dequeue_count,
sum(ack_count) AS ack_count,
sum(nack_count) AS nack_count,
sum(dlq_count) AS dlq_count,
sum(ttl_expire_count) AS ttl_expire_count,
max(queue_length) AS max_queue_length,
max(concurrency_current) AS max_concurrency_current,
max(env_queue_length) AS max_env_queue_length,
max(env_concurrency) AS max_env_concurrency,
max(oldest_message_age_ms) AS max_oldest_message_age_ms,
avgState(wait_duration_ms) AS avg_wait_duration_ms,
sum(wait_duration_ms) AS total_wait_duration_ms,
countIf(wait_duration_ms > 0) AS wait_duration_count
FROM trigger_dev.raw_queue_metrics_v1
max(max_queue_length) AS max_queue_length,
max(max_concurrency_current) AS max_concurrency_current,
max(max_env_queue_length) AS max_env_queue_length,
max(max_env_concurrency) AS max_env_concurrency,
max(max_oldest_message_age_ms) AS max_oldest_message_age_ms,
sum(total_wait_duration_ms) AS total_wait_duration_ms,
sum(wait_duration_count) AS wait_duration_count
FROM trigger_dev.queue_metrics_5s_v1
GROUP BY organization_id, project_id, environment_id, queue_name, bucket_start;
```
### 3.3 Hour-level aggregation
### 3.4 Hour-level aggregation (long-term tier)
Rolls up from minute table. Used for 7-day+ views and long-term trends.
```sql
CREATE TABLE trigger_dev.queue_metrics_by_hour_v1
@@ -303,6 +369,31 @@ FROM trigger_dev.queue_metrics_by_minute_v1
GROUP BY organization_id, project_id, environment_id, queue_name, bucket_start;
```
### 3.5 Query routing by time range (ABR-inspired)
The query routing here borrows the core idea from [Cloudflare's ABR (Adaptive Bit Rate) analytics](https://blog.cloudflare.com/explaining-cloudflares-abr-analytics/): automatically select the best resolution table for each query based on the requested time range, so dashboards stay fast regardless of how far back the user looks.
**Why full ABR (sampling) isn't needed here**: Cloudflare's ABR stores the *same raw events* at decreasing sample rates (100%, 10%, 1%, 0.01%) across parallel tables and multiplies counts by the sample interval at query time. This is designed for extremely high-cardinality data (billions of unique IP/URL/rule combinations per day) where pre-aggregation is impractical because you don't know what dimensions the user will GROUP BY.
Queue metrics are fundamentally different:
- **Low cardinality**: only ~4 dimensions (org, project, env, queue_name)
- **Fixed aggregations**: we always want the same sums/maxes — no ad-hoc GROUP BY on arbitrary fields
- **Modest volume**: even at 1000 ops/sec, the 5s table produces only ~1.7M rows/day per 100 queues
For this workload, **tiered materialized views** (5s → 1m → 1h) are simpler and give deterministic query performance without the complexity of sample-interval arithmetic or managing 7 parallel tables. We get the *spirit* of ABR — adaptive resolution selection — via table routing:
| Requested Period | Resolution | Table | Max Data Points |
|-----------------|------------|-------|-----------------|
| Last 30 minutes | 5s | `queue_metrics_5s_v1` | 360 |
| Last 2 hours | 5s | `queue_metrics_5s_v1` | 1,440 |
| Last 24 hours | 1m | `queue_metrics_by_minute_v1` | 1,440 |
| Last 7 days | 1m | `queue_metrics_by_minute_v1` | 10,080 |
| Last 30+ days | 1h | `queue_metrics_by_hour_v1` | 720 |
**If queue metrics volume grows significantly** (e.g., thousands of queues across many environments), ABR-style sampling at the raw table level would become worthwhile — particularly storing a 10% sampled version of `raw_queue_metrics_v1` to handle longer-range queries on the raw data. But for the initial system, tiered MVs are the right call.
The presenter can also downsample at query time (e.g., `GROUP BY toStartOfMinute(bucket_start)` on the 5s table) for periods between 2h-24h where you want fewer data points but higher fidelity than the minute table.
---
## 4. What Metrics Happen in Lua vs. Node.js
@@ -387,33 +478,34 @@ These are the new user-facing metrics enabled by this system:
| Metric | Description | Query Source | User Value |
|--------|-------------|-------------|------------|
| **Throughput** | Enqueues/s, dequeues/s, completions/s | `sum(enqueue_count)` over time from minute table | "How busy is my queue?" |
| **Queue depth over time** | Historical queue length graph | `max(max_queue_length)` from minute table | "Is my queue growing or draining?" |
| **Wait time (queue latency)** | Time from enqueue to dequeue | `total_wait_duration_ms / wait_duration_count` from minute table | "How long do my tasks wait before starting?" — the most important user metric |
| **Oldest message age** | How stale the oldest waiting run is | `max(max_oldest_message_age_ms)` from minute table | "Is something stuck?" |
| **Throughput** | Enqueues/s, dequeues/s, completions/s | `sum(enqueue_count) / 5` over time from 5s table | "How busy is my queue?" |
| **Queue depth over time** | Historical queue length graph | `max(max_queue_length)` from 5s table | "Is my queue growing or draining?" |
| **Wait time (queue latency)** | Time from enqueue to dequeue | `total_wait_duration_ms / wait_duration_count` from 5s table | "How long do my tasks wait before starting?" — the most important user metric |
| **Oldest message age** | How stale the oldest waiting run is | `max(max_oldest_message_age_ms)` from 5s table | "Is something stuck?" |
| **Concurrency utilization over time** | Historical concurrency usage | `max(max_concurrency_current) / max(concurrency_limit)` | "Should I increase my concurrency limit?" |
| **Failure rate** | Nacks + DLQ moves per minute | `sum(nack_count + dlq_count) / sum(dequeue_count)` | "Are my tasks failing?" |
| **Failure rate** | Nacks + DLQ per 5s bucket | `sum(nack_count + dlq_count) / sum(dequeue_count)` | "Are my tasks failing?" |
| **TTL expiration rate** | Runs expiring before execution | `sum(ttl_expire_count)` over time | "Am I losing work to TTLs?" |
| **Environment-level totals** | Aggregate of all queues | Filtered by `environment_id`, grouped by time | "Overall environment health" |
### 5.3 Recommended API shape
```typescript
// GET /api/v1/queues/:queueParam/metrics?period=1h&resolution=1m
// GET /api/v1/queues/:queueParam/metrics?period=30m&resolution=5s
// resolution: "5s" | "1m" | "1h" (auto-selected if omitted based on period)
{
queue: "my-queue",
period: { start: "2025-01-01T00:00:00Z", end: "2025-01-01T01:00:00Z" },
resolution: "1m",
period: { start: "2025-01-01T00:00:00Z", end: "2025-01-01T00:30:00Z" },
resolution: "5s",
timeseries: [
{
timestamp: "2025-01-01T00:00:00Z",
throughput: { enqueued: 42, dequeued: 38, completed: 35 },
queue_depth: { max: 120, current: 95 },
throughput: { enqueued: 3, dequeued: 2, completed: 2 },
queue_depth: { max: 120 },
latency: { avg_wait_ms: 1523, max_age_ms: 8200 },
concurrency: { current: 8, limit: 10, utilization_pct: 80 },
failures: { nack: 2, dlq: 0, ttl_expired: 1 }
concurrency: { max: 8, limit: 10, utilization_pct: 80 },
failures: { nack: 0, dlq: 0, ttl_expired: 0 }
},
// ... one entry per minute
// ... one entry per 5 seconds (360 data points for 30 min)
]
}
```
@@ -488,7 +580,7 @@ Add migration `016_add_queue_metrics.sql` with the tables and materialized views
### Phase 4: API and presenters
- New `QueueMetricsPresenter` that queries the minute/hour tables
- New `QueueMetricsPresenter` that queries the 5s/minute/hour tables (auto-selects based on time range)
- New API endpoint `GET /api/v1/queues/:queueParam/metrics`
- Environment-level metrics endpoint `GET /api/v1/environments/:envId/queue-metrics`
@@ -503,8 +595,8 @@ The alerting system should **not** be part of the stream consumer pipeline. Inst
```
┌─────────────────────────────────────┐
│ QueueAlertEvaluator (cron job) │
│ - Runs every 60s via redis-worker │
│ - Queries queue_metrics_by_minute
│ - Runs every 30s via redis-worker │
│ - Queries queue_metrics_5s / _min │
│ - Evaluates alert rules │
│ - Creates ProjectAlert records │
└─────────────────────────────────────┘
@@ -513,7 +605,7 @@ The alerting system should **not** be part of the stream consumer pipeline. Inst
### Why separate from the consumer?
1. **Decoupled failure domains**: Alert evaluation failing shouldn't affect metric ingestion
2. **Different cadence**: Metrics are ingested every second; alerts are evaluated every minute
2. **Different cadence**: Metrics are ingested every second; alerts are evaluated every 30s
3. **Query flexibility**: Alert conditions can use complex ClickHouse aggregations across multiple minutes
4. **Reuses existing infrastructure**: The existing `ProjectAlert` + `ProjectAlertChannel` + `DeliverAlertService` system handles delivery via Slack/Email/Webhook
@@ -573,16 +665,16 @@ model QueueAlertRule {
### Alert evaluation flow
```
1. QueueAlertEvaluator runs every 60s (via redis-worker cron)
1. QueueAlertEvaluator runs every 30s (via redis-worker cron)
2. Fetch all enabled QueueAlertRules
3. For each rule, query ClickHouse:
- BACKLOG: SELECT max(max_queue_length) FROM queue_metrics_by_minute_v1
WHERE timestamp > now() - interval {windowMinutes} minute
3. For each rule, query ClickHouse (uses 5s table for windows <= 2 hours, minute table otherwise):
- BACKLOG: SELECT max(max_queue_length) FROM queue_metrics_5s_v1
WHERE bucket_start > now() - interval {windowSeconds} second
AND queue_name = {queueName}
- LATENCY: SELECT max(total_wait_duration_ms / wait_duration_count)
FROM queue_metrics_by_minute_v1 WHERE ...
- LATENCY: SELECT sum(total_wait_duration_ms) / sum(wait_duration_count)
FROM queue_metrics_5s_v1 WHERE ...
- ERROR_RATE: SELECT sum(nack_count + dlq_count) / sum(dequeue_count)
FROM queue_metrics_by_minute_v1 WHERE ...
FROM queue_metrics_5s_v1 WHERE ...
4. If threshold exceeded AND cooldown expired:
a. Create ProjectAlert record
b. Enqueue DeliverAlertService for each configured channel
@@ -626,8 +718,9 @@ With MAXLEN ~100000 per shard and 2 shards:
- Raw table: ~8 days retention, auto-pruned by TTL
- At 1000 ops/sec, that's ~86M rows/day → ~690M rows in 8 days
- With ZSTD compression and LowCardinality, expect ~10-20 bytes per row on disk → **~7-14GB** for raw data
- Minute aggregation: 1440 rows/day/queue → negligible
- Hour aggregation: 24 rows/day/queue → negligible
- 5-second aggregation: 17,280 rows/day/queue, 2-day TTL → very small footprint
- Minute aggregation: 1,440 rows/day/queue, 31-day TTL → negligible
- Hour aggregation: 24 rows/day/queue, 400-day TTL → negligible
### Consumer resource usage
@@ -681,5 +774,6 @@ The key invariant: **queue operations (enqueue/dequeue/ack) are never blocked or
| XADD in Lua (inline) | Emit from Node.js after Lua returns | Lua gives atomic snapshot of queue state at exact moment of operation. Node.js would need separate Redis calls and introduce race conditions. |
| Auto-generated stream IDs | Custom second+queue IDs | Avoids silent data loss from collisions. Redis auto-IDs are monotonic and unique. |
| Separate alert evaluator | Alert in consumer pipeline | Decoupled failure domains, simpler consumer logic, richer query capabilities. |
| 8-day raw TTL, 31-day minute, 400-day hour | Longer raw retention | Matches existing task_events pattern. Raw data is voluminous; aggregations are compact. |
| 3-tier aggregation: 5s → 1m → 1h | Only minute + hour | 5s gives near-real-time dashboard resolution; minute and hour provide cost-effective longer-term storage. |
| 8-day raw, 2-day 5s, 31-day minute, 400-day hour TTLs | Longer retention | Tiered retention balances storage cost vs. query needs. Raw and 5s are ephemeral; minute and hour are durable. |
| Sharded streams | Single stream | Matches existing queue shard architecture. Enables horizontal scaling. |