Page MenuHomePhabricator

Spike: drop all 90-day windows in MWHistoryDeltaWriter and replace with full-table revert detection
Closed, ResolvedPublic

Description

Background

T426469 investigated revert detection windows and recommended a 90-day bounded approach as a pragmatic middle ground. That design was implemented in T425729 as a two-tier schema:

Bounded tier (4 columns, always TRUE/FALSE):
  revision_is_identity_reverted_within_90_days
  revision_first_identity_reverting_revision_id_within_90_days
  revision_seconds_to_identity_revert_within_90_days
  revision_is_identity_revert_within_90_days

Authoritative tier (4 columns, TRUE/NULL):
  revision_is_identity_reverted
  revision_first_identity_reverting_revision_id
  revision_seconds_to_identity_revert
  revision_is_identity_revert

In the current daily writer (MWHistoryDeltaWriter), *both* tiers are in fact 90-day bounded: the revert_seed CTE pulls only the last 90 days from the Iceberg target, so any revision whose first revert landed more than 90 days later is invisible to the writer entirely.

Problem

After team review, the 90-day-window design is being reconsidered for three reasons:

  1. Semantic confusion. The "90 days" in these fields is measured from rev_dt of the incoming event backward into the Iceberg target — not from event_timestamp, not from the ingestion date, and not comparable to the 90-day windows used in other MediaWiki metrics. Consumers cannot safely interpret these fields against other 90-day signals.
  1. Implementation complexity. Correctly computing the bounded and authoritative tiers in SQL requires a multi-CTE chain (revert_seed → revert_candidates → sha1_ranked → first_revert → revert_annotations) plus a separate back-patch MERGE that updates already-committed rows when a late-arriving revert comes in. The chain has subtle correctness requirements. This complexity has already surfaced multiple reviewer comments in Gerrit 1284858.
  1. Correctness gap remains. Even with the 90-day window the authoritative tier is not truly authoritative: it returns NULL (not TRUE) for reverts detected at > 90 days. The monthly MWHistorySnapshotMerger overwrites these fields correctly via the full Spark-based algorithm, but the daily rows can be wrong for weeks.

Proposed spike

Determine whether computing revert fields via a full-table unbounded SHA1 self-join on the Iceberg target is fast enough to be used in MWHistoryDeltaWriter for every daily run.

This would:

  • Drop the four _within_90_days columns from the schema entirely
  • Drop all 90-day window bounds from the revert CTE chain (revert_seed becomes a full-table scan; the authoritative fields become truly authoritative)
  • Drop the back-patch MERGE (MERGE 2): with full history available in MERGE 1, the reverted base row is always present and annotatable in the same pass
  • Retain the four authoritative revert columns, now populated by the same SHA1-rank logic as the monthly Spark run

Current table dimensions (T425729, April 2026)

  • ~8.1 billion rows, ~540 GB
  • Partitioned by (months(event_timestamp)) → ~300 partitions after compaction
  • Daily delta writer currently runs in 5–10 min/day

Prior performance data point (T426469)
Simulated CTE-only unbounded SHA1 scan on enwiki + frwiki: 89 s and 62 s, respectively, versus ~54 s / 50 s for the 90-day window. That test was a standalone SELECT, not the full writer pipeline against the complete Iceberg table.

What to build

  1. Branch off user-events (six-MERGE MWHistoryDeltaWriter chain including page and user events).
  2. Remove the four _within_90_days columns from DDL, INSERT, UPDATE, and MWHistorySnapshotMerger projection.
  3. Replace the revert_seed 90-day filter with a full-table scan (no lower bound on event_timestamp).
  4. Remove the 90-day bounds inside revert_annotations for the authoritative tier.
  5. Remove the back-patch MERGE (MERGE 2) and its CTE (buildBackPatchCteSQL).
  6. Run the modified writer against production data for a representative high-edit day (e.g. a Wednesday in April 2026).

Metrics to collect

  • Wall time for the daily delta run
  • Bytes scanned at the Iceberg table
  • Spark shuffle bytes and number of files opened (from Spark History Server)
  • Resulting revert field values: spot-check 1000 incoming revision_ids against wmf.mediawiki_history for accuracy

Acceptance threshold

  • Daily run completes in ≤ 1 hour
  • Revert field accuracy matches wmf.mediawiki_history on the sample (within expected compound-revert gap)

Out of scope

  • The rev_dt 90-day filter on incoming events (line 173 in buildIncomingSQL): this guards against page imports and admin operations with artificially old rev_dt and is independently motivated; evaluate separately
  • The tags MERGE 90-day lower bound on the target: similarly motivated by partition pruning, not by revert semantics; evaluate separately
  • Fixing compound-revert detection (known gap regardless of approach; monthly merger covers it)

