Files
UmbraTrans/umbratrans_web/tests/test_assignments.py
umbra audio d6cebe4d55 The controller assigns work, and a job is done only when the artifacts say so
Replaces claiming with assignment. The controller hands a job to one or more nodes and a
node asks what it has been given; jobs.lease_owner could name only one holder, so a job
fanned out to several machines was inexpressible, and 'what was assigned and what is not
complete' had no answer. job_assignments is one row per (job, node, attempt), so both are
now readable.

Nodes poll rather than being pushed, which is not a simplification: nodes sit behind NAT
and the controller has no route to them. The only path that exists is a node calling out.

The rule with teeth: a job is complete when the controller has the artifacts to show it.
Reporting is not believing. A node that finishes and hands over nothing gets its
assignment failed, with the reason naming what was missing, and the work stays available
for another machine. The tempting implementation - mark complete, then verify afterwards -
cannot fail, because the only thing it could compare against is the report it just
trusted. artifacts_verified_at is written by verification alone, so 'the node says it is
done' and 'the controller confirmed it' are different facts that cannot look alike.

fan_out defaults to 1 and is never inferred. More than one is redundant execution: several
machines do the same work and the first verified result wins, the rest are cancelled.
Worth having for cheap idempotent work, wasteful for anything costing GPU-seconds, so a
caller has to say so. The losers are cancelled rather than left running - otherwise every
machine on the list burns its time on a result nobody will read.

Four defects found while building it, all by tests written first:

- Naming a node explicitly bypassed the drain check. An administrator could drain a
  machine, see it read as drained, and still hand it work: the control doing nothing while
  appearing to work. Status is now checked on both routes.
- attempts were counted from jobs.attempt, which only claim_job incremented, so under
  assignments max_attempts was never reached and a failing job was re-offered forever.
  Counted from failed assignments now, so a fan-out to three costs one attempt, not three.
- start_assignment reported 'not startable' when the real problem was that the assignment
  belonged to another node. Ownership is checked first, because an operator handed the
  first will look in the wrong place.
- The artifact API accepted values the database rejects: role 'output' where
  job_artifacts_role_check allows only 'consumed' and 'produced', an unbounded status, a
  content_hash that was not 64 hex characters, and kind at 80 where the limit is 64. Every
  completion would have been a 500. The schema had fixed this vocabulary all along and
  the API had to learn it rather than invent it.

Artifacts are recorded even when verification fails: a short run still produced evidence,
and discarding it would destroy the record of why. storage_key is never treated as proof
the bytes exist, because there is no object storage to ask yet (DECISION-6).

Claim still exists. It is listed in the pinned node surface as pending removal and the
next change takes it out - two paths handing out work is the state this project keeps
finding and criticising.

Mutation-verified: trusting the node fails 3 tests, dropping the drain check fails 1,
leaving fan-out losers running fails 1.

674 passed, 4 skipped.
2026-10-04 18:48:05 -07:00

568 lines
21 KiB
Python

