Sessions and the cross-midnight backfill¶
Sessions are where "rebuild a day at a time" earns its complications. A session — a run of one device's events with no 30-minute gap — is defined by relationships between rows, and those relationships don't respect your partitions:
- A session that starts at 23:47 and keeps going belongs to one day's partition but is built from two days' events.
- Worse: nothing in the definition stops a session from going on forever. One kiosk display, background sync, or misbehaving client that never pauses for 30 minutes produces a session with no end — a row that is never final, and that only a full-history scan can rebuild.
So any sessionizer that you want to maintain incrementally has to cut long sessions somewhere. That's not a smelt rule; it's arithmetic. The design question is what the cut is anchored to. This page builds the answer that keeps partitions independent — anchor it to the clock — and closes with the other answer, which costs more than it looks like it should.
The cut rule¶
silver.sessions keeps the ordinary 30-minute gap rule (plus a platform
change starting a new session) and adds one deadline on top. The gap
rule ends a session when the user pauses; the deadline exists so a
session that never pauses still ends: a session dies at the first
midnight it fails to reach into, where "reaching into" a day means
having an event in its first 30 minutes. A session that genuinely
crosses a midnight always reaches into the new day (its gaps are under
30 minutes, so some event lands within 30 minutes of the boundary),
which means the deadline only ever fires at the next midnight after
that. Two consequences, and everything on this page leans on them:
a session can cross at most one midnight, so every session spans at
most two calendar days — and whether it must end is computable from a
timestamp alone, with no memory of the session's history.
The model¶
---
materialization: table
refresh: incremental
grain: partition
timeseries:
event_time_column: session_start_date
partition_column: session_start_date
granularity: day
---
WITH sessionized AS (
SELECT
device_id,
event_ts,
event_date,
platform,
utm_campaign,
session_start_ts,
CAST(session_start_ts AS DATE) AS session_start_date
FROM smelt.functions.sessionize(
source => smelt.silver.events_deduped,
partition_col => device_id,
ts_col => event_ts,
platform_col => platform
)
)
SELECT
CONCAT(CAST(device_id AS VARCHAR), '-', CAST(session_start_ts AS VARCHAR)) AS session_id,
device_id,
session_start_ts,
session_start_date,
MIN(event_ts) AS session_start,
MAX(event_ts) AS session_end,
COUNT(*) AS event_count,
ANY_VALUE(platform) AS platform,
ARG_MAX(utm_campaign, -epoch_us(event_ts)) FILTER (
WHERE utm_campaign IS NOT NULL
AND event_ts <= session_start_ts + INTERVAL '5 minutes'
) AS utm_campaign
FROM sessionized
WHERE event_date
BETWEEN session_start_date
AND session_start_date + INTERVAL '1 day'
GROUP BY device_id, session_start_ts, session_start_date
HAVING MAX(event_ts) - MIN(event_ts) < INTERVAL '2 days' -- max_session_span: explicit, checkable cap assertion
Three things in here deserve names:
- The sessionization is a reusable function.
smelt.functions.sessionize(source) assigns each event its session's start timestamp using window functions. (Thesource =>syntax is smelt's named-argument form for function calls — not standard SQL.) ItsRANGE BETWEEN INTERVAL '2 days' PRECEDINGframes are not just implementation: smelt reads them as the function's declared reach into the past. Functions expand transparently into the caller, so the planner analyzes the real SQL, not an opaque call. (More: functions guide.) - The
WHEREfilter is another declaration.event_date BETWEEN session_start_date AND session_start_date + INTERVAL '1 day'states the two-calendar-day rule in column terms: a session's events live on its start day or the day after, never further. TheHAVINGclause restates the same cap as a per-row assertion the emitted SQL enforces. And, same move as the lateness filter on the previous page, smelt derives windows from it — this time in the opposite direction. - The attribution expression is just SQL.
ARG_MAX(utm_campaign, -epoch_us(event_ts)) FILTER (WHERE …)picks the earliest non-NULL campaign within the session's first five minutes — negating the timestamp turns "value at the maximum" into "value at the earliest event." It plays no role in the maintenance derivation; it's here so the pipeline computes something a marketer would recognize.
The write window inverts the filter¶
For events_parsed on the previous page, day D's output
depended on earlier source days, so the read widened backward. A
session table skews the other way: this table is partitioned by
session_start_date, and an event arriving on day D can extend a session
that started on day D−1. New data for day D can change yesterday's
partition.
smelt gets that by inverting the declared filter: if a session's events
reach at most one day past its start, then day D's events reach back to
sessions starting on D−1. A run over [D, D+1) must therefore rewrite
partitions [D−1, D+1) — yesterday's and today's — and it does:
-- trigger: Backfill
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
INSERT INTO main.silver_sessions SELECT * FROM (
-- … model SELECT body (see the full SQL below) …
) AS _smelt_output_clamp WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
COMMIT
-- trigger: NewData { source: "silver.events_deduped" }
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
INSERT INTO main.silver_sessions SELECT * FROM (
-- … model SELECT body (see the full SQL below) …
) AS _smelt_output_clamp WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
COMMIT
Full emitted SQL — smelt explain silver.sessions --show-sql --period 2026-04-10..2026-04-11
-- trigger: Backfill
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
INSERT INTO main.silver_sessions SELECT * FROM (
WITH sessionized AS (
SELECT
device_id,
event_ts,
event_date,
platform,
utm_campaign,
session_start_ts,
CAST(session_start_ts AS DATE) AS session_start_date
FROM ((
WITH _marked AS (
SELECT
*,
LAG(event_ts) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _prev_ts,
LAG(platform
) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _prev_platform
FROM (SELECT * FROM main.silver_events_deduped WHERE first_seen_date >= '2026-04-07' AND first_seen_date < '2026-04-11') AS source
),
_bounded AS (
SELECT
*,
CASE
WHEN _prev_ts IS NULL THEN event_ts
WHEN epoch_us(event_ts) - epoch_us(_prev_ts) > 30 * 60 * 1000000 THEN event_ts
WHEN _prev_platform != platform
THEN event_ts
ELSE NULL
END AS _boundary_ts
FROM _marked
),
_candidate AS (
SELECT
*,
MAX(_boundary_ts) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _candidate_root_ts
FROM _bounded
),
_deadlined AS (
SELECT
*,
CASE
WHEN _candidate_root_ts IS NULL THEN NULL
WHEN CAST(_candidate_root_ts AS TIME) < TIME '00:30:00'
THEN CAST(CAST(_candidate_root_ts AS DATE) AS TIMESTAMP) + INTERVAL '1 day'
ELSE CAST(CAST(_candidate_root_ts AS DATE) AS TIMESTAMP) + INTERVAL '2 days'
END AS _deadline
FROM _candidate
)
SELECT
*,
CASE
WHEN _candidate_root_ts IS NOT NULL AND event_ts < _deadline THEN _candidate_root_ts
ELSE MIN(event_ts) OVER (PARTITION BY device_id, CAST(event_ts AS DATE))
END AS session_start_ts
FROM _deadlined
)) AS __smelt_t2529)
SELECT
CONCAT(CAST(device_id AS VARCHAR), '-', CAST(session_start_ts AS VARCHAR)) AS session_id,
device_id,
session_start_ts,
session_start_date,
MIN(event_ts) AS session_start,
MAX(event_ts) AS session_end,
COUNT(*) AS event_count,
ANY_VALUE(platform) AS platform,
ARG_MAX(utm_campaign, -epoch_us(event_ts)) FILTER (
WHERE utm_campaign IS NOT NULL
AND event_ts <= session_start_ts + INTERVAL '5 minutes'
) AS utm_campaign
FROM sessionized
WHERE event_date
BETWEEN session_start_date
AND session_start_date + INTERVAL '1 day'
GROUP BY device_id, session_start_ts, session_start_date
HAVING MAX(event_ts) - MIN(event_ts) < INTERVAL '2 days' -- max_session_span: explicit, checkable cap assertion
) AS _smelt_output_clamp WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
COMMIT
-- trigger: NewData { source: "silver.events_deduped" }
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
INSERT INTO main.silver_sessions SELECT * FROM (
WITH sessionized AS (
SELECT
device_id,
event_ts,
event_date,
platform,
utm_campaign,
session_start_ts,
CAST(session_start_ts AS DATE) AS session_start_date
FROM ((
WITH _marked AS (
SELECT
*,
LAG(event_ts) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _prev_ts,
LAG(platform
) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _prev_platform
FROM (SELECT * FROM main.silver_events_deduped WHERE first_seen_date >= '2026-04-07' AND first_seen_date < '2026-04-11') AS source
),
_bounded AS (
SELECT
*,
CASE
WHEN _prev_ts IS NULL THEN event_ts
WHEN epoch_us(event_ts) - epoch_us(_prev_ts) > 30 * 60 * 1000000 THEN event_ts
WHEN _prev_platform != platform
THEN event_ts
ELSE NULL
END AS _boundary_ts
FROM _marked
),
_candidate AS (
SELECT
*,
MAX(_boundary_ts) OVER (
PARTITION BY device_id ORDER BY event_ts
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW -- max_lookback
) AS _candidate_root_ts
FROM _bounded
),
_deadlined AS (
SELECT
*,
CASE
WHEN _candidate_root_ts IS NULL THEN NULL
WHEN CAST(_candidate_root_ts AS TIME) < TIME '00:30:00'
THEN CAST(CAST(_candidate_root_ts AS DATE) AS TIMESTAMP) + INTERVAL '1 day'
ELSE CAST(CAST(_candidate_root_ts AS DATE) AS TIMESTAMP) + INTERVAL '2 days'
END AS _deadline
FROM _candidate
)
SELECT
*,
CASE
WHEN _candidate_root_ts IS NOT NULL AND event_ts < _deadline THEN _candidate_root_ts
ELSE MIN(event_ts) OVER (PARTITION BY device_id, CAST(event_ts AS DATE))
END AS session_start_ts
FROM _deadlined
)) AS __smelt_t2529)
SELECT
CONCAT(CAST(device_id AS VARCHAR), '-', CAST(session_start_ts AS VARCHAR)) AS session_id,
device_id,
session_start_ts,
session_start_date,
MIN(event_ts) AS session_start,
MAX(event_ts) AS session_end,
COUNT(*) AS event_count,
ANY_VALUE(platform) AS platform,
ARG_MAX(utm_campaign, -epoch_us(event_ts)) FILTER (
WHERE utm_campaign IS NOT NULL
AND event_ts <= session_start_ts + INTERVAL '5 minutes'
) AS utm_campaign
FROM sessionized
WHERE event_date
BETWEEN session_start_date
AND session_start_date + INTERVAL '1 day'
GROUP BY device_id, session_start_ts, session_start_date
HAVING MAX(event_ts) - MIN(event_ts) < INTERVAL '2 days' -- max_session_span: explicit, checkable cap assertion
) AS _smelt_output_clamp WHERE session_start_date >= '2026-04-09' AND session_start_date < '2026-04-11'
COMMIT
Read the frame: the run window was one day, the DELETE covers
session_start_date in [2026-04-09, 2026-04-11), and the events read
widened to cover both the session span and the sessionizer's two-day
lookback. Every bound traces to something declared in SQL you can point
at. (The output also lists a second statement group under a NewData
trigger — the same work, run when upstream data changes rather than when
you ask for a window; the changing-things page
puts that to use.)
The payoff: a midnight-straddling session, handled by a one-day run¶
In the generated dataset there's a device with an event at
2026-05-03 23:47 and its next at 2026-05-04 00:03 — a 16-minute gap,
one session, started on May 3rd. Suppose you've already built everything
through May 3rd, and today's job runs May 4th:
-- trigger: Backfill
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-05-03' AND session_start_date < '2026-05-05'
INSERT INTO main.silver_sessions SELECT * FROM (
-- … model SELECT body (see the full SQL below) …
) AS _smelt_output_clamp WHERE session_start_date >= '2026-05-03' AND session_start_date < '2026-05-05'
COMMIT
-- trigger: NewData { source: "silver.events_deduped" }
BEGIN
DELETE FROM main.silver_sessions WHERE session_start_date >= '2026-05-03' AND session_start_date < '2026-05-05'
INSERT INTO main.silver_sessions SELECT * FROM (
-- … model SELECT body (see the full SQL below) …
) AS _smelt_output_clamp WHERE session_start_date >= '2026-05-03' AND session_start_date < '2026-05-05'
COMMIT
The May 4th run rewrites the May 3rd partition, folding the midnight event into the existing session's row instead of minting a fragment session at 00:03. This is the bug class — sessions split at partition boundaries, session counts inflated — that hand-built day-at-a-time session jobs get wrong by default, and that you otherwise fix by remembering to over-rebuild ("always redo yesterday too") in a place far from the session logic. Here the over-rebuild is derived, minimal, and proven against the same filter the query enforces.
An end-to-end test in the repo (per_partition_equivalence.rs) pins the
property all of this serves: building this table day by day, in any
order, produces results identical to building it from scratch in one
pass.
A different cut, a different execution shape¶
The clock-anchored deadline is a design choice, and a reasonable person
might prefer the other one: "a session ends roughly two days after it
started," measured from the session's own start. The full example
builds that too, as silver.sessions_chained, with the same gap rule and
attribution; the two tables differ only in where the cap's timing comes
from.
That one change transforms the execution. "When did the session I'm continuing start?" cannot be answered from any bounded window of new events — for a long-lived session, the start could be arbitrarily far back. The model must consult its own prior output, and smelt, seeing the self-reference, proves a different property: the table still converges, but only if its partitions are built strictly in time order. smelt enforces that ordering itself (backfills run oldest-first, one partition at a time, never in parallel), and — as the changing-things page shows — the self-reference also opts the table out of automatic change propagation. The deep dive walks the model and its emitted plan.
What makes the choice worth a page of its own is how differently the three plausible designs treat one pathological input — a device emitting an event every 29 minutes for nine days straight, so the gap rule never fires and only the cap decides:
| Design | Result on the never-idle device | Execution |
|---|---|---|
Clock-anchored cap (silver.sessions) |
9 sessions (~1/day) | partitions independent |
Root-anchored cap (silver.sessions_chained) |
5 sessions (~1/2 days) | strictly ordered, sequential |
| Cap inside the window frame only (this example's original design, since replaced) | ~50 single-event sessions per day | "independent," and wrong |
(If you build streaming pipelines: the clock-anchored table is roughly
what session_window can express; the root-anchored one is loosely the
shape you'd otherwise reach for stateful processing for.)
The third row is the cautionary one. A cap enforced only by a window frame's reach looks partition-independent — no self-reference, nothing for an analyzer to object to — but under the never-idle input the frame simply stops containing what it needs, and session counts inflate 50×. Session count is a headline metric. The difference between the first two designs and the third is exactly the difference between a bound that is true of the data and one that is merely present in the code.