mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
121 lines
5.1 KiB
Python
121 lines
5.1 KiB
Python
"""Long-running operations: the queue, the history and the resources jobs hold
|
|
(spec 2026-10-13, sections 1-2). One worker per installation, and fencing for
|
|
every write a worker makes. Same host (the same data directory, which holds
|
|
the worker's flock): the flock proves the previous holder is gone, so the
|
|
lease is taken over at once. Another host: only after a stale heartbeat."""
|
|
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.db import models, transaction
|
|
from django.utils import timezone
|
|
|
|
TAKEOVER_AFTER = timedelta(minutes=2)
|
|
|
|
|
|
class Operation(models.Model):
|
|
QUEUED = "queued"
|
|
RUNNING = "running"
|
|
CANCELLING = "cancelling"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
UNCERTAIN = "uncertain"
|
|
ACTIVE = (QUEUED, RUNNING, CANCELLING)
|
|
TERMINAL = (SUCCEEDED, FAILED, CANCELLED, UNCERTAIN)
|
|
STATES = [(s, s) for s in ACTIVE + TERMINAL]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
kind = models.CharField(max_length=64, db_index=True)
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="operations")
|
|
username = models.CharField(max_length=150) # kept when the user is deleted
|
|
submission_key = models.CharField(max_length=64)
|
|
resource = models.CharField(max_length=255, db_index=True) # the page that shows the job
|
|
resource_label = models.CharField(max_length=255)
|
|
state = models.CharField(max_length=16, choices=STATES, default=QUEUED, db_index=True)
|
|
params = models.JSONField(default=dict)
|
|
intent = models.JSONField(default=dict) # plan facts, then what run is about to change
|
|
# set with the intent committed right before the first libvirt effect:
|
|
# recovery fails a job without it, it never reached libvirt
|
|
effects_started = models.BooleanField(default=False)
|
|
phase = models.CharField(max_length=255, blank=True)
|
|
progress_done = models.BigIntegerField(null=True)
|
|
progress_total = models.BigIntegerField(null=True)
|
|
result = models.JSONField(default=dict)
|
|
error = models.TextField(blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
started_at = models.DateTimeField(null=True)
|
|
finished_at = models.DateTimeField(null=True)
|
|
heartbeat_at = models.DateTimeField(null=True)
|
|
worker = models.CharField(max_length=128, blank=True) # identity of the worker that owns it
|
|
time_limit = models.PositiveIntegerField(null=True) # seconds, fixed at admission
|
|
cancel_requested = models.BooleanField(default=False)
|
|
resolved_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+")
|
|
resolved_at = models.DateTimeField(null=True)
|
|
resolution = models.CharField(max_length=16, blank=True)
|
|
resolution_note = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
constraints = [models.UniqueConstraint(fields=["user", "submission_key"], name="unique_submission")]
|
|
|
|
def __str__(self):
|
|
return f"{self.kind} {self.id}"
|
|
|
|
|
|
class Claim(models.Model):
|
|
SHARED = "shared"
|
|
EXCLUSIVE = "exclusive"
|
|
|
|
operation = models.ForeignKey(Operation, null=True, on_delete=models.CASCADE, related_name="claims")
|
|
resource = models.CharField(max_length=255, db_index=True)
|
|
mode = models.CharField(max_length=9, choices=[(SHARED, SHARED), (EXCLUSIVE, EXCLUSIVE)])
|
|
holder = models.CharField(max_length=128, blank=True) # transient: host:pid:thread
|
|
expires_at = models.DateTimeField(null=True) # transient only
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
|
|
class WorkerLease(models.Model):
|
|
name = models.CharField(primary_key=True, max_length=16, default="worker")
|
|
holder = models.CharField(max_length=128)
|
|
host = models.CharField(max_length=255)
|
|
heartbeat_at = models.DateTimeField()
|
|
|
|
|
|
class LeaseLost(Exception):
|
|
"""This worker no longer holds the lease or owns the job."""
|
|
|
|
|
|
def acquire(holder, host):
|
|
now = timezone.now()
|
|
with transaction.atomic():
|
|
lease, created = WorkerLease.objects.select_for_update().get_or_create(
|
|
name="worker", defaults={"holder": holder, "host": host, "heartbeat_at": now}
|
|
)
|
|
if created or lease.holder == holder:
|
|
return True
|
|
if lease.host == host or lease.heartbeat_at < now - TAKEOVER_AFTER:
|
|
WorkerLease.objects.filter(name="worker").update(holder=holder, host=host, heartbeat_at=now)
|
|
return True
|
|
return False
|
|
|
|
|
|
def renew(holder):
|
|
return bool(WorkerLease.objects.filter(name="worker", holder=holder).update(heartbeat_at=timezone.now()))
|
|
|
|
|
|
def last_seen():
|
|
lease = WorkerLease.objects.filter(name="worker").first()
|
|
return lease.heartbeat_at if lease else None
|
|
|
|
|
|
@contextmanager
|
|
def fenced(holder):
|
|
"""A transaction in which holder still holds the lease; a takeover locks
|
|
the same row, so it waits for this transaction or makes it fail."""
|
|
with transaction.atomic():
|
|
if not WorkerLease.objects.select_for_update().filter(name="worker", holder=holder).exists():
|
|
raise LeaseLost(holder)
|
|
yield
|