ECM Version
Observed on 0.18.1. Source references below re-verified against the current checkout (030c9363, v0.18.2-0037); none of the paths involved have changed.
Priority
Priority 2: ECM is severely degraded
What is the bug?
Event providers reuse a fixed pool of slot names (numbers 01..100) and roll the variable part of each name over daily — the draw, the players, and the date/time. The M3U-refresh dedup path ("BD-A" scorer, ADR-008 §D1) compares the incoming stream name against the existing channel names in the target group with no date/time awareness, so today's fixture in a slot is scored as a duplicate of yesterday's fixture in the same slot. At or above the queue threshold the stream is queued as a pending merge instead of creating a channel, and while a row is pending the auto-creation pipeline defers that stream: no channel created, no EPG assigned.
When the whole slot pool rolls over at once, every stream in the group is deferred, the run reports 0 channels created, and the group sits on yesterday's matches for roughly a third of the day. There is no operator workaround — details below.
Names in the tables below are stand-ins that reproduce the shape of a real provider's slot naming; the scores are measured against the shipped matcher.
The scorer is not date-aware, and a one-day difference is not a close call. find_candidate normalizes both sides with NameCleanMode.CONSERVATIVE — NFC, strip a leading N | prefix, lowercase, strip (backend/services/dedup_matcher.py:522, called at :716 and :730) — and scores with fuzz.token_set_ratio(...) / 100.0 (:744). Nothing in the matcher or in the reused normalization_engine helpers touches month names, HH:MM, AM/PM, GMT/IST, or an @ marker. Measured with the shipped code and the default dedup_threshold = 0.80 (backend/config.py:891):
| incoming stream |
candidate channel |
confidence |
EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 18 Sep 09:30 AM GMT-1 |
... Player Three - Player Four @ 17 Sep 01:00 PM GMT-1 |
0.862 → queued |
EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 18 Sep 09:30 AM GMT-1 |
... Player One - Player Two @ 19 Sep 09:30 AM GMT-1 |
0.988 → queued |
| (same name) |
(same name) |
1.000 → queued |
That is the whole bug in one line: a name that differs only by the date is scored 0.99. The static slot prefix plus the tournament/round words carry the rest of the score; the slot pool exists precisely to make them identical.
A queued stream is a deferred create, and the deferral is what breaks the run.
backend/channel_pipeline_executor.py:1490-1529 calls _maybe_enqueue_pending_merge after the exact/normalized name lookups have missed, and returns the result verbatim, skipping the create_channel API call.
backend/services/m3u_dedup_hook.py:301-416 scores the stream against every channel in the target group — _maybe_enqueue_pending_merge builds the candidate list from self.existing_channels filtered only by channel_group_id (executor.py:6198-6210). There is no exclusion of a channel the stream is already attached to, and no exclusion of a candidate whose dynamic (date/event) part differs.
- On a hit the executor returns
ActionResult(skipped=True, ...) with the description Stream 'X' already in pending merges queue; channel creation deferred (executor.py:6270-6278).
- Because the create was skipped,
exec_ctx.current_channel_id is never set, so the rule's following assign_epg action fails with No channel context for assign_epg / No channel to update (executor.py:2650-2655). The run then finalises completed_with_errors (engine.py:638-639).
So the operator-visible signature is a run that created nothing, plus a set of assign_epg errors that read like EPG trouble and are actually the dedup queue.
No operator escape hatch exists. Both review actions fail to unblock the pipeline:
-
Accept cannot apply. The rule uses create_channel, so both provider copies of a slot carry the same stream name; POST /{id}/accept cannot disambiguate which copy to attach, and when the candidate channel is gone it returns 404 with the row left pending (backend/routers/channel_merges.py:1351-1366).
-
Dismiss does not stick. POST /{id}/dismiss flips the row to dismissed (channel_merges.py:1648-1652), and the partial unique index that keeps the queue idempotent only covers pending rows:
Index(
"uq_pending_merges_active",
"stream_name",
"candidate_channel_id",
unique=True,
sqlite_where=sa_text("status = 'pending'"),
),
(backend/models.py:3328-3335)
The next M3U refresh re-scores the same stream against the same candidate, the collision recovery query filters status == "pending" (m3u_dedup_hook.py:234-243), finds nothing, and inserts a fresh pending row. Dismissal is per-row, not sticky per (stream_name, candidate_channel_id) pair.
-
Rows never expire. There is no reaper, no last_seen_at, and nothing that reacts to the candidate channel disappearing. The auto_aged_out action_type is a reserved enum value in migration 0014 (:66, :289-291) that no code ever writes, and idx_pending_merges_candidate is documented as "offline orphan detection (§D4)" (models.py:3320-3322) but is never queried. Pending rows whose candidate channel no longer exists block forever; they are neutralised only by accident, when Pass 4 orphan reconciliation deletes the previous day's channels (engine.py:6021) and the candidate ceases to exist.
Second, simpler bug in the same path. The candidate set includes channels the stream is already attached to, so a stream can be queued as a duplicate of the channel it is on, at confidence 1.0. One of the rows observed in the live incident was exactly that. Nothing in _maybe_enqueue_pending_merge consults the channel's streams list.
Diagnosability makes it invisible. results["pending_merges_added"] exists (engine.py:2898, aggregated at :3422) and reaches the toast at backend/tasks/channel_pipeline.py:755 / :812 / :941 — but only inside the if not has_failed_actions: branch (:805). A run with any failed action skips that notification entirely and emits only the task-layer "Task Completed with Warnings". The returned summary is:
Auto-creation after M3U refresh: 26 streams evaluated, 12 matched, 0 channels created, 0 updated; 24 actions failed
(tasks/channel_pipeline.py:891-893) — which is indistinguishable from "the provider had nothing today", and names neither the deferral nor the blocking row ids. (The engine log line at engine.py:757-762 has the same gap.)
Dry runs cannot see any of this. The hook is bypassed before it reads the queue when exec_ctx.dry_run is true (executor.py:6183-6189), so a preview reports channels as createable that the live run then defers. The asymmetry is itself a diagnosability bug: preview and run disagree about what will happen.
Steps to Reproduce
- Group G contains a channel
EVENT SLOT 04: <Match A> @ <yesterday> ..., created the previous day from slot 04 by an m3u_refresh-triggered create_channel rule ({stream_name} name template, if_exists: merge) with a following assign_epg action.
- The provider renames slot 04 in place to
<Match B> @ <today> ... and the M3U refresh runs.
- The dedup hook scores the stream against the same-group channels; the slot-04 pair scores 0.86–0.99 (table above) and is queued as a pending merge.
- The create is skipped, the following
assign_epg fails with No channel context for assign_epg, and the run summary reads 0 channels created with N failed actions.
- Dismissing or accepting the row does not help: dismiss re-queues on the next refresh; accept cannot disambiguate the two provider copies of the slot.
- The group only heals once Pass 4 deletes the previous day's channels, which dissolves the candidates by accident.
Reproduction of the score itself, with no Dispatcharr needed:
from services.dedup_matcher import find_candidate
find_candidate(
"EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 18 Sep 09:30 AM GMT-1",
[("c1", "EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 19 Sep 09:30 AM GMT-1")],
0.80,
)
# -> MatchResult(..., confidence=0.988) # one day apart, queue threshold is 0.80
Screenshots / Logs / Artifacts
Some of it is in the debug bundle I'll attach privately if it helps, but the reasoning above stands on its own.
Additional Context
Dispatcharr 0.31.0.
Proposed fix (happy to implement, but see the scope question).
- Date/time-aware scoring in the BD-A matcher (the real fix). Strip the date/time token run (
@ 18 Sep 09:30 AM GMT-1 and variants) from both sides before scoring, or exclude those tokens from the score. The rule the scorer should encode: a next-day stream in the same slot is a rollover, not a duplicate — when the only strong similarity signal is the static slot prefix and the dynamic part differs, do not queue; create. This is ADR-008 §D2 territory (the 0.60 floor and the operator threshold are untouched), so it likely wants an ADR addendum rather than a silent behaviour change.
- Never queue a stream against the channel it is already attached to. The candidate list is built with no attachment filter (
executor.py:6198-6210); the channel's streams list is available at that point.
- Pending-row lifecycle. Auto-expire/auto-resolve a pending row when its candidate channel no longer exists (404 on resolve,
channel_merges.py:1351-1366) instead of blocking forever; and make dismissal sticky per (stream_name, candidate_channel_id) pair rather than per row (models.py:3328-3335, m3u_dedup_hook.py:234-243). Terminal rows already persist, so a sticky dismissal can be enforced by consulting them at enqueue time without a schema change.
- Diagnosability. The pipeline summary should count deferred-by-pending-merge streams and name the blocking row ids, instead of reporting only
0 channels created; the deferral reason should be carried into the assign_epg action error so a single deferred slot does not read as an EPG bug; and the dry run should surface pending merges so preview and run agree.
Tests I would add: scorer with date/time tokens stripped (same-slot @ 18 Sep vs @ 17 Sep does not reach the queue threshold; genuinely identical names still do); stream already attached to the candidate → not queued; pending row whose candidate channel 404s → auto-resolved and the pipeline proceeds; dismissed stream+candidate pair not re-queued on the next refresh; integration rollover scenario (day-old channels + refreshed slot names → one run creates today's channels, zero new pending rows).
Scope question. Items 1 and 4 are self-contained and are what actually stops the daily freeze. Item 3 touches queue semantics. I would rather land 1+2+4 first and treat 3 as a follow-up if you agree — say the word and I will open a PR against dev scoped accordingly.
ECM Version
Observed on 0.18.1. Source references below re-verified against the current checkout (
030c9363, v0.18.2-0037); none of the paths involved have changed.Priority
Priority 2: ECM is severely degraded
What is the bug?
Event providers reuse a fixed pool of slot names (numbers 01..100) and roll the variable part of each name over daily — the draw, the players, and the date/time. The M3U-refresh dedup path ("BD-A" scorer, ADR-008 §D1) compares the incoming stream name against the existing channel names in the target group with no date/time awareness, so today's fixture in a slot is scored as a duplicate of yesterday's fixture in the same slot. At or above the queue threshold the stream is queued as a pending merge instead of creating a channel, and while a row is pending the auto-creation pipeline defers that stream: no channel created, no EPG assigned.
When the whole slot pool rolls over at once, every stream in the group is deferred, the run reports
0 channels created, and the group sits on yesterday's matches for roughly a third of the day. There is no operator workaround — details below.Names in the tables below are stand-ins that reproduce the shape of a real provider's slot naming; the scores are measured against the shipped matcher.
The scorer is not date-aware, and a one-day difference is not a close call.
find_candidatenormalizes both sides withNameCleanMode.CONSERVATIVE— NFC, strip a leadingN |prefix, lowercase, strip (backend/services/dedup_matcher.py:522, called at:716and:730) — and scores withfuzz.token_set_ratio(...) / 100.0(:744). Nothing in the matcher or in the reusednormalization_enginehelpers touches month names,HH:MM, AM/PM,GMT/IST, or an@marker. Measured with the shipped code and the defaultdedup_threshold = 0.80(backend/config.py:891):EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 18 Sep 09:30 AM GMT-1... Player Three - Player Four @ 17 Sep 01:00 PM GMT-1EVENT SLOT 04: Championship | Qualifying: Player One - Player Two @ 18 Sep 09:30 AM GMT-1... Player One - Player Two @ 19 Sep 09:30 AM GMT-1That is the whole bug in one line: a name that differs only by the date is scored 0.99. The static slot prefix plus the tournament/round words carry the rest of the score; the slot pool exists precisely to make them identical.
A queued stream is a deferred create, and the deferral is what breaks the run.
backend/channel_pipeline_executor.py:1490-1529calls_maybe_enqueue_pending_mergeafter the exact/normalized name lookups have missed, and returns the result verbatim, skipping thecreate_channelAPI call.backend/services/m3u_dedup_hook.py:301-416scores the stream against every channel in the target group —_maybe_enqueue_pending_mergebuilds the candidate list fromself.existing_channelsfiltered only bychannel_group_id(executor.py:6198-6210). There is no exclusion of a channel the stream is already attached to, and no exclusion of a candidate whose dynamic (date/event) part differs.ActionResult(skipped=True, ...)with the descriptionStream 'X' already in pending merges queue; channel creation deferred(executor.py:6270-6278).exec_ctx.current_channel_idis never set, so the rule's followingassign_epgaction fails withNo channel context for assign_epg/No channel to update(executor.py:2650-2655). The run then finalisescompleted_with_errors(engine.py:638-639).So the operator-visible signature is a run that created nothing, plus a set of
assign_epgerrors that read like EPG trouble and are actually the dedup queue.No operator escape hatch exists. Both review actions fail to unblock the pipeline:
Accept cannot apply. The rule uses
create_channel, so both provider copies of a slot carry the same stream name;POST /{id}/acceptcannot disambiguate which copy to attach, and when the candidate channel is gone it returns 404 with the row leftpending(backend/routers/channel_merges.py:1351-1366).Dismiss does not stick.
POST /{id}/dismissflips the row todismissed(channel_merges.py:1648-1652), and the partial unique index that keeps the queue idempotent only covers pending rows:(
backend/models.py:3328-3335)The next M3U refresh re-scores the same stream against the same candidate, the collision recovery query filters
status == "pending"(m3u_dedup_hook.py:234-243), finds nothing, and inserts a fresh pending row. Dismissal is per-row, not sticky per(stream_name, candidate_channel_id)pair.Rows never expire. There is no reaper, no
last_seen_at, and nothing that reacts to the candidate channel disappearing. Theauto_aged_outaction_type is a reserved enum value in migration 0014 (:66,:289-291) that no code ever writes, andidx_pending_merges_candidateis documented as "offline orphan detection (§D4)" (models.py:3320-3322) but is never queried. Pending rows whose candidate channel no longer exists block forever; they are neutralised only by accident, when Pass 4 orphan reconciliation deletes the previous day's channels (engine.py:6021) and the candidate ceases to exist.Second, simpler bug in the same path. The candidate set includes channels the stream is already attached to, so a stream can be queued as a duplicate of the channel it is on, at confidence 1.0. One of the rows observed in the live incident was exactly that. Nothing in
_maybe_enqueue_pending_mergeconsults the channel'sstreamslist.Diagnosability makes it invisible.
results["pending_merges_added"]exists (engine.py:2898, aggregated at:3422) and reaches the toast atbackend/tasks/channel_pipeline.py:755/:812/:941— but only inside theif not has_failed_actions:branch (:805). A run with any failed action skips that notification entirely and emits only the task-layer "Task Completed with Warnings". The returned summary is:(
tasks/channel_pipeline.py:891-893) — which is indistinguishable from "the provider had nothing today", and names neither the deferral nor the blocking row ids. (The engine log line atengine.py:757-762has the same gap.)Dry runs cannot see any of this. The hook is bypassed before it reads the queue when
exec_ctx.dry_runis true (executor.py:6183-6189), so a preview reports channels as createable that the live run then defers. The asymmetry is itself a diagnosability bug: preview and run disagree about what will happen.Steps to Reproduce
EVENT SLOT 04: <Match A> @ <yesterday> ..., created the previous day from slot 04 by anm3u_refresh-triggeredcreate_channelrule ({stream_name}name template,if_exists: merge) with a followingassign_epgaction.<Match B> @ <today> ...and the M3U refresh runs.assign_epgfails withNo channel context for assign_epg, and the run summary reads0 channels createdwith N failed actions.Reproduction of the score itself, with no Dispatcharr needed:
Screenshots / Logs / Artifacts
Some of it is in the debug bundle I'll attach privately if it helps, but the reasoning above stands on its own.
Additional Context
Dispatcharr 0.31.0.
Proposed fix (happy to implement, but see the scope question).
@ 18 Sep 09:30 AM GMT-1and variants) from both sides before scoring, or exclude those tokens from the score. The rule the scorer should encode: a next-day stream in the same slot is a rollover, not a duplicate — when the only strong similarity signal is the static slot prefix and the dynamic part differs, do not queue; create. This is ADR-008 §D2 territory (the 0.60 floor and the operator threshold are untouched), so it likely wants an ADR addendum rather than a silent behaviour change.executor.py:6198-6210); the channel'sstreamslist is available at that point.channel_merges.py:1351-1366) instead of blocking forever; and make dismissal sticky per(stream_name, candidate_channel_id)pair rather than per row (models.py:3328-3335,m3u_dedup_hook.py:234-243). Terminal rows already persist, so a sticky dismissal can be enforced by consulting them at enqueue time without a schema change.0 channels created; the deferral reason should be carried into theassign_epgaction error so a single deferred slot does not read as an EPG bug; and the dry run should surface pending merges so preview and run agree.Tests I would add: scorer with date/time tokens stripped (same-slot
@ 18 Sepvs@ 17 Sepdoes not reach the queue threshold; genuinely identical names still do); stream already attached to the candidate → not queued; pending row whose candidate channel 404s → auto-resolved and the pipeline proceeds; dismissed stream+candidate pair not re-queued on the next refresh; integration rollover scenario (day-old channels + refreshed slot names → one run creates today's channels, zero new pending rows).Scope question. Items 1 and 4 are self-contained and are what actually stops the daily freeze. Item 3 touches queue semantics. I would rather land 1+2+4 first and treat 3 as a follow-up if you agree — say the word and I will open a PR against
devscoped accordingly.