131 lines
6.7 KiB
PL/PgSQL
131 lines
6.7 KiB
PL/PgSQL
-- 0006: a node can be removed from the fleet for good.
|
|
--
|
|
-- Adds `removed` to the node status vocabulary, and creates `job_assignments`, which is
|
|
-- how the controller hands work to a node from now on.
|
|
--
|
|
-- Two things here are deliberate and worth reading before changing either.
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 1. `removed`
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- Distinct from `retired`, and the distinction is the point:
|
|
--
|
|
-- retired the machine is out of service but the node is a record of it. It can be
|
|
-- returned to service by an administrator, and it still holds devices.
|
|
-- removed the machine is gone. Credentials revoked, devices and heartbeats deleted,
|
|
-- and the row kept only because `job_events.node_id` refers to it.
|
|
--
|
|
-- The row cannot be deleted. `job_events.node_id` and `node_pairing_codes.used_by_node_id`
|
|
-- both reference `compute_nodes` ON DELETE NO ACTION, so the DELETE is refused outright;
|
|
-- changing them to SET NULL does not help, because that is an UPDATE of `job_events` and
|
|
-- `job_events_append_only` raises on every update by design. Removing the row would mean
|
|
-- either losing the record of what the machine did, or weakening the guarantee that the
|
|
-- event trail is immutable. The trail is worth more than a tidy DELETE.
|
|
--
|
|
-- `removed` is terminal. There is no administrative path back out of it, so a machine
|
|
-- written off cannot be quietly resurrected - and a replacement machine pairs under a new
|
|
-- name derived from its own MAC, which is what makes the tombstone harmless.
|
|
|
|
ALTER TABLE public.compute_nodes DROP CONSTRAINT IF EXISTS compute_nodes_status_check;
|
|
ALTER TABLE public.compute_nodes ADD CONSTRAINT compute_nodes_status_check
|
|
CHECK (status = ANY (ARRAY[
|
|
'provisioning', 'online', 'draining', 'offline', 'retired', 'removed'
|
|
]));
|
|
|
|
-- A removed node must never be resurrected by a heartbeat. `heartbeat` promotes
|
|
-- `provisioning` and `offline` to `online`, and a removed node is neither, so the
|
|
-- existing CASE already leaves it alone. Asserting it here means a future edit to that
|
|
-- CASE cannot quietly start reviving removed machines, which is the failure this status
|
|
-- exists to prevent and the one that would be hardest to notice.
|
|
|
|
CREATE OR REPLACE FUNCTION app.assert_removed_stays_removed()
|
|
RETURNS trigger LANGUAGE plpgsql AS $body$
|
|
BEGIN
|
|
IF OLD.status = 'removed' AND NEW.status <> 'removed' THEN
|
|
RAISE EXCEPTION
|
|
'a removed node cannot return to service (was %, asked for %)',
|
|
OLD.status, NEW.status;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$body$;
|
|
|
|
DROP TRIGGER IF EXISTS compute_nodes_removed_is_terminal ON public.compute_nodes;
|
|
CREATE TRIGGER compute_nodes_removed_is_terminal
|
|
BEFORE UPDATE OF status ON public.compute_nodes
|
|
FOR EACH ROW EXECUTE FUNCTION app.assert_removed_stays_removed();
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 2. job_assignments
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- The controller assigns work; a node no longer claims it. `jobs.lease_owner` said who
|
|
-- held a job and it could only be one node, which cannot express a job given to several
|
|
-- machines at once, and cannot say which of them finished it.
|
|
--
|
|
-- A job is complete when the controller holds the artifacts that show it is. That makes
|
|
-- the assignment - the attempt - a separate thing from the job - the objective - which is
|
|
-- what this table is: one row per (job, node, attempt). Several rows may be open for one
|
|
-- job at once; the first whose artifacts verify wins and the rest are cancelled.
|
|
--
|
|
-- `state` is deliberately not the job's status. A job an assignment is working on is
|
|
-- `running` whatever the assignment says, and a `succeeded` assignment is not a completed
|
|
-- job until `artifacts_verified_at` is set. Collapsing the two is how a node's word
|
|
-- becomes the definition of done.
|
|
|
|
CREATE TABLE IF NOT EXISTS public.job_assignments (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
job_id uuid NOT NULL REFERENCES public.jobs (id) ON DELETE CASCADE,
|
|
node_id uuid NOT NULL REFERENCES public.compute_nodes (id) ON DELETE RESTRICT,
|
|
state text NOT NULL DEFAULT 'assigned',
|
|
attempt integer NOT NULL DEFAULT 1,
|
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
|
started_at timestamptz,
|
|
finished_at timestamptz,
|
|
lease_expires_at timestamptz,
|
|
artifacts_verified_at timestamptz,
|
|
result jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
error text,
|
|
CHECK (state = ANY (ARRAY[
|
|
'assigned', -- handed to the node, not yet picked up
|
|
'running', -- the node says it started
|
|
'succeeded', -- the node says it finished; NOT yet verified
|
|
'failed', -- the node gave up, or its lease ran out
|
|
'cancelled' -- superseded: another node won, or an administrator stopped it
|
|
])),
|
|
CHECK (finished_at IS NULL OR finished_at >= assigned_at)
|
|
);
|
|
|
|
-- One open assignment per (job, node): a node cannot be handed the same job twice at the
|
|
-- same time, but may be handed it again after a failure, which is what `attempt` counts.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS job_assignments_open_per_node
|
|
ON public.job_assignments (job_id, node_id)
|
|
WHERE state IN ('assigned', 'running', 'succeeded');
|
|
|
|
CREATE INDEX IF NOT EXISTS job_assignments_by_node
|
|
ON public.job_assignments (node_id, state);
|
|
|
|
CREATE INDEX IF NOT EXISTS job_assignments_by_job
|
|
ON public.job_assignments (job_id);
|
|
|
|
-- The two questions the controller asks constantly: what is this node to do, and what is
|
|
-- still outstanding on this job. Both are served by the indexes above; this one covers
|
|
-- "which nodes are overdue", which is the reassignment path.
|
|
CREATE INDEX IF NOT EXISTS job_assignments_lease
|
|
ON public.job_assignments (lease_expires_at)
|
|
WHERE state IN ('assigned', 'running');
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- 3. Recorded, not inferred
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- `artifacts_verified_at` is the whole of "a job is complete when the controller has the
|
|
-- artifacts to show it". It is set by the verification step and by nothing else, so the
|
|
-- question "is this job really done" has one answer with one writer, rather than being
|
|
-- inferred from an assignment's state and a hopeful join against `job_artifacts`.
|
|
|
|
COMMENT ON COLUMN public.job_assignments.artifacts_verified_at IS
|
|
'Set only by artifact verification. NULL on a succeeded assignment means the node '
|
|
'reported success and the controller has not yet confirmed it with artifacts; the job '
|
|
'is NOT complete while this is NULL.'; |