Skip to content

Commit 043a781

Browse files
feat: add ckanext.datapusher_plus.prefect_enabled to run jobs without Prefect
Submitting a resource fails outright on deployments where CKAN cannot write $PREFECT_HOME: ERROR [ckanext.datapusher_plus.logic.action] Error submitting job to DataPusher: [Errno 13] Permission denied: '/root/.prefect/profiles.toml' That is not a Prefect outage — `submit_flow_run`'s `from prefect.deployments import run_deployment` runs Prefect's settings bootstrap, which creates `$PREFECT_HOME/profiles.toml` (default `$HOME/.prefect`). A CKAN process running with HOME=/root but no write access there cannot even import the library, so no amount of server configuration helps. Add `ckanext.datapusher_plus.prefect_enabled` (default true). Setting it to false turns Prefect off entirely: `datapusher_submit` enqueues the job on CKAN's own background queue and the new `jobs/local_runner.py` runs the same nine ingestion stages in-process, with nothing on the path importing `prefect`. Operators run `ckan jobs worker` instead of a Prefect server, worker, and work pool. The local runner keeps the Jobs/Logs tables, the datapusher_hook callbacks, the complete-with-skip contract, the flow_timeout deadline, and the post-database-failure datastore cleanup. It does not provide per-stage retries, result caching, the run graph/artifacts/events, or human-in-the-loop PII review — with Prefect off, a job crossing pii_review_threshold aborts before any datastore write rather than suspending for an approval that can never arrive. A database-stage failure deliberately leaves the datastore alone (that stage can fail before touching anything, and dropping there would destroy data the run never wrote). The task_status row records the RQ job id as `rq_job_id` rather than `flow_run_id`, so nothing builds a Prefect-UI deep link out of it. The Prefect-free half of the pipeline moves out of prefect_flow.py into the new jobs/pipeline_core.py — the CKAN status callback, input validation, RuntimeContext construction, the stage invoker and its StageAbort signal, and the datastore rollback body — shared verbatim by both runners. prefect_flow keeps its historical private names as aliases, so custom flows composed from its @task primitives are unaffected; tests that patched prefect_flow.dsu / .QSVCommand / .Path now patch pipeline_core. Unit suite: 307 passed, 1 failed (test_quoted_csv_inference_matrix, a pre-existing qsv-binary-dependent failure present on the base commit too). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 474ce97 commit 043a781

17 files changed

