Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/templates/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions src/templates/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 24 additions & 1 deletion src/templates/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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))
Expand Down
Loading