{"record":{"id":"cd18f9c9ce5fe116","repo":"apache/superset","slug":"job-not-found-or-already-completed","errorCode":null,"errorMessage":"Job not found or already completed","messagePattern":"Job not found or already completed","errorType":"http","errorClass":"AsyncQueryJobException","httpStatus":404,"severity":"warning","filePath":"superset/async_events/async_query_manager.py","lineNumber":437,"sourceCode":"        Authorize and cancel a running async job.\n\n        The caller's ``channel_id`` and ``user_id`` (resolved server-side from\n        the request, never taken from the client) must match the job's original\n        owner. The terminal ``STATUS_CANCELLED`` event is emitted here rather\n        than by the worker, which never runs for a task revoked while it was\n        still queued; the worker only logs the cancellation it is told about\n        through the flag, so a job still gets exactly one terminal event.\n\n        :raises AsyncQueryJobException: the job is unknown or already terminal\n        :raises AsyncQueryTokenException: the caller does not own the job\n        \"\"\"\n        if not self._cache:\n            raise CacheBackendNotInitialized(\"Cache backend not initialized\")\n\n        key = self._job_registry_key(job_id)\n        raw = self._cache.get(key)\n        if raw is None:\n            raise AsyncQueryJobException(\"Job not found or already completed\")\n\n        record = json.loads(raw)\n        if record.get(\"channel_id\") != channel_id or record.get(\"user_id\") != user_id:\n            raise AsyncQueryTokenException(\"Not authorized to cancel this job\")\n\n        # Flag before revoking so the worker's timeout handler, which may fire\n        # almost immediately, reliably sees the cancellation. Write only if the\n        # key still exists (``xx``): if the job finished and cleared its record\n        # between the read above and here, don't recreate a stale record or\n        # revoke a task that is already gone — report it as not found instead.\n        flagged = self._cache.set(\n            key,\n            json.dumps({**record, \"cancelled\": True}),\n            ex=self._jwt_expiration_seconds or None,\n            xx=True,\n        )\n        if not flagged:\n            raise AsyncQueryJobException(\"Job not found or already completed\")","sourceCodeStart":419,"sourceCodeEnd":455,"githubUrl":"https://github.com/apache/superset/blob/f4587218dd19d046c3e4d00063e7d27f8a2ed354/superset/async_events/async_query_manager.py#L419-L455","documentation":"AsyncQueryJobException raised in the job-cancellation path of the async query manager: when the per-job registry record (Redis key '<stream-prefix>job-cancel:<job_id>') is missing (cache.get returns None), the job is unknown — either it never existed, or it already reached a terminal state and cleaned up its record. Cancellation requires a live registry entry so it can be flagged and the Celery task revoked.","triggerScenarios":"Calling the cancel endpoint (DELETE on the async job resource) with a job_id that was mistyped, already finished successfully, already errored, was already cancelled, or whose registry entry expired (TTL is jwt_expiration_seconds). Also double-clicking a cancel button — the second request finds the record gone.","commonSituations":"Frontends racing: the job completes normally right as the user hits cancel; retrying a cancel request that already succeeded; long-running jobs whose registry entry TTL (tied to JWT expiration) is shorter than the job duration; stale UI showing a job that the worker already finalized.","solutions":["Treat this as benign in races: catch AsyncQueryJobException for 'already completed' and surface 'job already finished' to the user instead of an error.","Verify the job_id matches the one returned in the job metadata from the original request (check job_id/user_id/channel_id).","If jobs legitimately outlive the registry TTL, raise GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION so registry entries persist for the full expected job duration."],"exampleFix":"# before\nmanager.cancel_job(job_id, channel_id, user_id)  # may raise mid-request\n\n# after\nfrom superset.async_events.async_query_manager import AsyncQueryJobException\ntry:\n    manager.cancel_job(job_id, channel_id, user_id)\nexcept AsyncQueryJobException:\n    logger.info(\"Job %s already terminal; ignoring cancel\", job_id)","handlingStrategy":"try-catch","validationCode":"raw = cache.get(f\"{stream_prefix}job-cancel:{job_id}\")\nif raw is None:\n    skip_cancel(\"job unknown or already terminal\")","typeGuard":null,"tryCatchPattern":"try:\n    manager.cancel_job(job_id, channel_id, user_id)\nexcept AsyncQueryJobException:\n    # unknown or terminal: not an error for the user\n    log.info(\"cancel skipped: job %s already completed\", job_id)","preventionTips":["Disable the cancel button once a terminal event arrives to avoid racing the worker.","Align registry TTL (jwt expiration) with your longest expected query duration."],"tags":["async-queries","job-cancellation","race-condition","redis"],"backgroundTag":null,"analyzedSha":"f4587218dd19d046c3e4d00063e7d27f8a2ed354","analyzedAt":"2026-08-14T22:39:27.425Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}