"""The controller assigns work, and a job is done when the controller can prove it.
Two rules, and the second is the one with teeth:
* **The controller assigns.** A node no longer claims work; it asks what it has been
given. ``jobs.lease_owner`` could name only one holder, so a job fanned out to several
machines was inexpressible, and "what was assigned and what is not complete" had no
answer.
* **A job is complete when the controller has the artifacts to show it.** Not when a node
says so. A node reporting success with nothing behind it is a failure, recorded as one,
with the reason naming what was missing.
The second rule is easy to state and easy to implement wrongly: the tempting version
marks the job complete inside the same transaction that accepted the node's report, and
then verifies afterwards "as a check". That version cannot fail, because the only thing it
could have compared against is the report it just trusted.
"""
from __future__ import annotations
import os
from uuid import uuid4
import pytest
from umbratrans_web.compute import COMPLETE, FAILED, QUEUED, RUNNING, ComputeRepository
from umbratrans_web.tests.test_compute import _device, _node, _online
INTEGRATION_ENV = "UMBRATRANS_TEST_DSN"
def _integration_enabled() -> bool:
return bool(os.environ.get(INTEGRATION_ENV)) and os.environ.get(
"UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE", ""
) in {"1", "true", "yes", "on"}
integration = pytest.mark.skipif(
not _integration_enabled(),
reason=f"needs {INTEGRATION_ENV} and an acknowledged disposable database",
)
@pytest.fixture
def compute():
"""A repository against the scratch database, emptied on teardown.
Duplicated from `test_compute.py` rather than moved to `conftest.py`: those fixtures
are module-local on purpose, and hoisting them would make every file's fixtures depend
on one file's imports. The cost is that a change to the teardown list has to be made
twice, which is the smaller of the two mistakes.
"""
from umbratrans_web.tests.test_compute import _require_scratch_database
_require_scratch_database()
repository = ComputeRepository(
os.environ[INTEGRATION_ENV], min_size=1, max_size=6, passwords=None
)
try:
yield repository
with repository._connection() as connection, connection.transaction(): # noqa: SLF001
connection.execute(
"TRUNCATE public.job_events, public.jobs, public.job_assignments, "
"public.job_artifacts, public.artifacts, public.node_heartbeats, "
"public.node_models, public.node_credentials, public.compute_devices, "
"public.compute_nodes, public.model_registry CASCADE"
)
finally:
repository.close()
@pytest.fixture
def organization(compute):
"""One organization, seeded directly.
Created in the repository rather than through the service because these tests are
about dispatch, and the service path would need an authenticated administrator to
exist first.
"""
identifier = uuid4()
with compute._connection() as connection, connection.transaction(): # noqa: SLF001
connection.execute(
"INSERT INTO public.organizations (id, name, slug) VALUES (%s, %s, %s)",
(identifier, "Assignment Test", f"assign-{identifier.hex[:8]}"),
)
return identifier
@pytest.fixture
def world(compute, organization):
"""Two online machines, a device each, and a project the jobs belong to.
The project is not incidental. `artifacts.project_id` is NOT NULL while
`jobs.project_id` is nullable, so a job with no project cannot produce an artifact and
therefore cannot be completed - every test here would be measuring a constraint
violation instead of the thing under test. Which is itself worth knowing: work that
produces output has to belong to something.
"""
from umbratrans_web.tenancy import tenant_search_path
project = uuid4()
with compute._connection() as connection, compute._tx(connection):
connection.execute(
"SELECT set_config('search_path', %s, true)", (tenant_search_path(organization),)
)
connection.execute(
"INSERT INTO projects (id, name, slug) VALUES (%s, %s, %s)",
(project, "Assigned Book", f"assigned-{project.hex[:8]}"),
)
first = _online(
compute,
_node(compute, organization, "assign-a"),
[_device(_node(compute, organization, "device-a"))],
)
second = _online(
compute,
_node(compute, organization, "assign-b"),
[_device(_node(compute, organization, "device-b"))],
)
return {
"compute": compute,
"organization": organization,
"project": project,
"a": first,
"b": second,
}
def _job(world, **overrides):
"""A job that belongs to the project's organization, so its artifacts can exist."""
kwargs = {"kind": "speak", "requested_by": "test", "project_id": world["project"]}
kwargs.update(overrides)
return world["compute"].submit_job(world["organization"], **kwargs)
def _artifact(**overrides) -> dict:
"""A well-formed artifact. Everything verification needs is present by default."""
spec = {
"kind": "audio",
"content_hash": "a" * 64,
"storage_key": "audio/chapter-001.mp3",
"byte_size": 1024,
"media_type": "audio/mpeg",
"role": "produced",
}
spec.update(overrides)
return spec
# -- the controller assigns ---------------------------------------------------
@integration
def test_a_job_goes_to_the_node_the_controller_named(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
created = compute.assign_job(job.id, node_ids=[a.id])
assert len(created) == 1
assert created[0].node_id == a.id
assert created[0].state == "assigned"
@integration
def test_a_job_is_not_running_until_a_node_starts_it(world) -> None:
"""Assignment is not progress.
A console that says ``running`` for a job nobody has picked up yet is claiming work
that has not happened, and the difference matters when an operator is deciding whether
the queue is moving.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
compute.assign_job(job.id, node_ids=[a.id])
assert compute.get_job(job.id).status == QUEUED, "an unstarted job claims to be running"
@integration
def test_a_node_sees_only_what_was_assigned_to_it(world) -> None:
"""A node's whole view of the queue, and it is only its own work."""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
mine = _job(world)
theirs = _job(world)
compute.assign_job(mine.id, node_ids=[a.id])
compute.assign_job(theirs.id, node_ids=[b.id])
seen_by_a = compute.assignments_for_node(a.id)
assert [one.job_id for one in seen_by_a] == [mine.id], (
f"node A was shown work that is not its own: {[str(one.job_id) for one in seen_by_a]}"
)
@integration
def test_polling_does_not_hand_back_work_already_in_hand(world) -> None:
"""An agent polls in a loop. Without this it would start the same job every time."""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
assert compute.assignments_for_node(a.id) == [], (
"a running assignment is offered again to the node already running it"
)
@integration
def test_work_comes_in_priority_order(world) -> None:
"""The controller expresses urgency through priority, so the node must honour it.
Ordered by assignment time instead, a node picks up whatever arrived first and the
priority column becomes decoration.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
low = _job(world, priority=900)
high = _job(world, priority=1)
compute.assign_job(low.id, node_ids=[a.id])
compute.assign_job(high.id, node_ids=[a.id])
seen = compute.assignments_for_node(a.id)
assert [one.job_id for one in seen] == [high.id, low.id], (
"the node was given the low-priority work first"
)
@integration
def test_a_drained_node_is_not_given_work_by_assignment(world) -> None:
"""The claim path checks status; the assignment path must too, or draining is theatre.
Draining a machine and then assigning to it by name would be the worst of both: the
operator's control appears to work, and a node explicitly switched off keeps receiving
work.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
compute.set_node_status(a.id, "draining")
created = compute.assign_job(job.id, node_ids=[a.id])
assert not any(one.node_id == a.id for one in created), (
"a drained node was assigned work"
)
assert compute.assignments_for_job(job.id) == []
@integration
def test_one_node_cannot_start_another_nodes_assignment(world) -> None:
"""Guessing an id must not put a machine's name on work it never did."""
from umbratrans_web.errors import ConflictError
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
with pytest.raises(ConflictError) as error:
compute.start_assignment(assignment.id, b.id)
assert error.value.code == "assignment_not_yours", (
f"the refusal does not say the assignment is another node's: {error.value}"
)
assert compute.assignments_for_job(job.id)[0].state == "assigned"
# -- a job is complete when the controller has the artifacts ------------------
@integration
def test_completing_with_artifacts_completes_the_job(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
outcome = compute.complete_assignment(
assignment.id, a.id, artifacts=[_artifact()], result={"chapters": 1}
)
assert outcome.verified is True
assert outcome.job_status == COMPLETE
assert compute.get_job(job.id).status == COMPLETE
assert outcome.assignment.artifacts_verified_at is not None
@integration
def test_a_node_reporting_success_with_nothing_behind_it_does_not_complete_the_job(world) -> None:
"""The rule with teeth.
The node says it finished. The controller looks for artifacts, finds none, and records
a failure - because "a job is complete when the controller has the artifacts to show
it" means the node's report is a claim to be checked, not a fact to be recorded.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
outcome = compute.complete_assignment(assignment.id, a.id, artifacts=[], result={"chapters": 1})
assert outcome.verified is False
assert compute.get_job(job.id).status != COMPLETE, (
"the job was completed on the node's word alone, with no artifact behind it"
)
assert outcome.missing, "the failure does not say what was missing"
@integration
def test_an_artifact_without_a_hash_is_not_stored_and_does_not_count(world) -> None:
"""Otherwise verification would be checking the node's report against itself.
An artifact with neither a hash nor a storage key is a claim. Accepting it would mean
the verification step compares a node's report with the same node's report, which is
not a check at all - and it would pass.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
outcome = compute.complete_assignment(
assignment.id, a.id, artifacts=[{"kind": "audio", "byte_size": 10}]
)
assert outcome.verified is False, "a hashless artifact was accepted as proof"
assert outcome.artifacts_recorded == []
@integration
def test_verification_is_recorded_separately_from_the_node_saying_so(world) -> None:
"""`succeeded` and `verified` are different facts and must not look alike.
If they collapse, then a node's report is the definition of done, and the column that
records independent confirmation has nothing to record.
"""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
compute.complete_assignment(assignment.id, a.id, artifacts=[_artifact()])
stored = compute.assignments_for_job(job.id)[0]
assert stored.state == "succeeded"
assert stored.is_verified is True
assert stored.artifacts_verified_at is not None
@integration
def test_the_artifact_is_linked_to_the_job_so_the_answer_is_readable(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
outcome = compute.complete_assignment(assignment.id, a.id, artifacts=[_artifact()])
assert len(outcome.artifacts_recorded) == 1
with compute._connection() as connection:
linked = connection.execute(
"SELECT count(*) AS total FROM public.job_artifacts WHERE job_id = %s", (job.id,)
).fetchone()["total"]
assert linked == 1, "the artifact is not linked to the job, so nothing records what it produced"
# -- more than one node -------------------------------------------------------
@integration
def test_a_job_can_be_given_to_several_machines_at_once(world) -> None:
"""What `lease_owner` could not express."""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
created = compute.assign_job(job.id, node_ids=[a.id, b.id], fan_out=2)
assert {one.node_id for one in created} == {a.id, b.id}
assert len(compute.assignments_for_job(job.id)) == 2
@integration
def test_one_machine_is_not_given_the_same_job_twice(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
first = compute.assign_job(job.id, node_ids=[a.id])
second = compute.assign_job(job.id, node_ids=[a.id])
assert len(first) == 1
assert len(compute.assignments_for_job(job.id)) == 1, "the same node holds the job twice"
@integration
def test_the_first_verified_machine_wins_and_the_others_are_cancelled(world) -> None:
"""Otherwise every machine on the list burns its time on a result nobody reads."""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
created = compute.assign_job(job.id, node_ids=[a.id, b.id], fan_out=2)
by_node = {one.node_id: one for one in created}
compute.start_assignment(by_node[a.id].id, a.id)
outcome = compute.complete_assignment(by_node[a.id].id, a.id, artifacts=[_artifact()])
assert outcome.verified is True
assert str(b.id) in outcome.superseded_nodes
loser = next(
one for one in compute.assignments_for_job(job.id) if one.node_id == b.id
)
assert loser.state == "cancelled", "the losing machine is still holding an open assignment"
assert compute.get_job(job.id).status == COMPLETE
@integration
def test_a_machine_giving_up_does_not_disturb_one_still_working(world) -> None:
"""With a fan-out the job is not waiting for anybody.
Requeuing or failing the job while another machine is mid-flight is how a running job
gets handed out a second time.
"""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
created = compute.assign_job(job.id, node_ids=[a.id, b.id], fan_out=2)
by_node = {one.node_id: one for one in created}
compute.start_assignment(by_node[b.id].id, b.id)
compute.start_assignment(by_node[a.id].id, a.id)
compute.fail_assignment(by_node[a.id].id, a.id, error="gpu caught fire")
assert compute.get_job(job.id).status == RUNNING, (
"the job was moved out from under the machine that is still working on it"
)
assert next(
one for one in compute.assignments_for_job(job.id) if one.node_id == b.id
).state == "running"
@integration
def test_when_nothing_is_left_open_the_work_is_offered_again(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
compute.fail_assignment(assignment.id, a.id, error="out of memory")
assert compute.get_job(job.id).status == QUEUED, (
"the job is neither running nor waiting for anybody, so nothing will pick it up"
)
@integration
def test_work_is_offered_again_until_the_attempts_run_out(world) -> None:
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world, max_attempts=1)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
compute.fail_assignment(assignment.id, a.id, error="out of memory")
assert compute.get_job(job.id).status == FAILED, (
"the job keeps being re-offered after its attempts are spent, so it never ends"
)
# -- cancelling ---------------------------------------------------------------
@integration
def test_cancelling_one_attempt_leaves_the_job_for_the_others(world) -> None:
"""Cancelling an assignment is not cancelling the job.
Conflating them is how a job dies because one machine was withdrawn while three
others were still entitled to finish it.
"""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
created = compute.assign_job(job.id, node_ids=[a.id, b.id], fan_out=2)
by_node = {one.node_id: one for one in created}
compute.cancel_assignment(by_node[a.id].id, reason="machine withdrawn")
assert compute.get_job(job.id).status != FAILED
assert next(
one for one in compute.assignments_for_job(job.id) if one.node_id == b.id
).state == "assigned", "the other machine lost its assignment when one was cancelled"
@integration
def test_cancelling_records_why(world) -> None:
"""A month later, withdrawn and superseded look identical unless something says so."""
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.cancel_assignment(assignment.id, reason="machine withdrawn for reimage", actor="ops")
events = [event["event"] for event in compute.job_events(job.id)]
assert "cancelled" in events
details = [
event["detail"]
for event in compute.job_events(job.id)
if event["event"] == "cancelled"
]
assert any("reimage" in str(detail) for detail in details), (
f"the cancellation was recorded without its reason: {details}"
)
@integration
def test_a_finished_job_cannot_be_assigned(world) -> None:
"""Handing completed work to a machine is duplicated effort with a real cost."""
from umbratrans_web.errors import ConflictError
compute, organization, a, _b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
compute.complete_assignment(assignment.id, a.id, artifacts=[_artifact()])
with pytest.raises(ConflictError):
compute.assign_job(job.id, node_ids=[a.id])
@integration
def test_a_failed_verification_leaves_the_work_available(world) -> None:
"""Not lost, and not completed: available for another machine to be given.
A node reporting success with no artifact is a failed *attempt*. If that finished the
job, one broken node would silently end a queue.
"""
compute, organization, a, b = world["compute"], world["organization"], world["a"], world["b"]
job = _job(world)
assignment = compute.assign_job(job.id, node_ids=[a.id])[0]
compute.start_assignment(assignment.id, a.id)
compute.complete_assignment(assignment.id, a.id, artifacts=[])
assert compute.get_job(job.id).status == QUEUED
retry = compute.assign_job(job.id, node_ids=[b.id])
assert len(retry) == 1, "the work cannot be given to another machine after a failed attempt"