Lines changed: 1729 additions & 291 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
* New `ckanext.datapusher_plus.prefect_enabled` setting (default `true`). Setting it to `false` turns Prefect off entirely: `datapusher_submit` enqueues the job on CKAN's own background-job queue and the new `ckanext/datapusher_plus/jobs/local_runner.py` executes the same nine ingestion stages in-process, so no Prefect server, worker, work pool — or `import prefect` — is involved. Operators run `ckan jobs worker` instead. This is the fix for deployments where importing Prefect *itself* fails, e.g. a CKAN process running with `HOME=/root` but no write access there, which surfaces as `Error submitting job to DataPusher: [Errno 13] Permission denied: '/root/.prefect/profiles.toml'` (the alternative fix, if you want to keep Prefect, is to point `PREFECT_HOME` at a writable directory). The local runner keeps the `Jobs`/`Logs` tables, the `datapusher_hook` callbacks, the complete-with-skip contract, the `flow_timeout` deadline, and the post-database-failure datastore cleanup; it does *not* provide per-stage retries, result caching, the Prefect run graph/artifacts/events, or human-in-the-loop PII review — with Prefect off, a job crossing `pii_review_threshold` aborts before any datastore write rather than suspending for approval. The task_status row records the RQ job id as `rq_job_id` (not `flow_run_id`), so nothing builds a Prefect-UI deep link out of it. See [Running without Prefect](README.md#running-without-prefect).
11+
912
### Changed
13+
* The Prefect-free half of the pipeline moved out of `jobs/prefect_flow.py` into the new `jobs/pipeline_core.py` (the CKAN status callback, input validation, `RuntimeContext` construction, the stage invoker and its `StageAbort` signal, and the datastore rollback body), shared verbatim by the Prefect flow and the local runner. `prefect_flow` keeps its historical private names as aliases, so custom flows composed from its `@task` primitives are unaffected. Tests that patched `prefect_flow.dsu` / `.QSVCommand` / `.Path` now patch `pipeline_core`.
1014
* Auto-indexing now uses a closed cardinality range `[auto_index_min_threshold, auto_index_threshold]` instead of a single upper bound (issue #142). Defaults bumped: `auto_index_threshold` `3` → `10` and the new `auto_index_min_threshold` defaults to `3`. The new lower bound skips the useless-single-value-column case @EricSoroos flagged in #142 (a 1-value text column previously produced a 10–40MB B-tree the Postgres planner would never choose). The upper bump from 3 → 10 widens the DataTables-SearchBuilder filtering sweet spot to cover typical enum-shaped columns (status = 3–10 values, common enums under ~10). **Operator-facing impact at default settings:** columns with cardinality 1–2 lose their auto-index (intentional — the indexes were dead weight); columns with cardinality 4–10 gain an auto-index. No migration is required; the change re-applies on the next resubmit. To restore pre-#142 behavior (no lower floor) set `ckanext.datapusher_plus.auto_index_min_threshold = 0`. The setting `auto_index_threshold = -1` ("index every column") still works but now hits the `min_threshold = 3` floor unless paired with `auto_index_min_threshold = 0`.
1115
* **BREAKING** Bumped `MINIMUM_QSV_VERSION` from `4.0.0` to `20.0.0`. Operators must upgrade their `qsv` binary at the path configured by `ckanext.datapusher_plus.qsv_bin` before deploying this version — DP+ will refuse to start otherwise. See [qsv 20.0.0 release notes](https://github.com/dathere/qsv/releases/tag/20.0.0) and the migration notes below. ([README install snippet](README.md#option-2-install-prebuilt-qsv-binaries) updated accordingly.)
1216
* **BREAKING** Bumped `MINIMUM_QSV_VERSION` from `20.0.0` to `20.1.0` (and bumped the qsv version installed by `Dockerfile.worker`, `.github/workflows/ci.yml`, and `.github/workflows/main.yml` to match). Operators must upgrade their `qsv` binary at the path configured by `ckanext.datapusher_plus.qsv_bin` before deploying this version — DP+ will refuse to start otherwise with `JobError: At least qsv version 20.1.0 required. Found 20.0.0.`. qsv 20.1.0 itself introduces no breaking changes against 20.0.0 (per the [20.1.0 release notes](https://github.com/dathere/qsv/releases/tag/20.1.0): "pipelines built on 20.0.0 will upgrade in place"), so the upgrade is binary-swap-only — no data re-ingestion is required. The reason this is still flagged as **BREAKING** is the minimum-version gate, not the qsv behavior. User-visible improvement justifying the floor bump: qsv-dateparser 0.14 → 0.15 in qsv 20.1.0 adds recognition for ISO 8601 `T`-separated datetimes without a timezone suffix (e.g. `2024-10-11T14:30:00`) — qsv 20.0.0 misclassified these as `String` during `qsv stats --infer-dates`, which in DP+ surfaced as a date-typed column being demoted to text on certain CSV shapes (one of the gaps documented in the issue #173 regression test, now closed). The regression-test matrix in `tests/test_issue_173_date_format_inference.py` is updated to the new baseline.

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,17 @@ prefect_flow.py → Orchestration. Per-stage @task functions (each delegates
6666
__init__.py → Public surface via PEP 562 lazy __getattr__ (defers the Prefect
6767
import so CKAN admin commands don't spin up a Prefect server).
6868
Exposes `datapusher_plus_flow`, `push_to_datastore` (v2 shim),
69-
`datapusher_plus_to_datastore` (alias), `callback_datapusher_hook`.
69+
`datapusher_plus_to_datastore` (alias), `callback_datapusher_hook`,
70+
`run_job`.
71+
pipeline_core.py → Prefect-free core shared by both runners: callback_datapusher_hook,
72+
validate_input, build_runtime_context, run_stage + StageAbort,
73+
resource_is_datastore_dump, rollback_datastore_writes, resolve_int.
74+
**Nothing here may import prefect.**
75+
local_runner.py → The `prefect_enabled = false` path. `enqueue_job` puts the job on
76+
CKAN's RQ queue (`ckan jobs worker` runs it); `run_job` executes the
77+
nine stages sequentially over one live RuntimeContext, owning the
78+
same Jobs-row state machine and callbacks as the flow. No retries,
79+
caching, artifacts, events, or PII suspend-for-review.
7080
context.py → ProcessingContext — per-run mutable state shared across stages.
7181
runtime_context.py → JobInput (frozen, JSON-serializable flow input), the per-stage
7282
`*Result` dataclasses (DownloadResult, AnalyzeResult, …), the
@@ -96,6 +106,8 @@ stages/
96106

97107
Operators can register a custom flow via `ckanext.datapusher_plus.prefect_flow`; the per-stage `@task` functions in `prefect_flow.py` are the public composable primitives.
98108

109+
Prefect can also be turned off entirely with `ckanext.datapusher_plus.prefect_enabled = false`, which routes submissions to `jobs/local_runner.py` on CKAN's RQ worker. When touching the pipeline, keep shared logic in `jobs/pipeline_core.py` so both runners stay in sync — and keep that module free of any `prefect` import, since the disabled mode exists for hosts where importing Prefect itself fails.
110+
99111
### Key Modules
100112

101113
- **plugin.py** — CKAN plugin entry point, implements IConfigurer, IConfigurable, IActions, IAuthFunctions, IPackageController, IResourceUrlChange, IResourceController, ITemplateHelpers, IBlueprint, IClick (+ IFormRedirect conditionally)
@@ -168,3 +180,4 @@ Key settings in `ckan.ini` (see config.py and config_declaration.yaml for the fu
168180
- `ckanext.datapusher_plus.prefer_dmy` — Date format preference (DMY vs MDY)
169181
- `ckanext.datapusher_plus.enable_druf` — Enable DRUF workflow
170182
- `ckanext.datapusher_plus.enable_form_redirect` — Enable IFormRedirect interface
183+
- `ckanext.datapusher_plus.prefect_enabled` — Orchestrate with Prefect (default: true); `false` runs jobs in-process on `ckan jobs worker`

CONFIG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,27 @@ When DRUF is enabled, the following templates are overridden:
5858
- Works with standard CKAN installations
5959
- Compatible with ckanext-scheming
6060

61+
### Turning Prefect off
62+
63+
By default (v3.0+) ingestion jobs are orchestrated by a Prefect server + worker. Setting `prefect_enabled = false` runs them in-process on CKAN's own background-job worker instead, over the same ingestion stages, with nothing in the path importing `prefect`.
64+
65+
**Configuration:**
66+
```ini
67+
# Orchestrate jobs with Prefect (default: true)
68+
ckanext.datapusher_plus.prefect_enabled = false
69+
```
70+
71+
**What it does:**
72+
- `datapusher_submit` enqueues the job on CKAN's RQ queue instead of creating a Prefect flow run
73+
- `ckanext/datapusher_plus/jobs/local_runner.py` executes the nine stages sequentially in the worker process
74+
- The `Jobs`/`Logs` tables, the job-status page, and the `datapusher_hook` callbacks behave identically
75+
76+
**Requirements:**
77+
- A running CKAN worker: `ckan -c /etc/ckan/default/ckan.ini jobs worker`
78+
- No Prefect server, worker, or work pool (`datapusher_plus prefect-deploy` refuses to run in this mode)
79+
80+
**What you give up:** per-stage retries, result caching / re-run-from-failed-stage, the Prefect run graph, artifacts and `datapusher.*` events, and human-in-the-loop PII review (a job crossing `pii_review_threshold` aborts before any datastore write instead of waiting for approval). See [Running without Prefect](README.md#running-without-prefect) for the full comparison — including the common trigger for wanting it, a `PermissionError` on `$PREFECT_HOME/profiles.toml` when CKAN cannot write `$HOME/.prefect`.
81+
6182
## Example Configuration
6283

6384
Add these lines to your CKAN configuration file (e.g., `/etc/ckan/default/ckan.ini`):
@@ -68,6 +89,10 @@ ckanext.datapusher_plus.enable_druf = true
6889

6990
# Enable IFormRedirect for better form redirects (recommended with DRUF)
7091
ckanext.datapusher_plus.enable_form_redirect = true
92+
93+
# Run ingestions in-process on CKAN's job worker instead of Prefect
94+
# (default: true — leave unset to keep Prefect orchestration)
95+
ckanext.datapusher_plus.prefect_enabled = false
7196
```
7297

7398
**Recommended combinations:**

README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ ckan -c /etc/ckan/default/ckan.ini datapusher_plus submit {dataset_id}
570570
571571
## Prefect orchestration (v3.0+)
572572
573-
DataPusher+ v3.0 replaces the v2 RQ-based background worker with a [Prefect 3](https://docs.prefect.io/v3/) flow. RQ is no longer used by DP+ itself (CKAN continues to ship RQ for unrelated extensions).
573+
DataPusher+ v3.0 replaces the v2 RQ-based background worker with a [Prefect 3](https://docs.prefect.io/v3/) flow. RQ is no longer used by DP+ itself (CKAN continues to ship RQ for unrelated extensions) — unless you set `ckanext.datapusher_plus.prefect_enabled = false`, which runs ingestions on CKAN's RQ worker instead of Prefect; see [Running without Prefect](#running-without-prefect).
574574
575575
### Why Prefect
576576
@@ -733,6 +733,7 @@ The **default** DP+ flow does NOT call these subflows — it inlines the underly
733733

734734
| Key | Default | Purpose |
735735
|---|---|---|
736+
| `ckanext.datapusher_plus.prefect_enabled` | `true` | Orchestrate ingestions with Prefect. `false` runs them in-process on CKAN's own job worker with no Prefect import at all — see [Running without Prefect](#running-without-prefect). |
736737
| `ckanext.datapusher_plus.prefect_deployment_name` | `datapusher-plus/datapusher-plus` | Fully-qualified Prefect deployment name (`<flow>/<deployment>`). |
737738
| `ckanext.datapusher_plus.prefect_work_pool` | `datapusher-plus` | Work-pool name workers subscribe to. |
738739
| `ckanext.datapusher_plus.prefect_flow` | _(unset)_ | `module.path:flow_name` entrypoint of a custom flow. |
@@ -765,11 +766,61 @@ takes effect on the next flow run without a worker restart.
765766

766767
Resolution order: Prefect Variable -> env var -> `ckan.ini` -> built-in default. Variable lookup failures (Prefect server unreachable, name absent, value not int-parseable) silently fall through to the next priority — operators with no Prefect Variables set see no behaviour change.
767768

769+
## Running without Prefect
770+
771+
Prefect can be turned off entirely:
772+
773+
```ini
774+
ckanext.datapusher_plus.prefect_enabled = false
775+
```
776+
777+
Submissions are then enqueued on **CKAN's own background-job queue** and executed in-process by `ckanext/datapusher_plus/jobs/local_runner.py`, which runs the same nine ingestion stages in the same order. Nothing in the submit or job path imports `prefect`.
778+
779+
Instead of a Prefect server + worker, you run CKAN's built-in worker:
780+
781+
```bash
782+
ckan -c /etc/ckan/default/ckan.ini jobs worker
783+
```
784+
785+
That is the only operational change — `ckan datapusher_plus submit` / `resubmit`, the DRUF workflow, the job-status page, the `Jobs`/`Logs` tables, and the `datapusher_hook` callbacks all behave as before. `ckan datapusher_plus prefect-deploy` refuses to run in this mode (there is no deployment to register).
786+
787+
### When you'd want this
788+
789+
* **Prefect can't be run at all** — including the case where merely *importing* it fails. A CKAN process running with `HOME=/root` but no write access there fails submission with:
790+
791+
```
792+
ERROR [ckanext.datapusher_plus.logic.action] Error submitting job to DataPusher: [Errno 13] Permission denied: '/root/.prefect/profiles.toml'
793+
```
794+
795+
Prefect writes its profile store to `$PREFECT_HOME` (default `$HOME/.prefect`) the first time it is imported. If you'd rather keep Prefect, the alternative fix is to point `PREFECT_HOME` at a directory the CKAN user can write and restart CKAN and the worker:
796+
797+
```bash
798+
PREFECT_HOME=/var/lib/ckan/prefect
799+
```
800+
801+
* **Small or single-node deployments** where a second orchestration service isn't worth the operational surface.
802+
* **Air-gapped or locked-down hosts** where the Prefect server isn't permitted.
803+
804+
### What you give up
805+
806+
| Capability | With Prefect | With `prefect_enabled = false` |
807+
|---|---|---|
808+
| Per-stage retries / backoff | Yes | No — the job fails and is resubmitted |
809+
| Result caching, re-run from failed stage | Yes | No |
810+
| Run graph, artifacts, `datapusher.*` events | Yes | No (events are no-ops) |
811+
| Human-in-the-loop PII review (`pii_review_threshold`) | Suspends for approval | Aborts the job before any datastore write |
812+
| Datastore cleanup after a failed write group | Transactional rollback | Same cleanup, minus the database stage's own failure (see below) |
813+
| Horizontal scaling | Add Prefect workers | Add `ckan jobs worker` processes |
814+
815+
Rollback difference in detail: when a stage *after* the database load fails, the local runner drops the half-built datastore table and restores a stashed Data Dictionary, exactly as the Prefect `on_rollback` hook does. When the **database stage itself** raises, it leaves the datastore alone — that stage can fail before touching anything (e.g. "could not connect to the Datastore"), and dropping there would destroy data the run never wrote.
816+
768817
### Troubleshooting
769818

770819
| Symptom | Likely cause | Fix |
771820
|---|---|---|
772821
| `datapusher_submit` returns `False` with a Prefect connection error in the CKAN log | The Prefect server is unreachable from CKAN | Check `PREFECT_API_URL` and that the Prefect server is healthy at `<API>/health`. |
822+
| `Error submitting job to DataPusher: [Errno 13] Permission denied: '/root/.prefect/profiles.toml'` | CKAN's process can't write `$PREFECT_HOME`, so `import prefect` fails | Set `PREFECT_HOME` to a writable directory, or turn Prefect off with `ckanext.datapusher_plus.prefect_enabled = false` (see [Running without Prefect](#running-without-prefect)). |
823+
| With `prefect_enabled = false`, jobs stay `pending` forever | No CKAN background worker is running | Start `ckan -c /etc/ckan/default/ckan.ini jobs worker`. |
773824
| Flow run sits in `Scheduled` forever | No worker is polling the configured work pool | Start `prefect worker start -p datapusher-plus` on a host with the `datapusher-plus` package installed. |
774825
| Flow run goes straight to `Failed` with "QSV binary not found" | The worker process can't see the qsv binary | Set `ckanext.datapusher_plus.qsv_bin` in the CKAN config the worker reads, or install qsv in the worker's PATH. |
775826
| Re-run from a failed task re-downloads the file | Result storage block isn't registered, so persisted results aren't being read | Re-run `ckan datapusher_plus prefect-deploy` — it calls `ensure_result_storage_block`. |

ckanext/datapusher_plus/cli.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,17 @@ def prefect_deploy(work_pool: str | None):
274274
``datapusher_plus_flow`` otherwise. This is how operators register
275275
custom ingestion flows without modifying DP+.
276276
"""
277+
import ckanext.datapusher_plus.config as conf
278+
279+
if not conf.prefect_enabled():
280+
error_shout(
281+
"ckanext.datapusher_plus.prefect_enabled is false — this "
282+
"deployment runs ingestions in-process on CKAN's RQ worker, "
283+
"so there is no Prefect deployment to register. Run "
284+
"`ckan jobs worker` instead, or set prefect_enabled = true."
285+
)
286+
raise click.Abort()
287+
277288
try:
278289
from prefect import flow as _flow_decorator # noqa: F401
279290
from prefect.deployments.runner import RunnerDeployment # noqa: F401
@@ -457,15 +468,26 @@ def migrate_from_rq(resubmit: bool, yes: bool):
457468
session.commit()
458469
click.echo(f"Reset {reset_count} stale ``pending`` task_status rows.")
459470

460-
# Sanity-check Prefect server reachability.
461-
try:
462-
import ckanext.datapusher_plus.prefect_client as prefect_client
471+
# Sanity-check Prefect server reachability — unless the operator
472+
# migrated off RQ *without* adopting Prefect, in which case there is
473+
# no server to reach and jobs run on CKAN's own worker.
474+
import ckanext.datapusher_plus.config as conf
463475

464-
prefect_client.get_running_resource_ids()
465-
click.echo("Prefect server is reachable.")
466-
except Exception as e:
467-
error_shout(f"Cannot reach Prefect server: {e}")
468-
raise click.Abort()
476+
if not conf.prefect_enabled():
477+
click.echo(
478+
"ckanext.datapusher_plus.prefect_enabled is false — skipping "
479+
"the Prefect reachability check; jobs will run in-process on "
480+
"CKAN's RQ worker (`ckan jobs worker`)."
481+
)
482+
else:
483+
try:
484+
import ckanext.datapusher_plus.prefect_client as prefect_client
485+
486+
prefect_client.get_running_resource_ids()
487+
click.echo("Prefect server is reachable.")
488+
except Exception as e:
489+
error_shout(f"Cannot reach Prefect server: {e}")
490+
raise click.Abort()
469491

470492
# Optional: resubmit each drained resource through the new path.
471493
if resubmit:

ckanext/datapusher_plus/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,37 @@
3737
# TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
3838
UPLOAD_LOG_LEVEL = tk.config.get("ckanext.datapusher_plus.upload_log_level", "INFO")
3939

40+
41+
# Orchestration backend.
42+
#
43+
# ``True`` (default) submits every ingestion to Prefect. ``False`` turns
44+
# Prefect off completely: jobs are enqueued on CKAN's own RQ background
45+
# queue and executed in-process by ``jobs/local_runner.py``, and nothing
46+
# in the request or job path imports ``prefect``. That matters for
47+
# deployments where importing Prefect itself fails — e.g. a CKAN process
48+
# running with ``HOME=/root`` but no write access there, where Prefect's
49+
# settings bootstrap raises ``PermissionError: '/root/.prefect/profiles.toml'``.
50+
#
51+
# Deliberately a function rather than a module-level constant: the flag
52+
# gates a code path in the web request (``datapusher_submit``) and in the
53+
# CLI, so operators flipping it in ``ckan.ini`` should not need a
54+
# restart-order-of-operations lesson. Reading it live also keeps
55+
# ``config_declaration.yaml``'s declared default authoritative under
56+
# CKAN 2.10+.
57+
def prefect_enabled() -> bool:
58+
"""Return whether ingestion jobs are orchestrated by Prefect.
59+
60+
Falls back to ``True`` in contexts where CKAN config is not loaded
61+
(bare tooling imports), matching the shipped default.
62+
"""
63+
try:
64+
value = tk.config.get("ckanext.datapusher_plus.prefect_enabled")
65+
except Exception:
66+
return True
67+
if value is None or value == "":
68+
return True
69+
return tk.asbool(value)
70+
4071
# Supported formats
4172
FORMATS = tk.config.get(
4273
"ckanext.datapusher_plus.formats",

0 commit comments

Comments
 (0)