`fact_video_segments` is not incrementally populated
Nobody has claimed this yet.
- Dominant language
- Dockerfile
- Stars
- 2
- Forks
- 10
- PR merge metrics
- No merged PRs in 30d
Description
Summary
reporting.fact_video_segments is empty in any deployment that has not run
dbt run --full-refresh after learners watched videos, and it silently stops advancing
from whatever moment it was last full-refreshed.
I believe this is structural rather than environmental: the model is declared as a plain
materialized view, but its SELECT joins two events that can never arrive in the same insert
block. I would like to check that reading with you before proposing a change, in case the
current materialization is deliberate and I'm missing the intended usage.
Everything downstream is affected: the watched_video_segments and
at_risk_watched_video_segments datasets, the "Number of Views across Video Duration" and
"Unique vs. Repeat Views" charts, the Individual Learner and At-Risk Learners dashboards,
and the in-context video graph in Studio and the instructor dashboard.
Evidence
I have been testing this in several ulmo and verawood instances.
Notably:
https://apps.ulmo.openedx.io
https://verawood.releases.edunext.link/
https://ulmo.releases.edunext.link/
During my testing I have played this video with a lot of play pause events and also leaving it running from start to end to no avail.
On a production instance that was upgraded and already had video activity, the table is not
empty but frozen: it holds 1,244 rows while the MV's own SELECT, run as a query, returns
2,973. The rows it has are the back-fill from when the model was first built.
To check any deployment:
SELECT verb_id, count() FROM <xapi_db>.video_playback_events GROUP BY verb_id; -- data present
SELECT count() FROM <reporting_db>.fact_video_segments; -- 0 / frozen
SELECT as_select FROM system.tables
WHERE database = '<reporting_db>' AND name = 'fact_video_segments_mv';
-- wrap that SELECT in `SELECT count() FROM (...)` and compare
Why it cannot work
models/video/fact_video_segments.sql is materialized="materialized_view" with a
ReplacingMergeTree target. A plain ClickHouse MV is an insert trigger: its SELECT sees
only the block being inserted. The model pairs each played event with a later
paused / seeked / completed / terminated event of the same learner and video
(first_value / last_value windows, then starts inner join ends). Those events are
minutes apart, so they land in different blocks, and in each block one side of the join is
empty. The MV never writes a row.
The table only receives dbt's own back-fill (CREATE TABLE ... AS <model sql>, on first
build and on --full-refresh), which is a one-time snapshot.
It is not only this model
The same pattern (a plain MV whose output depends on rows outside the inserted block) shows
up in two more places. The first follows from the pattern and the second is visible in the
SQL:
| model | per insert block it computes | effect |
|---|---|---|
video/fact_video_segments |
play/end pairing | empty |
video/fact_video_engagement |
plain MV on fact_video_segments: count(distinct block_id) per learner, full join to the course's video list, ReplacingMergeTree target |
inherits the empty input; even with input, each block's partial count would replace the previous one |
problems/dim_problem_coursewide_avg |
avg() / countIf()/count() per problem_id over the block's rows, ReplacingMergeTree target |
the "course-wide" figures cover only the learners in the latest insert block. They look plausible but are wrong. |
The last one is a separate problem and probably deserves its own issue; I list it to show
the class.
fact_video_segments also uses a ReplacingMergeTree for a count(1) column
(watch_count), so even a per-block-correct MV would replace counts instead of adding them.
History
The model was added already materialized as a plain MV. It has never produced an
incremental row.
|---|---|
| 297cd373 (2025-05-21) | fix: update video queries to fix inconsistencies in data adds fact_video_segments.sql as a plain MV and removes fact_video_plays.sql / fact_watched_video_duration.sql, which had no config() and were therefore plain views: always current, just not incremental. |
| parent 525f65e8 = v4.0.3 | last release without the problem |
| v5.0.0 (2025-08-01) → v8.0.0, main | affected |
For operators the change arrives with tutor-contrib-aspects v2.4.0 (DBT_BRANCH v5.0.0).
Why CI does not catch it
coverage.yml runs dbt run --full-refresh, which is exactly the back-fill path, and
test_fact_video_segments runs the model SQL as a standalone query. Neither exercises
insert-trigger semantics, so the test is green on a model that is inert in production.
Model logic issues found along the way
These are independent of materialization, but a fix will touch the same SQL:
- Pairing takes the last end before the next play, not the first
(last_value(event_id) ... unbounded following). For play@0 → seek 30→80 while playing →
pause@90, the model records 0–90 and counts the skipped 30–80 as watched. Backward seeks
undercount. seekedis only an end.video_playback_eventskeepstime-fromand dropstime-to,
so playback after a seek can't start a new interval.- Plays that never close (tab closed, no
stop_video) are dropped entirely.
Whether the player emits a fresh play_video after seeking during playback decides how much
the first point matters in practice. I have not established that yet.
v2.3.1 (v4.0.3) is the last working release.
Why CI does not catch it
coverage.yml runs dbt run --full-refresh, which is exactly the back-fill path, and
test_fact_video_segments runs the model SQL as a standalone query. Neither exercises
insert-trigger semantics, so the test is green on a model that is inert in production.
Model logic issues found along the way
These are independent of materialization, but a fix will touch the same SQL:
- Pairing takes the last end before the next play, not the first
(last_value(event_id) ... unbounded following). For play@0 → seek 30→80 while playing →
pause@90, the model records 0–90 and counts the skipped 30–80 as watched. Backward seeks
undercount. seekedis only an end.video_playback_eventskeepstime-fromand dropstime-to,
so playback after a seek can't start a new interval.- Plays that never close (tab closed, no
stop_video) are dropped entirely.
Whether the player emits a fresh play_video after seeking during playback decides how much
the first point matters in practice. I have not established that yet.
Now a proposal
Both the research and the proposal were done using heavy use of claude code. Now the research I understand it fully, but the proposal not completely. And this is why I want to have a proper conversation before I put effort into fixing upstream. I did fix it for my environment by using a small Tutor plugin that turns the plain fact_video_segments_mv into a ClickHouse refreshable materialized view. The plugin runs as an init task on the clickhouse service at priority 98, after the aspects task that runs dbt run. It reads the model's exact SELECT back from system.tables.as_select, drops the plain MV that dbt created, and recreates it as REFRESH EVERY 10 MINUTE TO fact_video_segments AS . It then triggers the first refresh straight away and waits for it to finish. From then on ClickHouse re-runs the full query on every refresh and swaps the result into the target table in one atomic step. Locally the table went from 0 to 82 rows on the first refresh, and new segments have kept appearing as videos are watched.
It does not look like the correct way to fixing it upstream, so that's where this comes in.
Proposal
Separate pairing (needs per-learner-and-video state and ordering) from per-second
counting (cheap at query time). Give each derived row a deterministic identity, so that
rebuilding it is idempotent and can be done incrementally.
video_playback_eventsalso keepsvideo_position_to(the seek'stime-to). It stays
one row per event, so a plain MV is correct here.- New
fact_video_watch_intervals: one row per watched interval
(org, course_key, object_id, actor_id, start_event_id, started_at, start_pos, end_pos, end_verb).- Target:
ReplacingMergeTree(<version>), ordered by
(org, course_key, object_id, actor_id, start_event_id). - Filled by a refreshable MV in
APPENDmode over a lookback window
(emission_time >= now() - INTERVAL <n>). Each refresh costs time proportional to the
window, not to history. A late end event re-emits its interval under the same key and
replaces it. - Pairing uses
leadInFrameover each learner-and-video stream: an interval closes at the
next event. This removes thefirst_value/last_value/ self-join chain.
- Target:
fact_video_segmentsbecomes a plain view over the intervals
(arrayJoin(range(start_pos, end_pos + 1))) with the same columns and name, so the Superset
datasets are unchanged.fact_video_engagementreads the intervals, since it only needs "which videos did this
learner watch", as a view or a refreshable MV.
Why not the other options:
- Refreshable MV over the unchanged model: this is what I run downstream today as a
stopgap, and it works. But every refresh recomputes and rewrites all history at one row per
second of video. It also swaps the target table instead of inserting, so the plain MV on top
(fact_video_engagement) most likely never fires. I have not verified that last point. - Chained plain MVs with an "open plays" state table: in-block play+pause pairs, MV
execution order, out-of-order and late delivery, and plays that never close all make it
fragile. It buys no freshness over a few-minute refresh. AggregatingMergeTreewithgroupArrayStateper learner and video: correct and truly
incremental, but every dashboard query has to finalise and walk the event arrays, and the
arrays grow without limit.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with models/video/fact_video_segments.sql and inspect its materialization, query, and ReplacingMergeTree target. Then read fact_video_engagement and coverage.yml, including test_fact_video_segments, to compare standalone and full-refresh coverage with insert-trigger behavior. Done requires an agreed upstream design and verified that new video activity advances the affected datasets and dashboards without relying on a later full refresh.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- clickhouse, sql
- Domain
- analytics, data-engineering, databases
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100