diff --git a/docs/architecture.md b/docs/architecture.md index 97e9934..15ad72c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,8 +179,10 @@ For example, `{"base_image":"sandbox:latest","setup_script":"echo ready"}` reuses a template only while the tag resolves to the same digest. A new digest creates a `building` record and returns `202 Accepted`. The build uses the saved digest reference even if the tag moves again. Callers poll until the -template becomes `available` or `failed`. A pull or store failure returns -`502`; a provider without template support returns `400`. +template becomes `available` or `failed`. A `POST` for a `failed` template +builds the same record again, and its `last_error` stays until that build +ends. A pull or store failure returns `502`; a provider without template +support returns `400`. Templates outlive hosts. Each provider owns these operations through `TemplateCapability`. Image pulls and store loads finish before the create diff --git a/src/templates/api.py b/src/templates/api.py index 314c8a9..b207b30 100644 --- a/src/templates/api.py +++ b/src/templates/api.py @@ -35,7 +35,7 @@ async def create_template( service: TemplateServiceDep, ) -> Template: try: - template, created = await service.get_or_create( + template, needs_build = await service.get_or_create( provider=payload.provider, base_image=payload.base_image, setup_script=payload.setup_script, @@ -52,7 +52,7 @@ async def create_template( detail="template creation could not be completed", ) from exc - if created: + if needs_build: background_tasks.add_task(service.build, template.id) return template diff --git a/src/templates/service.py b/src/templates/service.py index be46de8..0edb40c 100644 --- a/src/templates/service.py +++ b/src/templates/service.py @@ -92,10 +92,17 @@ async def get_or_create( .where(Template.base_image == resolved_base_image) .where(Template.base_image_ref == base_image_ref) .where(Template.setup_script_hash == setup_script_hash) + .with_for_update() ) ).scalar_one_or_none() if not winner: raise TemplateStateError("template creation race could not be resolved") + if winner.status == TemplateStatus.FAILED.value: + # last_error stays readable until the new build ends. + winner.status = TemplateStatus.BUILDING.value + winner.updated_at = now + await self.session.commit() + return winner, True return winner, False async def build(self, template_id: uuid.UUID) -> None: diff --git a/src/templates/tests/test_api.py b/src/templates/tests/test_api.py index 003fdf0..665adf8 100644 --- a/src/templates/tests/test_api.py +++ b/src/templates/tests/test_api.py @@ -126,6 +126,29 @@ async def test_duplicate_create_returns_existing_without_rebuilding(client, temp ] +async def test_create_builds_a_failed_template_again_and_keeps_its_error_while_it_builds( + client, template_provider +): + """A failed record builds again on the next create; its error stays until that build ends.""" + template_provider.build_error = OSError("builder crashed") + body = {"provider": template_provider.name, "setup_script": SETUP_SCRIPT, "label": "retry"} + first = await client.post("/templates", headers=AUTH_HEADERS, json=body) + template_provider.build_error = None + + second = await client.post("/templates", headers=AUTH_HEADERS, json=body) + polled = await client.get(f"/templates/{first.json()['id']}", headers=AUTH_HEADERS) + + assert second.status_code == 202 + assert second.json()["id"] == first.json()["id"] + assert second.json()["status"] == TemplateStatus.BUILDING.value + assert second.json()["last_error"] == "OSError: builder crashed" + assert polled.json()["status"] == TemplateStatus.AVAILABLE.value + assert polled.json()["last_error"] == "" + assert ( + template_provider.built == [(template_provider.base_image_ref, SETUP_SCRIPT, "retry")] * 2 + ) + + async def test_concurrent_creates_resolve_unique_index_race(template_provider): """Concurrent identical inserts converge on the unique-index winner.""" async with ( @@ -150,7 +173,7 @@ async def test_concurrent_creates_resolve_unique_index_race(template_provider): ) assert results[0][0].id == results[1][0].id - assert sorted(created for _, created in results) == [False, True] + assert sorted(needs_build for _, needs_build in results) == [False, True] async with async_session_factory() as session: count = await session.scalar(select(func.count()).select_from(Template))