-- 018 is retained unchanged: local anp_publishing_it records its application. -- These corrections use independently tracked resumable stages. -- wp3:phase expand transaction SET LOCAL lock_timeout = '5s'; ALTER TABLE publishing_attempts ADD COLUMN first_dispatch_started_at timestamptz; CREATE OR REPLACE FUNCTION publishing_attempts_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key, NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id, NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id, NEW.credential_id, NEW.credential_generation, NEW.created_at) IS DISTINCT FROM (OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key, OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id, OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id, OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514'; END IF; IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514'; END IF; IF OLD.first_dispatch_started_at IS NOT NULL AND NEW.first_dispatch_started_at IS DISTINCT FROM OLD.first_dispatch_started_at THEN RAISE EXCEPTION 'first dispatch time is immutable' USING ERRCODE = '23514'; END IF; IF NEW.dispatch_count < OLD.dispatch_count THEN RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514'; END IF; IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline AND OLD.provider_retry_deadline IS NOT NULL THEN RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514'; END IF; IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN NEW.claim_epoch := OLD.claim_epoch + 1; ELSE NEW.claim_epoch := OLD.claim_epoch; END IF; NEW.updated_at := now(); RETURN NEW; END $$; CREATE OR REPLACE FUNCTION drafts_revision_fence() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.publication_attempt < OLD.publication_attempt THEN RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514'; END IF; IF NEW.post IS DISTINCT FROM OLD.post THEN IF EXISTS (SELECT 1 FROM publishing_attempts a WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id AND (a.status = 'unknown' OR (a.status IN ('ready', 'in_progress') AND a.dispatch_started_at IS NOT NULL))) THEN RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000'; END IF; NEW.revision := OLD.revision + 1; NEW.approved_revision := NULL; NEW.approved_hash := NULL; NEW.approved_at := NULL; NEW.reviewer_user_id := NULL; ELSE NEW.revision := OLD.revision; END IF; RETURN NEW; END $$; -- wp3:phase dispatch_backfill batch WITH batch AS ( SELECT id FROM publishing_attempts WHERE first_dispatch_started_at IS NULL AND dispatch_count > 0 LIMIT 1000 FOR UPDATE ) UPDATE publishing_attempts a SET first_dispatch_started_at = CASE WHEN a.dispatch_count > 1 OR a.dispatch_started_at IS NULL THEN a.created_at ELSE a.dispatch_started_at END FROM batch WHERE a.id = batch.id; -- wp3:phase receipt_recovery_index concurrent CREATE INDEX CONCURRENTLY publishing_receipts_recovery ON publishing_receipts (workspace_id, attempt_id, received_at); -- wp3:phase cutover transaction SET LOCAL lock_timeout = '5s'; DROP FUNCTION publishing_due_schedules(integer); CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100) RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz, evidence_only boolean) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at, due.evidence_only FROM ( SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation, sp.publish_at, sp.next_attempt_at AS due_at, false AS evidence_only FROM scheduled_publications sp WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now() AND NOT EXISTS (SELECT 1 FROM publishing_attempts a JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id WHERE a.idempotency_key = sp.idempotency_key AND a.workspace_id = sp.workspace_id) UNION ALL SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation, sp.publish_at, a.lease_until AS due_at, false AS evidence_only FROM publishing_attempts a JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id WHERE a.status = 'in_progress' AND a.lease_until < now() AND NOT EXISTS (SELECT 1 FROM publishing_receipts r WHERE r.attempt_id = a.id AND r.workspace_id = a.workspace_id) UNION ALL SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation, sp.publish_at, r.received_at AS due_at, true AS evidence_only FROM publishing_attempts a JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id WHERE (a.status IN ('ready', 'failed', 'unknown') OR (a.status = 'in_progress' AND a.lease_until < now())) ) due ORDER BY due.due_at, due.schedule_id LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100) $$; REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC; GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer) TO sp_app;