Summary
A single duplicate-logo 400 from Dispatcharr aborts an entire channel-pipeline commit (POST /api/channel-pipeline/run/commit) with zero writes landed. The failing write is a create_logo that ECM's create_channel path adds for the matched stream's tvg-logo URL, even when the rule has no logo action. This has repeatedly killed a scheduled auto-creation rule in production (~15 event channels per run) and re-arms itself every cycle.
Root cause
- Dispatcharr's
POST /api/channels/logos/ returns 400 when a logo row with the same url already exists.
- Logo rows outlive their channels: deleting a channel does not delete its logo row, so a row left over from a previous event cycle collides with the next run.
ActionExecutor._resolve_logo_id creates first and only falls back to find_logo_by_url when the create raises. Under PlanningDispatcharrClient the create never reaches Dispatcharr, so the plan records a create_logo write with a temp id for every channel whose logo already exists.
replay_write_plan is fail-fast: the real create_logo raises on the 400, the replay raises PartialReplayError: pipeline replay failed at write 0, compensation runs, and the endpoint returns 502. Every planned channel create, stream merge and EPG assignment behind it is lost.
- The generic
_request log line says only [DISPATCHARR] API request failed: POST - status: 400; the body that names the duplicate URL is not logged anywhere.
Evidence (2026-09-16, UTC)
- 20:09–20:30: five commit attempts (including a minimal 3-write probe rule) all returned 502, zero completed writes, empty journal.
- Container log at the failure:
POST http://<dispatcharr>/api/channels/logos/ "HTTP/1.1 400 Bad Request"
[DISPATCHARR] API request failed: POST - status: 400
POST /api/channel-pipeline/run/commit -> 502 in 1542.1ms
- UI:
PartialReplayError: pipeline replay failed at write 0.
- Every write primitive succeeds individually (single/bulk channel create, stream merge, EPG assign); only the replay dies, always on the logo POST.
- Hand-POSTing
/api/channels/logos/ with the streams' exact tvg-logo URL returns 400 because a row with that URL exists.
- Workaround that restored service: deleting the orphan logo row (an April leftover,
channel_count=0, URL identical to the streams' logo) → the next commit succeeded (15 channels created, 15 merges). That run created a new logo row with the same URL, so the next rollover collides again.
Repro
- Ensure a Dispatcharr logo row exists whose
url equals a matched stream's tvg-logo (or POST the same logo twice; the second call returns 400).
- Run any pipeline rule with
create_channel for that stream: prepare → 200; commit → 502, zero writes, PartialReplayError at write 0.
Proposed fix
- Executor (
channel_pipeline_executor.py::_resolve_logo_id): look the URL up before creating, so a known logo produces no create_logo write in the plan and the created channel references the existing row.
- Client (
dispatcharr_client.py::create_logo): idempotent on URL. Resolve an existing row before the POST and again after a 400 (row created in between); include the response body in the failure log.
- Replay (
services/pipeline_write_plan.py::replay_write_plan): treat create_logo as a soft-fail write classified by recorded method, not HTTP status. Log, skip, map its temp id to None, continue. Structural writes (channel create, stream merge, EPG, profile membership) keep the fail-fast abort and compensation.
Acceptance criteria
- Commit succeeds end-to-end with a pre-existing colliding logo row; the created channel references the existing logo id.
- No behaviour change when logos are new or unique.
- Replay abort semantics preserved for non-logo writes.
Out of scope
Summary
A single duplicate-logo
400from Dispatcharr aborts an entire channel-pipeline commit (POST /api/channel-pipeline/run/commit) with zero writes landed. The failing write is acreate_logothat ECM'screate_channelpath adds for the matched stream'stvg-logoURL, even when the rule has no logo action. This has repeatedly killed a scheduled auto-creation rule in production (~15 event channels per run) and re-arms itself every cycle.Root cause
POST /api/channels/logos/returns400when a logo row with the sameurlalready exists.ActionExecutor._resolve_logo_idcreates first and only falls back tofind_logo_by_urlwhen the create raises. UnderPlanningDispatcharrClientthe create never reaches Dispatcharr, so the plan records acreate_logowrite with a temp id for every channel whose logo already exists.replay_write_planis fail-fast: the realcreate_logoraises on the 400, the replay raisesPartialReplayError: pipeline replay failed at write 0, compensation runs, and the endpoint returns502. Every planned channel create, stream merge and EPG assignment behind it is lost._requestlog line says only[DISPATCHARR] API request failed: POST - status: 400; the body that names the duplicate URL is not logged anywhere.Evidence (2026-09-16, UTC)
PartialReplayError: pipeline replay failed at write 0./api/channels/logos/with the streams' exacttvg-logoURL returns 400 because a row with that URL exists.channel_count=0, URL identical to the streams' logo) → the next commit succeeded (15 channels created, 15 merges). That run created a new logo row with the same URL, so the next rollover collides again.Repro
urlequals a matched stream'stvg-logo(or POST the same logo twice; the second call returns 400).create_channelfor that stream: prepare → 200; commit → 502, zero writes,PartialReplayErrorat write 0.Proposed fix
channel_pipeline_executor.py::_resolve_logo_id): look the URL up before creating, so a known logo produces nocreate_logowrite in the plan and the created channel references the existing row.dispatcharr_client.py::create_logo): idempotent on URL. Resolve an existing row before the POST and again after a 400 (row created in between); include the response body in the failure log.services/pipeline_write_plan.py::replay_write_plan): treatcreate_logoas a soft-fail write classified by recorded method, not HTTP status. Log, skip, map its temp id toNone, continue. Structural writes (channel create, stream merge, EPG, profile membership) keep the fail-fast abort and compensation.Acceptance criteria
Out of scope
429handling in the client (covered by [Bug]: /run/commit returns 502 on the first Dispatcharr 429; replay is fail-fast and the body cannot tell the caller whether retry is safe #1009 / v0.18.2-0039: fix #1009 planned commit partial-failure reporting and Dispatcharr 429 backoff #1010).