-
Notifications
You must be signed in to change notification settings - Fork 52
Add unified job status endpoint GET /api/v3_0/jobs/<uuid> #2141
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
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
130c032
Initial plan
Copilot 8361853
feat: add unified job status endpoint GET /api/v3_0/jobs/<uuid>
Copilot 318edae
fix: remove redundant uuid parameter from get_job_status signature
Copilot 30097c0
chore: generate openapi-specs.json
BelhsanHmida 454b0fc
feat: enrich job status response with result, func_name, origin and t…
Copilot ab0d6f3
test: add STARTED job test and assert result field in finished-job test
Copilot 41a9d26
docs: update job status endpoint description
BelhsanHmida d5d5978
feat: attach resource context to sequential wrap-up jobs
BelhsanHmida 139d9d0
test: cover wrap-up job resource metadata
BelhsanHmida 9cbd3d9
feat: expose failed job tracebacks
BelhsanHmida 081704f
test: cover failed job responses
BelhsanHmida 68aa00e
Update flexmeasures/api/v3_0/jobs.py
BelhsanHmida cd81375
feat(api): return 404 for unknown job ids
BelhsanHmida c0f5e63
feat(api): enforce job read access and return 503 when queues are una…
BelhsanHmida 57b717c
test(api): cover job access control and queue outage responses
BelhsanHmida 742551e
Merge branch 'main' into copilot/add-unified-job-status-endpoint
nhoening File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime | ||
|
|
||
| from flask import current_app | ||
| from flask_classful import FlaskView, route | ||
| from flask_json import as_json | ||
| from flask_security import auth_required | ||
| from redis.exceptions import ConnectionError as RedisConnectionError | ||
| from rq.job import Job, JobStatus, NoSuchJobError | ||
| from webargs.flaskparser import use_kwargs | ||
| from marshmallow import fields | ||
|
|
||
| from flexmeasures.api.common.utils.api_utils import job_status_description | ||
| from flexmeasures.auth.policy import check_access | ||
| from flexmeasures.data import db | ||
| from flexmeasures.data.models.time_series import Sensor | ||
| from flexmeasures.data.services.utils import get_asset_or_sensor_from_ref | ||
|
|
||
|
|
||
| def _isoformat_or_none(dt: datetime | None) -> str | None: | ||
| """Return an ISO-8601 string for *dt*, or ``None`` when *dt* is absent.""" | ||
| return dt.isoformat() if dt is not None else None | ||
|
|
||
|
|
||
| def _failed_job_exc_info(job: Job) -> str | None: | ||
| """Return traceback text for failed jobs when RQ stored it.""" | ||
| if not job.is_failed: | ||
| return None | ||
|
|
||
| latest_result = job.latest_result() | ||
| if latest_result is None: | ||
| return None | ||
|
|
||
| return latest_result.exc_string | ||
|
|
||
|
|
||
| def _job_read_context(job: Job): | ||
| """Resolve the asset or sensor whose read access governs this job.""" | ||
| asset_or_sensor_ref = job.meta.get("asset_or_sensor") or job.kwargs.get( | ||
| "asset_or_sensor" | ||
| ) | ||
| if asset_or_sensor_ref is not None: | ||
| return get_asset_or_sensor_from_ref(asset_or_sensor_ref) | ||
|
|
||
| sensor_id = job.meta.get("sensor_id") | ||
| if sensor_id is None: | ||
| forecast_kwargs = job.meta.get("forecast_kwargs", {}) | ||
| if isinstance(forecast_kwargs, dict): | ||
| sensor_id = forecast_kwargs.get("sensor_id") | ||
| if sensor_id is None: | ||
| sensor_id = job.kwargs.get("sensor_id") | ||
|
|
||
| if sensor_id is None: | ||
| return None | ||
|
|
||
| return db.session.get(Sensor, sensor_id) | ||
|
|
||
|
|
||
| def _job_queue_unavailable_response(): | ||
| return ( | ||
| dict( | ||
| status="ERROR", | ||
| message="Job queues are currently unavailable.", | ||
| ), | ||
| 503, | ||
| ) | ||
|
|
||
|
|
||
| class JobAPI(FlaskView): | ||
| """ | ||
| Endpoint for querying the status of background jobs by UUID. | ||
| """ | ||
|
|
||
| route_base = "/jobs" | ||
| trailing_slash = False | ||
|
|
||
| @route("/<uuid>", methods=["GET"]) | ||
| @auth_required() | ||
| @use_kwargs({"job_id": fields.Str(data_key="uuid", required=True)}, location="path") | ||
| @as_json | ||
| def get_job_status(self, job_id: str, **kwargs): | ||
| """ | ||
| .. :quickref: Jobs; Get the status of a background job | ||
|
|
||
| --- | ||
| get: | ||
| summary: Get the status of a background job | ||
| description: | | ||
| Look up a background job by its UUID and see whether it is | ||
| queued, running, finished, or failed. | ||
|
|
||
| The response includes a status message plus job metadata such | ||
| as the queue name, function name, timestamps, and the job | ||
| result when available. | ||
|
|
||
| Failed jobs also include traceback information when the worker | ||
| stored it with the job result. | ||
| security: | ||
| - ApiKeyAuth: [] | ||
| parameters: | ||
| - in: path | ||
| name: uuid | ||
| required: true | ||
| description: UUID of the background job. | ||
| example: b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b | ||
| schema: | ||
| type: string | ||
| responses: | ||
| 200: | ||
| description: Job status retrieved successfully. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| type: object | ||
| properties: | ||
| status: | ||
| type: string | ||
| enum: | ||
| - QUEUED | ||
| - STARTED | ||
| - FINISHED | ||
| - FAILED | ||
| - DEFERRED | ||
| - SCHEDULED | ||
| - STOPPED | ||
| - CANCELED | ||
| description: Current status of the job. | ||
| message: | ||
| type: string | ||
| description: Human-readable description of the job status. | ||
| result: | ||
| description: Return value of the job function, or null when not yet available. | ||
| nullable: true | ||
| func_name: | ||
| type: string | ||
| description: Fully-qualified name of the function executed by this job. | ||
| origin: | ||
| type: string | ||
| description: Name of the queue the job was placed on. | ||
| enqueued_at: | ||
| type: string | ||
| format: date-time | ||
| nullable: true | ||
| description: ISO-8601 timestamp of when the job was enqueued. | ||
| started_at: | ||
| type: string | ||
| format: date-time | ||
| nullable: true | ||
| description: ISO-8601 timestamp of when the job started executing. | ||
| ended_at: | ||
| type: string | ||
| format: date-time | ||
| nullable: true | ||
| description: ISO-8601 timestamp of when the job finished executing. | ||
| exc_info: | ||
| type: string | ||
| nullable: true | ||
| description: Traceback information for failed jobs, or null otherwise. | ||
| examples: | ||
| queued: | ||
| summary: Queued job | ||
| value: | ||
| status: QUEUED | ||
| message: "Scheduling job waiting to be processed." | ||
| result: null | ||
| func_name: "flexmeasures.data.services.scheduling.create_schedule" | ||
| origin: scheduling | ||
| enqueued_at: "2026-04-28T10:00:00+00:00" | ||
| started_at: null | ||
| ended_at: null | ||
| exc_info: null | ||
| finished: | ||
| summary: Finished job | ||
| value: | ||
| status: FINISHED | ||
| message: "Scheduling job has finished." | ||
| result: null | ||
| func_name: "flexmeasures.data.services.scheduling.create_schedule" | ||
| origin: scheduling | ||
| enqueued_at: "2026-04-28T10:00:00+00:00" | ||
| started_at: "2026-04-28T10:00:01+00:00" | ||
| ended_at: "2026-04-28T10:00:05+00:00" | ||
| exc_info: null | ||
| failed: | ||
| summary: Failed job | ||
| value: | ||
| status: FAILED | ||
| message: "Scheduling job failed with ValueError: ..." | ||
| result: null | ||
| func_name: "flexmeasures.data.services.scheduling.create_schedule" | ||
| origin: scheduling | ||
| enqueued_at: "2026-04-28T10:00:00+00:00" | ||
| started_at: "2026-04-28T10:00:01+00:00" | ||
| ended_at: "2026-04-28T10:00:02+00:00" | ||
| exc_info: "Traceback (most recent call last): ..." | ||
| 404: | ||
| description: NOT_FOUND | ||
| 401: | ||
| description: UNAUTHORIZED | ||
| 403: | ||
| description: INVALID_SENDER | ||
| 503: | ||
| description: SERVICE_UNAVAILABLE | ||
| tags: | ||
| - Jobs | ||
| """ | ||
| connection = current_app.redis_connection | ||
|
|
||
| try: | ||
| connection.ping() | ||
| job = Job.fetch(job_id, connection=connection) | ||
| read_context = _job_read_context(job) | ||
| if read_context is not None: | ||
| check_access(read_context, "read") | ||
| except NoSuchJobError: | ||
| return ( | ||
| dict( | ||
| status="ERROR", | ||
| message=f"Job {job_id} not found.", | ||
| ), | ||
| 404, | ||
| ) | ||
| except RedisConnectionError: | ||
| return _job_queue_unavailable_response() | ||
|
|
||
| try: | ||
| job_status = job.get_status() | ||
| status_name = ( | ||
| job_status.name | ||
| if isinstance(job_status, JobStatus) | ||
| else str(job_status).upper() | ||
| ) | ||
|
|
||
| # job.return_value is None when the job has not finished successfully | ||
| result = job.return_value() | ||
| except RedisConnectionError: | ||
| return _job_queue_unavailable_response() | ||
| except Exception: # noqa: BLE001 | ||
| result = None | ||
|
|
||
| return ( | ||
| dict( | ||
| status=status_name, | ||
| message=job_status_description(job), | ||
| result=result, | ||
| func_name=job.func_name, | ||
| origin=job.origin, | ||
| enqueued_at=_isoformat_or_none(job.enqueued_at), | ||
| started_at=_isoformat_or_none(job.started_at), | ||
| ended_at=_isoformat_or_none(job.ended_at), | ||
| exc_info=_failed_job_exc_info(job), | ||
| ), | ||
| 200, | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.