Skip to content

Commit 0f72eda

Browse files
jqnatividadclaude
andcommitted
fix(prefect): bootstrap full CKAN app in worker; fail CI on bad ingestion
The flow crashed silently in every CI run: the worker subprocess's lightweight bootstrap populated config + the model bind but never loaded CKAN's action registry. `_build_runtime_context` calls `datastore_utils.get_resource` -> `tk.get_action("resource_show")`, which raised because no actions were registered. That call sits *before* the flow's try/except, so the exception escaped, the process exited 1 with no traceback, `mark_job_as_errored` never ran, and the job stayed stuck at "running" (set by the announce callback). Three fixes: 1. Bootstrap the full CKAN app via the canonical `make_app(load_config (ini))` path -- exactly how `ckan jobs worker` ran the v2 pipeline. `make_app` runs `load_environment` (populates ckan.common.config, binds the model, loads every plugin + action) then builds the Flask stack. Skip when an app context already exists or ckan.common.config is already populated (the `ckan` CLI's CtxObject case), and fail loud with a traceback if make_app itself fails. 2. Move `_build_runtime_context` / `set_runtime_context` inside the flow's try/except so any failure there is recorded via mark_job_as_errored and surfaced through the error callback instead of escaping the flow silently. 3. The CI test step printed "ALL TESTED FILES FAILED" but always exited 0, so CI was green while every file failed. Make it exit 1 when any tested file does not fully ingest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cbb58b8 commit 0f72eda

2 files changed

Lines changed: 90 additions & 98 deletions

File tree

.github/workflows/main.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1034,15 +1034,28 @@ jobs:
10341034
echo ""
10351035
echo "⚠ OVERALL RESULT: PARTIAL SUCCESS"
10361036
echo "DataPusher Plus works with some remote files but has issues with others"
1037+
overall_failed=1
10371038
else
10381039
echo ""
10391040
echo "❌ OVERALL RESULT: ALL TESTED FILES FAILED"
10401041
echo "DataPusher Plus is not working correctly with remote files"
1042+
overall_failed=1
10411043
fi
1042-
1044+
10431045
echo ""
10441046
echo "Test completed at: $(date)"
10451047
1048+
# Fail the step when any tested file did not fully ingest
1049+
# (upload SUCCESS + datapusher_status complete + datastore
1050+
# active). Previously this step only echoed the verdict and
1051+
# always exited 0, so CI stayed green even when every file
1052+
# failed. The detailed per-file breakdown is still produced by
1053+
# the "Generate Combined Test Results" step (if: always()).
1054+
if [ "${overall_failed:-0}" -ne 0 ]; then
1055+
echo "::error::DataPusher+ ingestion failed for $failed_files of $total_files tested file(s)"
1056+
exit 1
1057+
fi
1058+
10461059
- name: Generate Combined Test Results and Worker Analysis
10471060
if: always()
10481061
run: |

ckanext/datapusher_plus/jobs/prefect_flow.py

Lines changed: 76 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -58,116 +58,82 @@
5858

5959

