1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/operations/models.py
catborise 1e66367bb4 feat(operations): a job's error and warning show in the reader's language
A job stored its error and its result's warning as English text, much
of it built with the values in it (volume paths, a disk, libvirt's
error), so no catalog could translate it, and the job pages and panel
showed English whatever the language.

A job now stores them as messages: our English text, marked for
translation, and its values; libvirt's own text is a message without
values and is shown as it is. The jobs API and pages put them in the
reader's language. OperationError keeps its text and values apart for
the same reason, so a refusal raised while a job is submitted is shown
translated too. Messages one after another still read as sentences.

Errors stored before are kept as one message each, shown as they were.
2026-11-02 23:49:45 +03:00

127 lines
5.4 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]
REQUEST = "request" # the worker of a job a request runs
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.JSONField(default=list) # messages (operations.utils.say), shown in the reader's language
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)
# identity of the owning worker; REQUEST for a job a request runs, which the worker never claims or recovers
worker = models.CharField(max_length=128, blank=True)
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}"
@property
def in_request(self):
return self.worker == Operation.REQUEST
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