Coverage for netbox_data_import/proposal_dispatch.py: 97.85%
93 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
1# SPDX-License-Identifier: Apache-2.0
2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com>
3"""Start Resolution Proposal Jobs only while the active Inference Backend has a free request slot.
5A requested proposal waits as a queued row without a Job. The dispatcher gives the oldest waiting rows
6a Job while slots are free. A slot is in use while a proposal's Job can still run, whatever the
7proposal's status: the worker of a cancelled row can still be inside its backend call. A slot is
8released only when no worker holds its Job lock and the queue no longer has its task.
9"""
11import logging
13from django.core.cache import cache
14from django.db import connection, transaction
15from django.utils import timezone
16from netbox.constants import RQ_QUEUE_LOW
17from redis.exceptions import ConnectionError as RedisConnectionError
18from redis.exceptions import TimeoutError as RedisTimeoutError
20from .inference_backend import NoActiveInferenceBackend, resolve_active_backend
21from .inference_credentials import CredentialFailure
22from .inference_trust import InvalidInferenceConfiguration
23from .jobs import ResolutionProposalJob, _task_lost, _try_job_lock, import_queue_task
24from .models import ProposalFailureReason, ProposalStatus, ResolutionProposal
25from .proposal_jobs import CREDENTIAL_FAILURE_REASONS, proposal_job_timeout
26from .resolution_proposals import fail_proposal, fail_queued_proposal, record_proposal_job
28logger = logging.getLogger(__name__)
30SLOT_TASK_LOST = "The proposal task is no longer queued or running."
31_DISPATCH_LOCK = "netbox-data-import-proposal-dispatch"
32POLL_DISPATCH_KEY = "netbox-data-import-proposal-poll-dispatch"
33POLL_DISPATCH_INTERVAL_SECONDS = 5
36def dispatch_proposals() -> list[int]:
37 """Release leaked slots, then give the oldest waiting proposals a Job while slots are free.
39 Call it outside a transaction. Return the ids of the dispatched proposals. A queue push that
40 fails after the commit fails its attempt with queue_unavailable, and its error propagates.
41 """
42 committed: list[bool] = []
43 dispatched: list[tuple[int, object]] = []
44 try:
45 with transaction.atomic():
46 # Registered first, so it runs before a queue push that may raise after the commit.
47 transaction.on_commit(lambda: committed.append(True))
48 # Two dispatchers that both counted the same free slot would exceed the limit.
49 with connection.cursor() as cursor:
50 cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [_DISPATCH_LOCK])
51 dispatched = _fill_free_slots()
52 except BaseException:
53 if committed:
54 for proposal_id, job in dispatched:
55 _fail_unpushed(proposal_id, job)
56 raise
57 return [proposal_id for proposal_id, _job in dispatched]
60def dispatch_proposals_on_poll() -> list[int]:
61 """Dispatch at most once per interval for all pending-card polls together.
63 The poll is only the backstop for a slot that a killed worker never handed on: a request and the
64 end of each proposal Job dispatch at once. The cache is shared by every NetBox process.
65 """
66 if not cache.add(POLL_DISPATCH_KEY, True, timeout=POLL_DISPATCH_INTERVAL_SECONDS):
67 return []
68 return dispatch_proposals()
71def _fill_free_slots() -> list[tuple[int, object]]:
72 """Enqueue one Job for each waiting proposal that a free slot can take, oldest first."""
73 waiting = ResolutionProposal.objects.filter(status=ProposalStatus.QUEUED, job__isnull=True).order_by(
74 "created", "pk"
75 )
76 in_use = _release_leaked_slots()
77 try:
78 backend = resolve_active_backend()
79 except (NoActiveInferenceBackend, InvalidInferenceConfiguration, CredentialFailure) as exc:
80 # No Job could call a backend, so each waiting row fails with the reason its Job would record.
81 reason = ProposalFailureReason.INVALID_CONFIGURATION
82 if isinstance(exc, CredentialFailure):
83 reason = CREDENTIAL_FAILURE_REASONS.get(exc.category, ProposalFailureReason.CREDENTIAL_UNAVAILABLE)
84 for proposal_id in waiting.values_list("pk", flat=True):
85 fail_queued_proposal(proposal_id, reason=reason)
86 return []
87 job_timeout = proposal_job_timeout(backend)
88 free = max(backend.max_concurrent_requests - in_use, 0)
89 dispatched = []
90 for proposal in waiting.select_related("requested_by")[:free]:
91 # The low queue lets a trace sync queued later start before minutes of inference.
92 job = ResolutionProposalJob.enqueue(
93 name=ResolutionProposalJob.Meta.name,
94 user=proposal.requested_by,
95 queue_name=RQ_QUEUE_LOW,
96 job_timeout=job_timeout,
97 proposal_id=proposal.pk,
98 )
99 record_proposal_job(proposal.pk, job)
100 dispatched.append((proposal.pk, job))
101 return dispatched
104def _release_leaked_slots() -> int:
105 """Release each slot whose Job can no longer run, and return how many slots stay in use."""
106 from core.choices import JobStatusChoices
107 from core.models import Job
109 in_use = 0
110 holders = ResolutionProposal.objects.filter(job__status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
111 for proposal in holders.select_related("job"):
112 job = proposal.job
113 # A worker holds this lock through its whole delivery, whatever Redis or the clock say.
114 if not _try_job_lock(job):
115 in_use += 1
116 continue
117 try:
118 job.refresh_from_db()
119 except Job.DoesNotExist:
120 # The Job was deleted after the holder query, and a deleted Job holds no slot.
121 continue
122 if job.status not in JobStatusChoices.ENQUEUED_STATE_CHOICES:
123 continue
124 # A free lock is no proof alone: a dropped database session releases it while the call goes on.
125 if not _task_lost(job, import_queue_task(job)):
126 in_use += 1
127 continue
128 logger.warning("Released the request slot of Resolution Proposal %s: %s", proposal.pk, SLOT_TASK_LOST)
129 Job.objects.filter(pk=job.pk, status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES).update(
130 status=JobStatusChoices.STATUS_ERRORED, completed=timezone.now(), error=SLOT_TASK_LOST
131 )
132 # The job system lost the run, not the backend; a terminal row keeps its status.
133 fail_proposal(proposal.pk, reason=ProposalFailureReason.QUEUE_UNAVAILABLE)
134 return in_use
137def _fail_unpushed(proposal_id: int, job) -> None:
138 """Fail an attempt whose task never reached the queue, so it neither waits nor holds a slot."""
139 from core.choices import JobStatusChoices
140 from core.models import Job
142 try:
143 pushed = import_queue_task(job) is not None
144 except (RedisConnectionError, RedisTimeoutError):
145 # A queue that cannot answer cannot show the task, so the attempt fails.
146 pushed = False
147 # A failed push stops only the later pushes, so a task that reached the queue keeps its attempt.
148 if pushed:
149 return
150 # A worker that took the attempt owns it, so only an attempt still queued fails here.
151 if fail_queued_proposal(proposal_id, reason=ProposalFailureReason.QUEUE_UNAVAILABLE):
152 Job.objects.filter(pk=job.pk, status=JobStatusChoices.STATUS_PENDING).update(
153 status=JobStatusChoices.STATUS_ERRORED
154 )
157__all__ = ("dispatch_proposals", "dispatch_proposals_on_poll")