References

  • T425729 — MWHistoryDeltaWriter + MWHistorySnapshotMerger implementation
  • T426469 — Original revert-window spike (motivated the 90-day design now under reconsideration)
  • T424350 — Parent task: incremental mediawiki_history table
  • Gerrit 1284858 — Code review thread where complexity concerns surfaced

Event Timeline

Change #1293836 had a related patch set uploaded (by Xcollazo; author: Xcollazo):

[analytics/refinery/source@master] Drop _within_90_days revert tier; full-table revert seed; scoped back-patch

https://gerrit.wikimedia.org/r/1293836

Spike complete — full-table revert detection is viable.

Performance (May 2026 production data, 6 days)

DateWall time
2026-05-0142 min
2026-05-0222 min
2026-05-0327 min
2026-05-0440 min
2026-05-0530 min
2026-05-0631 min

All six runs cleared the ≤ 1 hour acceptance threshold. The outliers (May 1: International Workers' Day, May 4: Star Wars day) reflect high edit volume on the source side, not the seed scan. Typical days cluster around 25–31 min. The table at test time was ~8.1B rows / ~540 GB (April 2026 snapshot seed).

What changed

  • revert_seed CTE: 90-day lower bound removed; now a full-table scan. Incoming revision_ids are excluded via LEFT ANTI JOIN to prevent double-counting on reruns or when admin ops re-emit events for already-committed revisions (would have caused seconds_to_revert=0 self-revert false positives).
  • revert_annotations: the four _within_90_days bounded-tier columns dropped entirely. The authoritative tier now has no time bound — TRUE when detected, NULL otherwise.
  • MERGE 2 (back-patch): retained but redesigned. Instead of a 90-day time bound on the seed, the seed is scoped to revision_text_sha1 IN (today's incoming sha1s). This avoids a second full-table scan while still catching cross-day reverts of arbitrarily old rows. Same ANTI JOIN guard applied.
  • MWHistorySnapshotMerger: revert_seconds CTE and its LEFT JOIN removed (were only needed to derive the bounded tier). All four _within_90_days columns removed from projection, UPDATE, and INSERT.
  • SnapshotMerger DELETE guard: AND t.event_entity = 'revision' added to the source='events' DELETE clause. Without this, page and user event rows would be deleted when the monthly merger runs, since projected_monthly only produces revision rows and every page/user row is unconditionally "not matched by source". T427328 tracks the full fix (extending the merger to also reconcile page and user entities).

Code

Gerrit: https://gerrit.wikimedia.org/r/c/1293836

Update — algorithm correctness + perf

Earlier spike claim of "semantically equivalent to wmf.mediawiki_history monthly" was wrong. The sha1_rank+1 self-join missed the canonical A → B → A revert pattern (intermediate different-sha rev inside a base→reverter window — monthly's wider-revert branch catches it; daily didn't), over-marked the sha-series base as reverted (monthly leaves it — the base's content survives), and over-marked same-sha rank≥2 reverters. The daily test suite had been hiding the divergence by only testing same-sha sequences with the intermediate omitted.

Fix: rewrote revert detection in MWHistoryDeltaWriter to implement the wider-revert algorithm directly in SQL — for each revision R, find the MIN-by-(ts, rev_id) reverter whose base was processed strictly before R and which itself comes after R. Same logic in the back-patch (MERGE 2). Seed scope broadened from sha1-IN to page_id-IN (wider reverts can cross sha-series). Ported the monthly's 9-revision "nested reverts" golden test from TestDenormalizedRevisionsBuilder as the integration check; all 78 unit tests pass.

Perf: hot Wikidata pages (top page ~2.1M revisions) made the join fan out to ~10¹² tuples on a single Spark task — MERGE 1 took 1.4h and failed, MERGE 2 spilled 70 GiB. Fixed with single-column filters pushed pre-shuffle: MERGE 1 restricted to incoming-on-both-sides; MERGE 2 to seed-rows-not-yet-annotated × incoming reverters (reads revision_is_identity_reverted from the target). First day after the fix: MERGE 1 ~15 min, MERGE 2 awaiting next run.

Caveats (documented in code): both filters assume in-order daily runs. Out-of-order backfills or late-arriving events (rev_dt earlier than delivery day) can leave a bounded set of annotations missed in the in-month window; the next MWHistorySnapshotMerger run heals at month-close. A time-bounded alternative is sketched in the candidates CTEs but kept unimplemented — the cross-side range predicate isn't pushable in OSS Spark's SortMergeJoin, so it wouldn't address the perf cliff.

Gerrit: https://gerrit.wikimedia.org/r/c/analytics/refinery/source/+/1293836 (branch T427314-full-table-revert-detection). Continuing verification against the May 2026 monthly snapshot once it lands.

Update — two algorithm bug fixes, attempted MERGE unification reverted

After the wider-revert spike ran clean on May 1-7 (mean ~30 min/day, max ~55 min, late-event gap 50 rows in 12.3M ≈ 4×10⁻⁶), Joseph flagged two correctness bugs in the monthly's reference algorithm that we should not inherit:

  1. Same-parent-id rule. A reverter sharing revision_parent_id with the row it would revert is a page-merge artifact, not a real revert — page merges leave two revisions with identical content pointing at the same pre-merge ancestor. The new daily skips these via r.reverter_parent_id IS DISTINCT FROM rev.revision_parent_id in the candidates CTE (NULL-safe — two unrelated page-creates that happen to share a sha1 are also skipped).
  1. NULL revision_text_sha1 filter. The monthly currently includes NULL-sha1 rows in its (page, sha) grouping, and Spark treats NULL=NULL in PARTITION BY, so all suppressed-text revisions on a page share one fake "sha-series" and rank against each other. The daily now excludes them in both revert_seed and revert_candidates. Tracked upstream as a separate fix; the daily intentionally diverges in the meantime.

Both fixes added to MERGE 1 and MERGE 2 along with positive sanity tests (parent-id pair → no revert; NULL-sha pair → no revert). 80 tests pass.

Attempted: unified MERGE 1 + MERGE 2 into one Iceberg MERGE INTO. Looked clean — multiple WHEN MATCHED clauses dispatched on an op tag, one shared sha1_ranked / first_reverter pipeline, one shuffle. Reverted: the unified source's revision_ids span the full revision_id range (back-patches target arbitrary historical rows), which defeats Iceberg COW's target file pruning. Symptom on first run: the Exchange in front of ReplaceData carried the full 8B-row target. Two separate MERGEs each have tightly-scoped source keys (today's incoming → current month; back-patches → a few thousand files) and prune correctly. The candidates / first_reverter computation runs twice per day — fine; the redundancy is the cost of partition pruning. Documented as a code-level comment and as a reusable Iceberg COW pattern note.

Considered: lag() / window-only revert detection. Tempting because the small reverters set (~1.8 MB/day) is broadcastable and the per-page chronological iteration matches Window partitioning. Discarded: lag() only sees the immediately-prior same-sha row, not the wider-revert "MIN-by-(ts, id) pending reverter across all base→reverter windows" that the monthly computes. The closest pure-window approximation (collect_list over an UNBOUNDED PRECEDING window) materializes the full page history per row — exactly the hot-page blow-up we're trying to avoid. Kept the current min_by-over-candidates structure with a BROADCAST hint on the small reverters set.

Perf addition (now testing). Added /*+ BROADCAST(r) */ on the candidates join. Reverters set is ~23k rows on the busiest day (~1.8 MB), trivially under autoBroadcastJoinThreshold. Eliminates the SortMergeJoin shuffle on the rev side. Currently running on a fresh ..._bugfix table to (a) verify the bug fixes produce only the expected diff vs _wider_revert and (b) measure the broadcast win.

I have not looked at the code, but this definition is incorrect:

Same-parent-id rule. A reverter sharing revision_parent_id with the row it would revert is a page-merge artifact, not a real revert

The definition should be:
A Reverting revision R shouldn't considered a revert if it has its revisionParentId being the revisionId of the revert it reverts-to.

Bug-fix sanity checks — both pass

(1) NULL-sha rows never carry a revert annotation. Spark treats NULL = NULL in PARTITION BY, so the algorithm has to filter NULL sha1 explicitly.

SELECT
    sum(CASE WHEN revision_text_sha1 IS NULL
              AND revision_is_identity_revert   = true THEN 1 ELSE 0 END) AS null_sha_marked_is_revert,
    sum(CASE WHEN revision_text_sha1 IS NULL
              AND revision_is_identity_reverted = true THEN 1 ELSE 0 END) AS null_sha_marked_reverted
FROM xcollazo.mediawiki_history_incremental_v1_aligned
WHERE source = 'events' AND event_entity = 'revision';
null_sha_marked_is_revertnull_sha_marked_reverted
00

(2) No-op rule: a row flagged is_revert=TRUE must NOT have its revision_parent_id pointing at an earlier same-sha occurrence on the same page (i.e. it's not a no-op duplicate of its parent).

SELECT count(*) AS noop_marked_as_revert_violations
FROM xcollazo.mediawiki_history_incremental_v1_aligned r
WHERE r.source       = 'events'
  AND r.event_entity = 'revision'
  AND r.revision_is_identity_revert = true
  AND r.revision_parent_id IS NOT NULL
  AND EXISTS (
    SELECT 1
    FROM xcollazo.mediawiki_history_incremental_v1_aligned prev
    WHERE prev.wiki_id            = r.wiki_id
      AND prev.page_id            = r.page_id
      AND prev.revision_text_sha1 = r.revision_text_sha1
      AND prev.revision_id        = r.revision_parent_id
  );
noop_marked_as_revert_violations
0

Late-event distribution and gap estimate

WITH t AS (
  SELECT
    event_timestamp                                                   AS rev_ts,
    to_timestamp(control_map['revision_update_dt'])                   AS write_ts,
    revision_is_identity_reverted, revision_is_identity_revert
  FROM xcollazo.mediawiki_history_incremental_v1_aligned
  WHERE source = 'events' AND event_entity = 'revision'
)
SELECT
  CASE
    WHEN unix_timestamp(write_ts) - unix_timestamp(rev_ts) <  3600    THEN '01 <1h (normal)'
    WHEN unix_timestamp(write_ts) - unix_timestamp(rev_ts) <  86400   THEN '02 1h-1d'
    WHEN unix_timestamp(write_ts) - unix_timestamp(rev_ts) <  604800  THEN '03 1d-7d'
    WHEN unix_timestamp(write_ts) - unix_timestamp(rev_ts) <  2592000 THEN '04 7d-30d'
    ELSE '05 >30d'
  END                                                                  AS lateness_bucket,
  count(*)                                                             AS n,
  sum(CASE WHEN revision_is_identity_reverted = true THEN 1 ELSE 0 END) AS marked_reverted
FROM t GROUP BY 1 ORDER BY 1;
lateness_bucketnmarked_reverted
01 <1h (normal)12,279,808162,873
02 1h-1d391
03 1d-7d22115
04 7d-30d25316
05 >30d31513

99.996% of events arrive within an hour of rev_ts. The late tail is dominated by page-import patterns (rev_dt from the original revision, not delivery time).

Upper-bound gap heuristic — NULL-annotated late rows that have a same-sha occurrence later on the same page (i.e. rows where the daily writer *might* have missed a reverter that arrived in a later day):

SELECT count(*) AS suspect_late_unannotated
FROM xcollazo.mediawiki_history_incremental_v1_aligned late
WHERE late.source = 'events'
  AND late.event_entity = 'revision'
  AND late.revision_is_identity_reverted IS NULL
  AND late.event_timestamp < to_timestamp(late.control_map['revision_update_dt']) - INTERVAL 1 DAYS
  AND EXISTS (
    SELECT 1 FROM xcollazo.mediawiki_history_incremental_v1_aligned later
    WHERE later.wiki_id = late.wiki_id AND later.page_id = late.page_id
      AND later.revision_text_sha1 = late.revision_text_sha1
      AND later.event_timestamp > late.event_timestamp
      AND later.revision_id     != late.revision_id
  );
suspect_late_unannotated
50

50 / 12.3M = 4×10⁻⁶, matching the commit-message claim. And the sample is again heavily dominated by the same dewiki phoiac9... blanking chain (≈50% of the sampled 30 rows are that page), meaning the heuristic *over*-counts — most of the NULLs it flags are the algorithm correctly excluding no-op duplicates, not a gap. So the real late-event gap is ≤ 50 rows.

Net

  • Both correctness sanity checks pass (zero violations).
  • 639 inner-chain catches and 50 no-op excludes vs the prior structure, both explainable.
  • 99.996% of events on-time; late-event gap is bounded above by 50 rows.
  • Authoritative diff vs wmf.mediawiki_history is the remaining check, blocked on the May 2026 monthly snapshot landing.

Implementation notes — hot-page join and pipeline invariant

The revert join is on (wiki_id, page_id) only, which means a single hot page (Wikidata bot-target items, Commons sandboxes — up to ~2.1M revisions per page empirically) can land all its revisions on one Spark task and fan out to O(N×R) tuples. Two layers bound this:

  • reverters_with_base is restricted to is_incoming = TRUE AND is_real_reverter = TRUE — roughly ~1.8 MB/day at WMF scale (~23k rows on the busiest May 2026 day). With the /*+ BROADCAST(r) */ hint this set is shipped to every probe task, eliminating the shuffle on the revision side entirely.
  • Each candidates_m1/m2 CTE applies its own rev-side filter (incoming-only for M1, seed-unannotated-only for M2), so the probe side is also tightly bounded per MERGE.

A unified candidates CTE (union of both sides) was tried and reverted: each MERGE re-ran the chain on the full union, doubling upstream work and raising mean daily run time from ~30 min to ~68 min over the 7-day May 2026 sample.

Correctness assumption: both candidates CTEs assume in-order daily runs (seed.max_ts < incoming.min_ts). Out-of-order backfills or late-arriving events (rev_dt before delivery day) leave a bounded set of annotations undetected in the in-month window. MWHistorySnapshotMerger heals these at monthly close.

Change #1293836 merged by jenkins-bot:

[analytics/refinery/source@master] Daily revert detection: align with monthly DenormalizedRevisionsBuilder

https://gerrit.wikimedia.org/r/1293836