feat(tradein): in-app scheduler — UI-managed schedule (replaces SSH crontab) #482
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#482
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-scheduler-backend"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
In-app scheduler: backend periodically (60s tick) проверяет
scrape_schedulestable → автоматически запускает city sweep если due (next_run_at ≤ NOW + enabled + no running). Recompute next_run_at для следующих суток в random время в окне. Заменяет SSH crontab setup на UI-managed config в БД.LOC: +443 / -2 (6 files).
Changes
data/sql/052_scrape_schedules.sqlavito_city_sweepwindow 2-5 UTC (05:00-08:00 МСК), enabledservices/scheduler.pycompute_next_run_at()(random hour в окне, cross-midnight OK) +scheduler_loop()async +reap_zombies()(runs > 6h без heartbeat → zombie) +trigger_avito_city_sweep_run()main.pyapi/v1/admin.pyGET /admin/scrape/schedules,PUT /admin/scrape/schedules/{source}schemas/trade_in.pyScheduleConfig+ScheduleConfigUpdatePydantictests/test_scheduler.pyVerify
test_scheduler.py6/6 passtest_city_sweep.py+test_api_avito_stage4a.py21/21 passDeviations
INTERVAL '{N} hours'в SQL →CAST(:interval AS interval)параметр (no string interp)task.add_done_callbackдля asyncio.create_task (RUF006)assert row is not Noneв update_schedule (защита от None)Behavior
scheduler: triggered city_sweep run_id=N next_run_at=ISOAuth
Все endpoints без
require_admin(PR #474 baseline). Caddy basic_auth — единственный gate.Test plan
scrape_schedulesrow для 'avito_city_sweep' exists с window 2-5 UTCscheduler: started+scheduler task spawned/admin/scrape/schedules/avito_city_sweepчерез UI → verify next_run_at recomputed/admin/scrape/avito-city-sweep/runsFrontend
Parallel PR
feat/tradein-frontend-schedulerдобавляет UI секцию (toggle enabled, window МСК ввод, status preview).Cron-script backward compat
deploy/avito-city-sweep.shостаётся в репо как backup опция. Никаких изменений — opt-in для тех кто хочет cron вместо in-app scheduler. После deploy this PR — рекомендация удалить crontab entry чтобы избежать double-trigger.Background scheduler в FastAPI lifespan: each tick (60s) checks scrape_schedules table, triggers city sweep run если due (next_run_at <= NOW() + enabled + no running run), recomputes next_run_at для следующих суток в random время в окне. - 052_scrape_schedules.sql: NEW table (source, enabled, window_start/end_hour UTC, default_params jsonb, last_run_id, last_run_at, next_run_at, updated_at). Seed: 'avito_city_sweep' enabled, window 2-5 UTC (05:00-08:00 МСК). - scheduler.py NEW: * compute_next_run_at(start, end) — random datetime в окне для +1 day. Cross-midnight OK. * scheduler_loop() — tick каждые 60s * get_due_schedules / has_running_run / trigger_avito_city_sweep_run * reap_zombies() — runs running > 6h без heartbeat → status='zombie' - main.py: FastAPI lifespan launches scheduler_task; clean shutdown через cancel - admin.py: 2 endpoints * GET /admin/scrape/schedules — list all (now single row, extensible) * PUT /admin/scrape/schedules/{source} — update config (enabled, window, params) + auto-recompute next_run_at - schemas/trade_in.py: ScheduleConfig + ScheduleConfigUpdate - tests/test_scheduler.py: 6 offline tests (normal window, cross-midnight, 1h window, future check, timezone-aware check) UI: frontend PR добавит секцию 6 на /scrapers/avito (parallel PR). Cron-script deploy/avito-city-sweep.sh остаётся как backup, opt-in.Merged via deep-code-reviewer — verdict APPROVE (minor advisories).
Verification summary
Migration 052 idempotency: PASS
BEGIN; ... COMMIT;wrap correctCREATE TABLE IF NOT EXISTS scrape_schedules+CREATE INDEX IF NOT EXISTS scrape_schedules_next_run_idx— re-runnableINSERT ... ON CONFLICT (source) DO NOTHING— seed is safe on re-deployCHECKconstraints onwindow_start_hour/window_end_hour(0..23) — enforced at DB levellast_run_id REFERENCES scrape_runs(id)valid (migration 015 createdscrape_runs)scheduler_loop reliability: PASS
try/except Exception → logger.exceptionобёртка tick — loop не падает на одной ошибкеawait asyncio.sleep(SCHEDULER_TICK_SEC)всегда выполняется (после except)task.add_done_callback(...)— RUF006 compliant, task не GC'нутawait scheduler_taskсCancelledErrorswallow — clean shutdownSessionLocal()+db.close()в finally — нет утечки connections_run()тоже использует свойSessionLocal()— не делит сессию с tick'омcompute_next_run_at(): PASS
[2,5): random inrandint(2*3600, 5*3600-1)= hours 2/3/422→3: total_seconds = (24-22)+3 = 5h → first_half=2h в [22,24), вторая часть в [0,3). Math correct[3,4):randint(10800, 14399)→ строго hour=3tzinfo=UTC(datetime.combine сtzinfo=UTC)reap_zombies(): PASS
CAST(:interval AS interval)— psycopg v3 compliant, no SQL injectionheartbeat_at IS NULL OR heartbeat_at < NOW() - 6h— корректный predicatescrape_runs_zombies_idx (status, heartbeat_at)из миграции 015No-running-run guard: PASS
has_running_run()check insidetrigger_avito_city_sweep_run()— atomic per sessionstatus='running'от 1-го, skip → correctContract stability для PR #483 (frontend): PASS
GET /admin/scrape/schedules→list[ScheduleConfig]PUT /admin/scrape/schedules/{source}→ScheduleConfigScheduleConfigполя:id,source,enabled,window_start_hour,window_end_hour,default_params,last_run_id,last_run_at(ISO),next_run_at(ISO),updated_at(ISO)ScheduleConfigUpdatebody:enabled(default true),window_start_hour(default 2),window_end_hour(default 5),default_paramsnpm run codegenпосле deploy подтянет typespsycopg v3 compliance: PASS
:namebinding (CAST(:interval AS interval),CAST(:params AS jsonb))import json+json.dumps(...)для jsonb param — корректноAuth model: PASS
require_admin— соответствует PR #474 baseline (Caddy basic_auth — единственный gate)Minor advisories (post-merge, not blocking)
Crontab removal action required: после deploy надо удалить crontab entry
avito-city-sweep.shна VPS, иначе double-trigger (cron + in-app scheduler). Script остаётся в репо как backup —deploy/avito-city-sweep.sh. Команда:crontab -e→ удалить line сavito-city-sweep.sh.Single-replica assumption documented: если в будущем добавится 2-й backend replica → нужен advisory lock (
pg_try_advisory_lock) в начале tick'а чтобы один schedulers не дублировал triggers. Текущий pilot — single replica, OK.Vault entry: новый pattern (in-app scheduler заменяет cron) — желательно
code/modules/tradein/InApp_Scheduler.mdилиdecisions/2026-05-23-inapp-scheduler-replaces-cron.md. Не сделано в этом PR.db.commit()послеreap_zombies()в loop: можно объединить в одну транзакцию сget_due_schedules()+trigger_*но текущая разбивка читабельнее и race-condition-safe.Post-merge verify checklist
deploy-tradein.ymltriggers on push to mainscrape_schedulestable + seed row exists на продеscheduler: started (tick=60s)+FastAPI lifespan: scheduler task spawnedчерез ~30s after startGET /admin/scrape/schedules→ 1 row дляavito_city_sweepPUT /admin/scrape/schedules/avito_city_sweep→next_run_atrecomputednext_run_at→ checkGET /admin/scrape/avito-city-sweep/runs— auto-triggered run появился