6060
def _bootstrap_ckan_app_context() -> None:
61-
"""Bring up just enough CKAN in a Prefect worker subprocess.
61+
"""Bring up the full CKAN app inside a Prefect worker subprocess.
6262
6363
The ``process`` worker spawns a fresh Python interpreter per flow
64-
run; CKAN's normal startup never happens. Three things break
65-
without a bootstrap:
66-
67-
1. ``tk.config`` — which is the module-level ``ckan.common.config``
68-
singleton, NOT ``flask.current_app.config`` — is empty, so DP+
69-
config reads return ``None``.
70-
2. CKAN's SQLAlchemy ``model.Session`` has no engine bound, so the
71-
first ``Jobs(...).save()`` raises ``UnboundExecutionError``.
72-
3. Flask-context-dependent helpers have no application context.
73-
74-
We deliberately avoid ``make_flask_stack`` / ``load_environment`` —
75-
they load the full plugin chain (risking recursion) and
76-
``make_flask_stack`` tripped a NoneType.split error in CI run
77-
25838697116. Instead:
78-
79-
* parse the ini (``ckan.cli.load_config``, ``configparser`` fallback);
80-
* ``ckan.common.config.update(cfg)`` — this is what actually makes
81-
``tk.config.get(...)`` return real values;
82-
* push a bare Flask app context for the Flask-context-dependent
83-
code paths;
84-
* build a SQLAlchemy engine from ``sqlalchemy.url`` and call
85-
``ckan.model.init_model``.
86-
87-
All steps are individually wrapped — a failure is logged and the
88-
downstream DP+ import / first DB call surfaces the real error.
89-
No-op when ``CKAN_INI`` is unset or an app context already exists
90-
(i.e. imported from the CKAN web process).
64+
run; CKAN's normal startup never happens. Without a bootstrap there
65+
is no Flask app, no populated ``tk.config``, no SQLAlchemy bind, and
66+
— critically — no action registry, so ``tk.get_action(...)`` (used
67+
by ``datastore_utils.get_resource`` and the metadata stage) raises.
68+
69+
Bootstrap exactly the way ``ckan``'s CLI does: ``make_app`` over the
70+
config returned by ``ckan.cli.load_config``. ``make_app`` runs
71+
``load_environment`` — which populates ``ckan.common.config`` (what
72+
``tk.config`` proxies to), binds ``ckan.model``, and loads every
73+
plugin + action — then builds the Flask stack. We push an
74+
application context afterwards. After this, DP+'s ``config.py``
75+
reads, ``Jobs(...).save()``, and local action calls all work just as
76+
they do inside the CKAN web process.
77+
78+
An earlier attempt used a hand-rolled lightweight bootstrap (parse
79+
ini, ``ckan.common.config.update``, bare Flask context,
80+
``model.init_model``). It was insufficient: only ``load_environment``
81+
populates the action registry, so the flow crashed in
82+
``get_resource`` *before* its own ``try/except`` could record the
83+
error — leaving jobs stuck "running" forever. The full bootstrap is
84+
the correct analogue of how ``ckan jobs worker`` ran the v2 pipeline.
85+
86+
No-op in three cases:
87+
88+
* ``CKAN_INI`` is unset — nothing to bootstrap from;
89+
* an app context already exists — imported from the CKAN web process;
90+
* ``ckan.common.config`` is already populated — imported from a
91+
``ckan`` CLI command (e.g. ``datapusher_plus prefect-deploy``),
92+
whose ``CtxObject`` already ran ``make_app``. Re-running it would
93+
load every plugin a second time.
9194
"""
9295
ini = os.environ.get("CKAN_INI")
9396
if not ini or not os.path.exists(ini):
9497
return
9598
try:
96-
from flask import Flask, has_app_context
99+
from flask import has_app_context
97100

98101
if has_app_context():
99102
return
100103
except Exception:
101104
return
102105

103-
log = logging.getLogger(__name__)
104-
105-
# --- parse the ini -----------------------------------------------------
106-
cfg: Dict[str, Any]
107-
try:
108-
from ckan.cli import load_config
109-
110-
cfg = dict(load_config(ini))
111-
except Exception as e:
112-
log.warning(
113-
"DP+ Prefect bootstrap: ckan.cli.load_config failed (%s); "
114-
"falling back to direct configparser",
115-
e,
116-
)
117-
try:
118-
import configparser
119-
120-
parser = configparser.ConfigParser()
121-
parser.read(ini)
122-
cfg = (
123-
dict(parser.items("app:main"))
124-
if parser.has_section("app:main")
125-
else {}
126-
)
127-
except Exception as e2:
128-
log.warning(
129-
"DP+ Prefect bootstrap: direct ini parse also failed: %s", e2
130-
)
131-
return
132-
133-
# --- populate ckan.common.config (makes tk.config.get work) -----------
134-
# This is the critical step: ``tk.config`` proxies to this singleton,
135-
# not to Flask's app config.
106+
# The CKAN CLI builds the app via its own CtxObject before our
107+
# command imports this module — ``ckan.common.config`` is populated
108+
# even though no app context is pushed. ``ckan.site_url`` is a
109+
# required key always present once load_environment has run, and
110+
# empty in a fresh worker interpreter; use it as the discriminator.
136111
try:
137112
from ckan.common import config as ckan_config
138113

139-
ckan_config.update(cfg)
140-
except Exception as e:
141-
log.warning(
142-
"DP+ Prefect bootstrap: ckan.common.config.update failed: %s", e
143-
)
144-
return
145-
146-
# --- push a Flask app context (for Flask-context-dependent code) ------
147-
try:
148-
app = Flask("dpp-prefect-worker")
149-
for key, value in cfg.items():
150-
app.config[key] = value
151-
app.app_context().push()
152-
except Exception as e:
153-
log.warning(
154-
"DP+ Prefect bootstrap: Flask app context push failed: %s", e
155-
)
114+
if ckan_config.get("ckan.site_url"):
115+
return
116+
except Exception:
117+
pass
156118

157-
# --- bind CKAN's SQLAlchemy model (makes Jobs(...).save() work) --------
119+
log = logging.getLogger(__name__)
158120
try:
159-
import sqlalchemy
160-
161-
import ckan.model as model
121+
from ckan.cli import load_config
122+
from ckan.config.middleware import make_app
162123

163-
engine = sqlalchemy.engine_from_config(cfg, "sqlalchemy.")
164-
model.init_model(engine)
165-
except Exception as e:
166-
log.warning(
167-
"DP+ Prefect bootstrap: ckan.model.init_model failed (%s); "
168-
"DB-touching tasks will fail until this is resolved",
169-
e,
124+
ckan_app = make_app(load_config(ini))
125+
ckan_app._wsgi_app.app_context().push()
126+
except Exception:
127+
# A worker that cannot bootstrap CKAN cannot run any DP+ flow.
128+
# Fail loud with a full traceback in the worker log rather than
129+
# limping on with an empty config / action registry — that mode
130+
# produced silent ``exit 1`` crashes that left jobs stuck
131+
# "running" forever.
132+
log.error(
133+
"DP+ Prefect bootstrap: make_app failed; cannot run flows",
134+
exc_info=True,
170135
)
136+
raise
171137

172138

173139
_bootstrap_ckan_app_context()
@@ -911,9 +877,19 @@ def datapusher_plus_flow(job_input: JobInput) -> Optional[str]:
911877

912878
errored = False
913879
with tempfile.TemporaryDirectory() as temp_dir:
914-
runtime = _build_runtime_context(job_input, temp_dir)
915-
token = set_runtime_context(runtime)
880+
# ``runtime`` / ``token`` are built *inside* the try so that a
881+
# failure in _build_runtime_context (e.g. get_resource raising)
882+
# is caught: mark_job_as_errored runs and the error callback
883+
# fires, instead of the exception escaping the flow silently and
884+
# leaving the job stuck "running" (set by the announce callback
885+
# above). Both stay None until successfully built; the except /
886+
# finally blocks guard on that.
887+
runtime = None
888+
token = None
916889
try:
890+
runtime = _build_runtime_context(job_input, temp_dir)
891+
token = set_runtime_context(runtime)
892+
917893
if _resource_is_datastore_dump(runtime):
918894
runtime.logger.info("Dump files are managed with the Datastore API")
919895
dph.mark_job_as_completed(job_id, {"skipped": "datastore-managed"})
@@ -992,20 +968,23 @@ def datapusher_plus_flow(job_input: JobInput) -> Optional[str]:
992968
except utils.JobError as e:
993969
errored = True
994970
dph.mark_job_as_errored(job_id, str(e))
995-
runtime.logger.error(f"DataPusher Plus error: {e}")
971+
if runtime is not None:
972+
runtime.logger.error(f"DataPusher Plus error: {e}")
996973
prefect_logger.error(f"DataPusher Plus error: {e}")
997974
raise
998975
except Exception as e:
999976
errored = True
1000977
tb = traceback.format_tb(sys.exc_info()[2])[-1] + repr(e)
1001978
dph.mark_job_as_errored(job_id, tb)
1002-
runtime.logger.error(
1003-
f"DataPusher Plus error: {e}, {traceback.format_exc()}"
1004-
)
979+
if runtime is not None:
980+
runtime.logger.error(
981+
f"DataPusher Plus error: {e}, {traceback.format_exc()}"
982+
)
1005983
prefect_logger.error(f"DataPusher Plus error: {e}")
1006984
raise
1007985
finally:
1008-
reset_runtime_context(token)
986+
if token is not None:
987+
reset_runtime_context(token)
1009988
if result_url:
1010989
status = "error" if errored else "complete"
1011990
saved_ok = callback_datapusher_hook(

0 commit comments

Comments
 (0)