feat(products): confirm → fetching_images + post-commit fetch scheduling + startup recovery (SD-0002 §6.5.3/§6.9)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:30:08 -07:00
parent 23267c4d4c
commit 5d5341b29b
3 changed files with 39 additions and 2 deletions
+3 -1
View File
@@ -132,11 +132,13 @@ def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
run_id = repo.insert_run( run_id = repo.insert_run(
conn, storefront_id, account_id, row["file_name"], row["dialect"], conn, storefront_id, account_id, row["file_name"], row["dialect"],
added=summary_counts["adds"], updated=summary_counts["updates"], added=summary_counts["adds"], updated=summary_counts["updates"],
errored=len(error_rows), status="complete", errored=len(error_rows), status="applying",
) )
for plan in diff_result.plan: for plan in diff_result.plan:
_apply_product_plan(conn, storefront_id, plan, run_id) _apply_product_plan(conn, storefront_id, plan, run_id)
repo.insert_run_errors(conn, run_id, error_rows) repo.insert_run_errors(conn, run_id, error_rows)
pending = repo.run_image_counts(conn, run_id)["pending"]
repo.set_run_status(conn, run_id, "fetching_images" if pending else "complete")
repo.delete_draft(conn, storefront_id, draft_id) repo.delete_draft(conn, storefront_id, draft_id)
conn.commit() conn.commit()
except Exception as exc: except Exception as exc:
+16 -1
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import logging import logging
import sys import sys
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -24,6 +25,7 @@ from pydantic import BaseModel
from app.domains import accounts, products, storefronts from app.domains import accounts, products, storefronts
from app.platform import config, db from app.platform import config, db
from app.platform import mailer as mailer_mod from app.platform import mailer as mailer_mod
from app.platform import objectstore as objectstore_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
from app.platform.mailer import Mailer from app.platform.mailer import Mailer
from app.platform import session as session_mod from app.platform import session as session_mod
@@ -117,13 +119,22 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
app.state.pool = db.open_pool(dsn) app.state.pool = db.open_pool(dsn, max_size=10)
with app.state.pool.connection() as conn: with app.state.pool.connection() as conn:
db.migrate(conn) # self-migrate at startup (INV-1, INV-7) db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8 app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind())
app.state.image_allow_private = config.image_fetch_allow_private()
app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run")
# §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path.
app.state.image_runner.submit(
products.recover_incomplete_runs, app.state.pool,
app.state.objectstore, app.state.image_allow_private,
)
try: try:
yield yield
finally: finally:
app.state.image_runner.shutdown(wait=False)
app.state.pool.close() app.state.pool.close()
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan) app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
@@ -316,6 +327,10 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
409, "nothing_to_apply", 409, "nothing_to_apply",
"Nothing to change — your catalog already matches this file.", "Nothing to change — your catalog already matches this file.",
) )
app.state.image_runner.submit(
products.run_image_phase, app.state.pool, app.state.objectstore,
run_id, app.state.image_allow_private,
)
return JSONResponse(status_code=201, content={"run_id": run_id}) return JSONResponse(status_code=201, content={"run_id": run_id})
@app.delete("/api/products/imports/drafts/{draft_id}") @app.delete("/api/products/imports/drafts/{draft_id}")
+20
View File
@@ -220,3 +220,23 @@ def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_prop
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"] events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events) assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
def test_confirm_marks_run_fetching_images_when_images_present(migrated_conn, merchant):
csv = b"Handle,Title,Image Src\nlamp,Lamp,https://m.example/a.png\n"
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
run_id = products.confirm_draft(
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
assert run["status"] == "fetching_images"
assert run["image_progress"]["total"] >= 1
def test_confirm_marks_run_complete_when_no_images(migrated_conn, merchant):
csv = b"Handle,Title,Vendor\nlamp,Lamp,Acme\n"
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
run_id = products.confirm_draft(
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
assert products.get_run(migrated_conn, merchant["storefront_id"], run_id)["status"] == "complete"