feat: entitlements based on clickhouse direct - #21129
Conversation
d7ecf0b to
629434c
Compare
629434c to
b2b7f70
Compare
| recent AS | ||
| ( | ||
| SELECT subject, min(event_time) AS first_event | ||
| FROM ${EVENTS_TABLE} | ||
| WHERE client_id = {clientId:String} | ||
| AND slug = {slug:String} | ||
| AND ingested_at >= {ingestedSince:DateTime64(3, 'UTC')} | ||
| AND event_time >= {eventTimeFloor:DateTime64(3, 'UTC')} | ||
| AND event_time < {to:DateTime64(3, 'UTC')} | ||
| GROUP BY subject | ||
| ) | ||
| SELECT | ||
| r.subject AS subject, | ||
| formatDateTime(r.first_event, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS started_at | ||
| FROM recent AS r | ||
| LEFT JOIN last_session AS l ON r.subject = l.subject | ||
| WHERE r.first_event > coalesce(l.started_at, toDateTime64(0, 3, 'UTC')) + toIntervalSecond({durationSeconds:UInt32})`; |
There was a problem hiding this comment.
Concern: Could session detection silently drop events?
recent aggregates to a single min(event_time) per subject before the gap check.
If one sweep's ingest window contains both an in-session event and a post-expiry one, min would pick the in-session event, fail the gap test, and open no session for either.
Suggestion:
Filter before aggregating rather than after
const FIND_NEW_SESSION_STARTS_SQL = `
WITH
last_session AS
(
SELECT subject, max(session_start) AS started_at
FROM ${SESSIONS_TABLE}
WHERE client_id = {clientId:String}
AND slug = {slug:String}
GROUP BY subject
)
SELECT
e.subject AS subject,
formatDateTime(
min(e.event_time), '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC'
) AS started_at
FROM ${EVENTS_TABLE} AS e
LEFT JOIN last_session AS l ON e.subject = l.subject
WHERE e.client_id = {clientId:String}
AND e.slug = {slug:String}
AND e.ingested_at >= {ingestedSince:DateTime64(3, 'UTC')}
AND e.event_time >= {eventTimeFloor:DateTime64(3, 'UTC')}
AND e.event_time < {to:DateTime64(3, 'UTC')}
-- Gate every event on the session gap, then take the earliest survivor.
-- Checking the gap against min(event_time) instead lets an in-session event
-- shield a later post-expiry one, so no session opens for either.
AND e.event_time > coalesce(l.started_at, toDateTime64(0, 3, 'UTC'))
+ toIntervalSecond({durationSeconds:UInt32})
GROUP BY e.subject`;
| const watermark = | ||
| (await this.meteringSweepManager.findWatermark(params)) ?? | ||
| new Date(now.getTime() - this.meteringSweepConfig.lookbackMs); | ||
| const ingestedSince = new Date( | ||
| watermark.getTime() - this.meteringSweepConfig.watermarkLagMs | ||
| ); | ||
|
|
||
| const thresholds = meterResult | ||
| .getNotificationThresholds() | ||
| .filter((threshold) => threshold > 0); | ||
|
|
||
| const { failed, ...summary } = await this.notifyCrossings( | ||
| params, | ||
| meter, | ||
| thresholds, | ||
| ingestedSince, | ||
| now | ||
| ); | ||
|
|
||
| if (failed > 0) { | ||
| this.statsd.increment('metering.sweep.watermark_held', { | ||
| slug: params.slug, | ||
| }); | ||
| return { ...summary, held: true, watermark: watermark.toISOString() }; | ||
| } | ||
|
|
||
| await this.meteringSweepManager.advanceWatermark({ | ||
| ...params, | ||
| watermark: now, | ||
| updatedAt: now, | ||
| }); | ||
|
|
||
| return { ...summary, held: false, watermark: now.toISOString() }; | ||
| } |
There was a problem hiding this comment.
Concern: Could a meter that outgrows the ClickHouse caps stall permanently rather than degrade?
ingestedSince is derived from the stored watermark and advanceWatermark is only reached on success. So if findWindowCandidates trips maxExecutionTimeSeconds or requestTimeoutMs, wouldn't the watermark stay frozen while now keeps advancing, making the active CTE's range larger on every retry, so the meter can't recover on its own?
| ): Promise<SubjectDispatch> { | ||
| const { params, meter, thresholds, lastSent, now } = context; | ||
|
|
||
| const grantedAmount = await this.usageGrantsManager.getActiveGrantedAmount( |
There was a problem hiding this comment.
Should the cooldown check come before this lookup?
lastSent is already in memory, and windowId doesn't depend on grantedAmount.
With cooldownMs at 1h, a recently notified subject gets re-read from Firestore on every sweep until it's eligible again (each answer is discarded). I think skipping looks safe since computeThresholdMet only filters thresholds. If none of them clears the cooldown, nothing in met will either.
| this.logger.error(err); | ||
| throw err; | ||
|
|
||
| const response = await fetch(params.url, { |
There was a problem hiding this comment.
Does the URL need to be validated?
| let failed = 0; | ||
| for (const meter of meters) { | ||
| try { | ||
| const result = await this.sweep(meter); |
There was a problem hiding this comment.
Should now be passed into this? -> const result = await this.sweep(meter, now);
DRAFT