-
Notifications
You must be signed in to change notification settings - Fork 15
Make missing-Redis-state job failures loud and self-diagnosing #1241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4ca2b48
b9ae01c
6314e37
877a686
8edfb75
7705816
eb8a201
a8e9c7f
4b007c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,7 +146,12 @@ def run_job(self, job_id: int) -> None: | |
| raise e | ||
| # self.retry(exc=e, countdown=1, max_retries=1) | ||
| else: | ||
| job.logger.info(f"Running job {job}") | ||
| # Log the Redis DB index at task start so cross-host DB-index drift is visible per job | ||
| # (workers reading state from a different DB than run_job wrote it to fail silently in | ||
| # process_nats_pipeline_result). Public log carries only the DB index; host:port goes | ||
| # to the server log, since the host names internal infrastructure. | ||
| job.logger.info(f"Running job {job} on Redis db {_redis_db_index()}") | ||
| logger.info("Job %s Redis target: %s", job.pk, _describe_redis_target()) | ||
| try: | ||
| job.run() | ||
| except Exception as e: | ||
|
|
@@ -281,10 +286,16 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub | |
| if not progress_info: | ||
| # State keys genuinely missing (the total-images key returned None). | ||
| # Ack so NATS stops redelivering and fail the job — there's no state | ||
| # left to reconcile against. | ||
| # left to reconcile against. The reason is built from a live Redis | ||
| # snapshot (DB index, keys present under job:{id}:*) so the FAILURE log | ||
| # and the UI progress.errors entry name the actual cause (DB-index | ||
| # misconfig vs eviction vs concurrent cleanup). | ||
| _log_missing_state_context(job_id, "process") | ||
| _ack_task_via_nats(reply_subject, logger) | ||
| _fail_job(job_id, "Job state keys not found in Redis (likely cleaned up concurrently)") | ||
| _fail_job( | ||
| job_id, | ||
| f"Job state missing from Redis (stage=process): {state_manager.diagnose_missing_state()}", | ||
| ) | ||
| return | ||
|
|
||
| try: | ||
|
|
@@ -367,7 +378,10 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub | |
| # then fail the job. Mirrors the stage=process missing-state path. | ||
| _log_missing_state_context(job_id, "results") | ||
| _ack_task_via_nats(reply_subject, job.logger) | ||
| _fail_job(job_id, "Job state keys not found in Redis (likely cleaned up concurrently)") | ||
| _fail_job( | ||
| job_id, | ||
| f"Job state missing from Redis (stage=results): {state_manager.diagnose_missing_state()}", | ||
| ) | ||
| return | ||
|
|
||
| # update complete state based on latest progress info after saving results | ||
|
|
@@ -416,6 +430,40 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub | |
| job.logger.error(error) | ||
|
|
||
|
|
||
| def _redis_db_index() -> str: | ||
| """Return just the DB index of the "default" Redis connection (e.g. ``"1"``). | ||
|
|
||
| Safe for the public job log: the DB index is the load-bearing signal for diagnosing | ||
| cross-host DB drift, without exposing the internal Redis host. Pair with | ||
| :func:`_describe_redis_target` (host:port, server logs only) when an operator needs the host. | ||
| """ | ||
| try: | ||
| from django_redis import get_redis_connection | ||
|
|
||
| redis = get_redis_connection("default") | ||
| kwargs = getattr(redis.connection_pool, "connection_kwargs", {}) or {} | ||
| return str(kwargs.get("db", "?")) | ||
| except Exception as e: | ||
| return f"(unavailable: {e})" | ||
|
|
||
|
|
||
| def _describe_redis_target() -> str: | ||
| """Return a ``redis=host:port/dbN`` string for the "default" Redis connection. | ||
|
|
||
| For server-side logs only — it names the internal Redis host, so it must not reach the | ||
| public job log (use :func:`_redis_db_index` there). Logged server-side at ``run_job`` start | ||
| so an operator can resolve a DB-index drift to a specific host. | ||
| """ | ||
| try: | ||
| from django_redis import get_redis_connection | ||
|
|
||
| redis = get_redis_connection("default") | ||
| kwargs = getattr(redis.connection_pool, "connection_kwargs", {}) or {} | ||
| return f"redis={kwargs.get('host', '?')}:{kwargs.get('port', '?')}/db{kwargs.get('db', '?')}" | ||
|
mihow marked this conversation as resolved.
mihow marked this conversation as resolved.
|
||
| except Exception as e: | ||
| return f"redis=(unavailable: {e})" | ||
|
|
||
|
|
||
| def _fail_job(job_id: int, reason: str) -> None: | ||
| from ami.jobs.models import Job, JobState | ||
| from ami.ml.orchestration.jobs import cleanup_async_job_resources | ||
|
|
@@ -425,6 +473,16 @@ def _fail_job(job_id: int, reason: str) -> None: | |
| job = Job.objects.select_for_update().get(pk=job_id) | ||
| if job.status in (JobState.CANCELING, *JobState.final_states()): | ||
| return | ||
| # Mirror the reason into progress.errors so the UI surfaces it | ||
| # alongside the FAILURE status. Operators can see the cause in the | ||
| # job detail view without digging through Celery worker logs. | ||
| try: | ||
| job.progress.errors.append(reason) | ||
| except Exception as e: | ||
| # Don't let a diagnostic-write failure mask the original FAILURE, but record | ||
| # that the reason could not be attached so the swallow is observable — otherwise | ||
| # the UI silently loses the cause this PR exists to surface. | ||
| logger.warning("Job %s: could not append failure reason to progress.errors: %s", job_id, e) | ||
| job.update_status(JobState.FAILURE, save=False) | ||
| job.finished_at = datetime.datetime.now() | ||
| job.save(update_fields=["status", "progress", "finished_at"]) | ||
|
Comment on lines
+480
to
488
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Line 480 + Line 488 performs an in-memory append then writes the whole Based on learnings: 🧰 Tools🪛 Ruff (0.15.18)[warning] 481-481: Do not catch blind exception: (BLE001) 🤖 Prompt for AI AgentsSource: Learnings |
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -163,6 +163,18 @@ def update_state( | |||||||||
| newly_removed = results[0] if processed_image_ids else 0 | ||||||||||
|
|
||||||||||
| if total_raw is None: | ||||||||||
| # Loud diagnostic before the silent None return. The caller will mark | ||||||||||
| # the job FAILURE based on this result, so the operator needs to see | ||||||||||
| # *why* the total key is gone. Distinguishes three causes that map to | ||||||||||
| # the same symptom: DB-index mismatch across hosts, key eviction, and | ||||||||||
| # never-initialized state. | ||||||||||
| logger.warning( | ||||||||||
| "Job %s state missing in Redis (stage=%s, target=%s): %s", | ||||||||||
| self.job_id, | ||||||||||
| stage, | ||||||||||
| self.connection_target(), | ||||||||||
| self.diagnose_missing_state(), | ||||||||||
| ) | ||||||||||
| return None | ||||||||||
|
|
||||||||||
| total = int(total_raw) | ||||||||||
|
|
@@ -182,6 +194,60 @@ def update_state( | |||||||||
| newly_removed=newly_removed, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| def diagnose_missing_state(self) -> str: | ||||||||||
| """ | ||||||||||
| One-line, log-safe snapshot of what Redis holds for this job: the DB index and | ||||||||||
| the per-job keys with their set cardinalities. Lets a missing-state FAILURE name | ||||||||||
| its cause — DB-index mismatch across processes, key eviction, or never-initialized | ||||||||||
| state — rather than collapsing all three into one guess. | ||||||||||
|
|
||||||||||
| The Redis host/port is omitted: this string is surfaced in the job's public | ||||||||||
| ``progress.errors`` (via the reason passed to ``_fail_job``), and the host names | ||||||||||
| internal infrastructure. The DB index is the load-bearing signal for the mismatch | ||||||||||
| case and is safe to expose; operators who need the host read it from the server-side | ||||||||||
| ``update_state`` warning via ``connection_target()``. | ||||||||||
|
|
||||||||||
| Any failure to collect diagnostics is swallowed: the caller is already failing the | ||||||||||
| job, and an exception here would mask the original cause. | ||||||||||
| """ | ||||||||||
| try: | ||||||||||
| redis = self._get_redis() | ||||||||||
| kwargs = getattr(redis.connection_pool, "connection_kwargs", {}) or {} | ||||||||||
| db = kwargs.get("db", "?") | ||||||||||
| keys = sorted(k.decode() if isinstance(k, bytes) else k for k in redis.scan_iter(match=self._pattern())) | ||||||||||
| sizes: list[str] = [] | ||||||||||
| for key in keys: | ||||||||||
| if key == self._total_key: | ||||||||||
| sizes.append(f"{key}=<str>") | ||||||||||
| continue | ||||||||||
| try: | ||||||||||
| sizes.append(f"{key}=SCARD:{redis.scard(key)}") | ||||||||||
| except RedisError: | ||||||||||
| sizes.append(f"{key}=<err>") | ||||||||||
| keys_summary = ", ".join(sizes) if sizes else "<none>" | ||||||||||
| return f"redis db{db}: keys_for_job={keys_summary}" | ||||||||||
| except Exception as e: | ||||||||||
| return f"(diagnostics failed: {e})" | ||||||||||
|
Comment on lines
+229
to
+230
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Sanitize fallback diagnostics to avoid leaking Redis endpoint details. Line 229 returns the raw exception string in a value that is surfaced to user-visible Proposed fix- except Exception as e:
- return f"(diagnostics failed: {e})"
+ except Exception:
+ return "redis db?: keys_for_job=<diagnostics_failed>"📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.15.18)[warning] 229-229: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| def connection_target(self) -> str: | ||||||||||
| """ | ||||||||||
| Redis target as ``host:port/dbN``, for server-side operator logs only. | ||||||||||
|
|
||||||||||
| Names the internal Redis host, so this must NOT go into job logs or | ||||||||||
| ``progress.errors`` — use :meth:`diagnose_missing_state` for anything surfaced to | ||||||||||
| the user. Kept separate so the host stays in operator-facing logs (where the | ||||||||||
| cross-host DB-drift diagnosis needs it) without leaking to the public job view. | ||||||||||
| """ | ||||||||||
| try: | ||||||||||
| redis = self._get_redis() | ||||||||||
| kwargs = getattr(redis.connection_pool, "connection_kwargs", {}) or {} | ||||||||||
| return f"{kwargs.get('host', '?')}:{kwargs.get('port', '?')}/db{kwargs.get('db', '?')}" | ||||||||||
| except Exception as e: | ||||||||||
| return f"(unavailable: {e})" | ||||||||||
|
|
||||||||||
| def _pattern(self) -> str: | ||||||||||
| return f"job:{self.job_id}:*" | ||||||||||
|
|
||||||||||
| def get_progress(self, stage: str) -> "JobStateProgress | None": | ||||||||||
| """Read-only progress snapshot for the given stage.""" | ||||||||||
| try: | ||||||||||
|
|
||||||||||
Uh oh!
There was an error while loading. Please reload this page.