feat(download): smarter backfill — time-boxed chunks, run-until-done (backend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m58s

Plan #693. Large-catalog backfill (Anduo) no longer sprints to the timeout
wall and dies as an error each run. Builds on the cursor checkpoint (#689).

- Time-boxed chunks: BACKFILL_TIMEOUT_SECONDS(1170)→BACKFILL_CHUNK_SECONDS(600),
  far under the 1350 soft limit. Hitting it = normal chunk boundary (the
  TimeoutExpired path already captures partial output + the cursor), not a
  near-wall death.
- Run-until-done state machine driven by config_overrides[_backfill_state]
  (running/complete/stalled). A running backfill auto-continues in chunks
  across ticks until gallery-dl exits cleanly (rc=0 = reached the bottom →
  'complete'); a safety-cap (BACKFILL_MAX_CHUNKS=200) + the #689 stall-guard
  pause a pathological walk as 'stalled'. Replaces the N-runs counter
  (backfill_runs_remaining repurposed as the cap countdown).
- Progress, not error: a chunk that timed out but advanced (cursor moved
  and/or files written) is reclassified TIMEOUT→PARTIAL (status 'ok').
- Retry storm tamed: gallery-dl retries 3→2, downloader timeout 120→60s, so
  one stuck CDN file fails in ~1-2 min not ~10 (Anduo #40838).
- API: POST /sources/{id}/backfill now takes {action: start|stop}; service
  start_backfill/stop_backfill; new enabled sources auto-arm run-until-done;
  source dict exposes backfill_state + backfill_chunks.

Frontend (Start/Stop control + state badge) lands in the next push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:02:46 -04:00
parent add1c1ad14
commit 96fffaff64
10 changed files with 365 additions and 310 deletions
+13 -14
View File
@@ -199,7 +199,7 @@ async def test_list_derives_next_check_at_when_last_checked_set(
@pytest.mark.asyncio
async def test_backfill_endpoint_arms_source(client, artist, db):
async def test_backfill_endpoint_start_and_stop(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill", enabled=True,
@@ -208,18 +208,21 @@ async def test_backfill_endpoint_arms_source(client, artist, db):
await db.commit()
sid = src.id
resp = await client.post(f"/api/sources/{sid}/backfill", json={"runs": 5})
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "start"})
assert resp.status_code == 200
body = await resp.get_json()
assert body["backfill_runs_remaining"] == 5
assert body["backfill_state"] == "running"
# GET reflects the new state.
# GET reflects the running state.
one = await client.get(f"/api/sources/{sid}")
assert (await one.get_json())["backfill_runs_remaining"] == 5
assert (await one.get_json())["backfill_state"] == "running"
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
assert (await stopped.get_json())["backfill_state"] is None
@pytest.mark.asyncio
async def test_backfill_endpoint_defaults_to_three(client, artist, db):
async def test_backfill_endpoint_defaults_to_start(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill-default", enabled=True,
@@ -228,26 +231,22 @@ async def test_backfill_endpoint_defaults_to_three(client, artist, db):
await db.commit()
resp = await client.post(f"/api/sources/{src.id}/backfill", json={})
body = await resp.get_json()
assert body["backfill_runs_remaining"] == 3
assert body["backfill_state"] == "running"
@pytest.mark.asyncio
async def test_backfill_endpoint_rejects_out_of_range(client, artist, db):
async def test_backfill_endpoint_rejects_bad_action(client, artist, db):
src = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-backfill-bad", enabled=True,
)
db.add(src)
await db.commit()
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"runs": 0})
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "nope"})
assert bad.status_code == 400
too_big = await client.post(
f"/api/sources/{src.id}/backfill", json={"runs": 99}
)
assert too_big.status_code == 400
@pytest.mark.asyncio
async def test_backfill_endpoint_404_when_source_missing(client):
resp = await client.post("/api/sources/999999/backfill", json={"runs": 3})
resp = await client.post("/api/sources/999999/backfill", json={"action": "start"})
assert resp.status_code == 404
+127 -155
View File
@@ -372,30 +372,16 @@ async def test_finalize_skipped_preserves_failures_clears_error(db):
assert row.last_checked_at is not None
# --- Plan #544: backfill lifecycle + PARTIAL → status=ok -------------------
# --- Plan #693: backfill state machine (time-boxed chunks, run-until-done) ---
@pytest.mark.asyncio
async def test_backfill_decrements_and_checkpoints_cursor(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A backfill run that did NOT finish the walk (subprocess budget cut it
short → success=False / rc!=0) decrements the run budget AND checkpoints
gallery-dl's last-emitted cursor, so the next run resumes instead of
re-walking from the top (plan #689, Anduo #40411)."""
def _backfill_svc(db, db_sync, tmp_path, result):
"""DownloadService wired with a fake gdl returning `result` + a real
importer (so an empty written_paths just attaches nothing)."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
source.backfill_runs_remaining = 3
await db.commit()
images_root = tmp_path / "images"
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
success=False, written_paths=[], files_downloaded=0,
stderr="[patreon][debug] Cursor: 03:PAGE2:xyz\n",
))
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
@@ -404,122 +390,138 @@ async def test_backfill_decrements_and_checkpoints_cursor(
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
fake_gdl = _fake_gdl_with_result(result)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
await svc.download_source(source.id)
remaining, overrides = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert remaining == 2
assert overrides.get("_backfill_cursor") == "03:PAGE2:xyz"
return svc, fake_gdl
@pytest.mark.asyncio
async def test_backfill_cursor_keeps_backfill_mode_after_budget_drained(
async def test_backfill_chunk_progress_advances_cursor(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A pending cursor with runs_remaining == 0 still runs in BACKFILL mode
(skip:True + the resume cursor threaded to gallery-dl), so a large walk
finishes across ticks without the operator re-triggering."""
from backend.app.services.download_service import DownloadService
from backend.app.services.gallery_dl import BACKFILL_SKIP_VALUE
from backend.app.services.importer import Importer
"""A running backfill chunk that didn't finish but advanced (new cursor)
stays 'running', checkpoints the cursor, bumps the chunk counter, and
spends one safety-cap chunk."""
_artist, source = seed_artist_and_source
source.backfill_runs_remaining = 0
source.config_overrides = {"_backfill_cursor": "03:RESUME:here"}
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 5
await db.commit()
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[], files_downloaded=0,
stderr="[patreon][debug] Cursor: 03:PAGE2:xyz\n",
))
await svc.download_source(source.id)
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "running"
assert co.get("_backfill_cursor") == "03:PAGE2:xyz"
assert co.get("_backfill_chunks") == 1
assert remaining == 4
@pytest.mark.asyncio
async def test_backfill_state_running_selects_backfill_mode_and_resumes(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""state=='running' drives backfill mode (skip:True + chunk budget) and
threads the stored cursor to gallery-dl as the resume point."""
from backend.app.services.gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
)
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:RESUME:here"}
source.backfill_runs_remaining = 5
await db.commit()
svc, fake_gdl = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[],
stderr="[patreon][debug] Cursor: 03:RESUME2:next\n",
))
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path / "images",
import_root=tmp_path / "images",
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
await svc.download_source(source.id)
kwargs = fake_gdl.download.call_args.kwargs
assert kwargs["skip_value"] is BACKFILL_SKIP_VALUE
assert kwargs["source_config"].resume_cursor == "03:RESUME:here"
assert kwargs["source_config"].timeout == BACKFILL_CHUNK_SECONDS
overrides = (await db.execute(
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert overrides.get("_backfill_cursor") == "03:RESUME2:next"
assert co.get("_backfill_cursor") == "03:RESUME2:next"
@pytest.mark.asyncio
async def test_backfill_completes_and_clears_cursor(
async def test_backfill_clean_exit_marks_complete(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A clean rc=0 finish means gallery-dl walked to the bottom — clear the
checkpoint and drop back to tick mode, regardless of files this run."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
"""A clean rc=0 chunk = gallery-dl reached the bottom → state 'complete',
cursor cleared, returns to tick mode."""
_artist, source = seed_artist_and_source
source.backfill_runs_remaining = 2
source.config_overrides = {"_backfill_cursor": "03:NEAR:bottom"}
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "03:NEAR:bottom"}
source.backfill_runs_remaining = 5
await db.commit()
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
stderr="[patreon][debug] Cursor: 03:LAST:page\n",
))
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path / "images",
import_root=tmp_path / "images",
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
await svc.download_source(source.id)
remaining, overrides = (await db.execute(
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "complete"
assert "_backfill_cursor" not in co
assert remaining == 0
assert "_backfill_cursor" not in (overrides or {})
@pytest.mark.asyncio
async def test_backfill_cursor_stuck_guard_gives_up(
async def test_backfill_cap_exhaustion_stalls(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""If two consecutive non-completing runs fail to advance the cursor,
drop the checkpoint + zero the budget so a wedged walk can't re-strand
forever (cf. download soft-limit ladder)."""
"""A progressing chunk that spends the last safety-cap chunk without
reaching the bottom pauses as 'stalled' (doesn't loop forever)."""
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 1
await db.commit()
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, written_paths=[],
stderr="[patreon][debug] Cursor: 03:MORE:left\n",
))
await svc.download_source(source.id)
remaining, co = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert co.get("_backfill_state") == "stalled"
assert remaining == 0
@pytest.mark.asyncio
async def test_backfill_stuck_guard_stalls_after_two_no_advance(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""Two consecutive chunks that fail to advance the cursor → 'stalled',
cursor cleared, so a wedged walk can't re-strand forever."""
from unittest.mock import MagicMock
from backend.app.services.download_service import DownloadService
_artist, source = seed_artist_and_source
source.backfill_runs_remaining = 0
source.config_overrides = {"_backfill_cursor": "stuck"}
source.config_overrides = {"_backfill_state": "running", "_backfill_cursor": "stuck"}
source.backfill_runs_remaining = 5
await db.commit()
svc = DownloadService(
@@ -528,109 +530,79 @@ async def test_backfill_cursor_stuck_guard_gives_up(
)
ctx = {
"source_id": source.id, "platform": "patreon",
"config_overrides": {"_backfill_cursor": "stuck"},
"backfill_runs_remaining": 0,
"config_overrides": {"_backfill_state": "running", "_backfill_cursor": "stuck"},
"backfill_runs_remaining": 5,
}
stuck = _make_fake_dl_result(success=False, stderr="Cursor: stuck\n")
# Run 1: cursor unchanged → 1 stall, checkpoint retained.
await svc._apply_backfill_lifecycle(ctx, stuck)
await db.commit()
overrides = (await db.execute(
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert overrides.get("_backfill_cursor") == "stuck"
assert overrides.get("_backfill_cursor_stalls") == 1
assert co.get("_backfill_state") == "running"
assert co.get("_backfill_cursor_stalls") == 1
# Run 2: still stuck → give up.
ctx["config_overrides"] = dict(overrides)
ctx["config_overrides"] = dict(co)
await svc._apply_backfill_lifecycle(ctx, stuck)
await db.commit()
remaining, overrides2 = (await db.execute(
select(Source.backfill_runs_remaining, Source.config_overrides)
.where(Source.id == source.id)
)).one()
assert remaining == 0
assert "_backfill_cursor" not in (overrides2 or {})
co2 = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert co2.get("_backfill_state") == "stalled"
assert "_backfill_cursor" not in co2
@pytest.mark.asyncio
async def test_backfill_auto_resets_on_clean_zero_files(
async def test_backfill_timeout_chunk_reclassified_to_ok(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""A clean run (rc=0) that downloaded zero files means the backfill
queue drained — reset to 0 immediately instead of wasting the rest of
the N-run budget on no-op walks."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
"""A backfill chunk that hit its time-box (TIMEOUT) but advanced is
reclassified to PARTIAL → event status 'ok' (progress, not error), and
consecutive_failures is not bumped."""
from backend.app.services.gallery_dl import ErrorType
_artist, source = seed_artist_and_source
source.backfill_runs_remaining = 3
source.config_overrides = {"_backfill_state": "running"}
source.backfill_runs_remaining = 5
await db.commit()
fake_result = _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
)
fake_gdl = _fake_gdl_with_result(fake_result)
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=False, files_downloaded=3,
error_type=ErrorType.TIMEOUT,
error_message="Download timed out",
stderr="[patreon][debug] Cursor: 03:NEXT:page\n",
))
event_id = await svc.download_source(source.id)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path / "images",
import_root=tmp_path / "images",
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
await svc.download_source(source.id)
remaining = (await db.execute(
select(Source.backfill_runs_remaining).where(Source.id == source.id)
ev = (await db.execute(
select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one()
assert remaining == 0
assert ev.status == "ok"
failures = (await db.execute(
select(Source.consecutive_failures).where(Source.id == source.id)
)).scalar_one()
assert failures == 0
@pytest.mark.asyncio
async def test_tick_mode_does_not_touch_backfill_counter(
async def test_tick_mode_when_not_running_leaves_state_untouched(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""When backfill_runs_remaining is already 0, downloads don't go
negative or otherwise mutate the counter."""
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
"""No _backfill_state → not backfilling; the lifecycle is a no-op and the
counter/state aren't mutated."""
_artist, source = seed_artist_and_source
assert source.backfill_runs_remaining == 0
assert (source.config_overrides or {}).get("_backfill_state") is None
fake_gdl = _fake_gdl_with_result(_make_fake_dl_result(
svc, _ = _backfill_svc(db, db_sync, tmp_path, _make_fake_dl_result(
success=True, written_paths=[], files_downloaded=0,
))
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=tmp_path / "images",
import_root=tmp_path / "images",
thumbnailer=Thumbnailer(images_root=tmp_path / "images"),
settings=sync_settings,
)
cred_service = CredentialService(db, CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True))
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=fake_gdl, importer=importer, cred_service=cred_service,
)
await svc.download_source(source.id)
remaining = (await db.execute(
select(Source.backfill_runs_remaining).where(Source.id == source.id)
co = (await db.execute(
select(Source.config_overrides).where(Source.id == source.id)
)).scalar_one()
assert remaining == 0
assert (co or {}).get("_backfill_state") is None
@pytest.mark.asyncio
+2 -2
View File
@@ -34,7 +34,7 @@ def test_timeout_ladder_keeps_subprocess_budgets_under_soft_limit():
preempts it. soft must in turn sit below the hard SIGKILL cap."""
from backend.app.services.gallery_dl import (
_DEFAULT_GDL_TIMEOUT_SECONDS,
BACKFILL_TIMEOUT_SECONDS,
BACKFILL_CHUNK_SECONDS,
)
from backend.app.tasks.download import (
DOWNLOAD_HARD_TIME_LIMIT,
@@ -42,7 +42,7 @@ def test_timeout_ladder_keeps_subprocess_budgets_under_soft_limit():
)
assert _DEFAULT_GDL_TIMEOUT_SECONDS < DOWNLOAD_SOFT_TIME_LIMIT
assert BACKFILL_TIMEOUT_SECONDS < DOWNLOAD_SOFT_TIME_LIMIT
assert BACKFILL_CHUNK_SECONDS < DOWNLOAD_SOFT_TIME_LIMIT
assert DOWNLOAD_SOFT_TIME_LIMIT < DOWNLOAD_HARD_TIME_LIMIT
+37 -20
View File
@@ -175,58 +175,75 @@ async def test_list_hides_sidecar_synthetic_anchors(db):
@pytest.mark.asyncio
async def test_set_backfill_runs_arms_source(db):
"""The service method overrides backfill_runs_remaining (regardless of
the auto-arm-on-create starting value) and returns the updated record
so the API can echo it back."""
async def test_start_backfill_arms_run_until_done(db):
"""start_backfill sets state=running + the chunk cap and clears any prior
cursor/chunk state, returning the updated record for the API to echo."""
from backend.app.services.source_service import BACKFILL_MAX_CHUNKS
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
updated = await svc.set_backfill_runs(rec.id, 5)
assert updated.backfill_runs_remaining == 5
# Simulate a prior, finished walk leaving stale checkpoint state.
await db.execute(
Source.__table__.update().where(Source.id == rec.id).values(
config_overrides={"_backfill_state": "complete", "_backfill_cursor": "old",
"_backfill_chunks": 7},
)
)
await db.commit()
db_value = (await db.execute(
select(Source.backfill_runs_remaining).where(Source.id == rec.id)
updated = await svc.start_backfill(rec.id)
assert updated.backfill_state == "running"
assert updated.backfill_chunks == 0
assert updated.backfill_runs_remaining == BACKFILL_MAX_CHUNKS
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert db_value == 5
assert co.get("_backfill_state") == "running"
assert "_backfill_cursor" not in co
@pytest.mark.asyncio
async def test_set_backfill_runs_rejects_out_of_range(db):
async def test_stop_backfill_returns_to_idle(db):
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice",
)
with pytest.raises(ValueError):
await svc.set_backfill_runs(rec.id, 0)
with pytest.raises(ValueError):
await svc.set_backfill_runs(rec.id, 11)
await svc.start_backfill(rec.id)
updated = await svc.stop_backfill(rec.id)
assert updated.backfill_state is None
assert updated.backfill_runs_remaining == 0
co = (await db.execute(
select(Source.config_overrides).where(Source.id == rec.id)
)).scalar_one()
assert "_backfill_state" not in (co or {})
@pytest.mark.asyncio
async def test_set_backfill_runs_raises_when_source_missing(db):
async def test_start_backfill_raises_when_source_missing(db):
svc = SourceService(db)
with pytest.raises(LookupError):
await svc.set_backfill_runs(99999, 3)
await svc.start_backfill(99999)
@pytest.mark.asyncio
async def test_new_enabled_source_starts_in_backfill_mode(db):
"""Plan #544 follow-up: freshly added enabled sources have no archive
yet, so the first few polls would blow the wall-clock cap in tick
mode. Pre-arm backfill so the initial walks use the longer timeout."""
"""Plan #693: freshly added enabled sources have no archive yet, so they
arm run-until-done backfill — state 'running' — to walk the full history
on the first ticks instead of blowing the wall-clock cap in tick mode."""
artist = await _artist(db, "Alice")
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice-new",
)
assert rec.backfill_runs_remaining == 3
assert rec.backfill_state == "running"
@pytest.mark.asyncio