|
58 | 58 |
|
59 | 59 |
|
60 | 60 | 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. |
62 | 62 |
|
63 | 63 | 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. |
91 | 94 | """ |
92 | 95 | ini = os.environ.get("CKAN_INI") |
93 | 96 | if not ini or not os.path.exists(ini): |
94 | 97 | return |
95 | 98 | try: |
96 | | - from flask import Flask, has_app_context |
| 99 | + from flask import has_app_context |
97 | 100 |
|
98 | 101 | if has_app_context(): |
99 | 102 | return |
100 | 103 | except Exception: |
101 | 104 | return |
102 | 105 |
|
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. |
136 | 111 | try: |
137 | 112 | from ckan.common import config as ckan_config |
138 | 113 |
|
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 |
156 | 118 |
|
157 | | - # --- bind CKAN's SQLAlchemy model (makes Jobs(...).save() work) -------- |
| 119 | + log = logging.getLogger(__name__) |
158 | 120 | 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 |
162 | 123 |
|
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, |
170 | 135 | ) |
| 136 | + raise |
171 | 137 |
|
172 | 138 |
|
173 | 139 | _bootstrap_ckan_app_context() |
@@ -911,9 +877,19 @@ def datapusher_plus_flow(job_input: JobInput) -> Optional[str]: |
911 | 877 |
|
912 | 878 | errored = False |
913 | 879 | 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 |
916 | 889 | try: |
| 890 | + runtime = _build_runtime_context(job_input, temp_dir) |
| 891 | + token = set_runtime_context(runtime) |
| 892 | + |
917 | 893 | if _resource_is_datastore_dump(runtime): |
918 | 894 | runtime.logger.info("Dump files are managed with the Datastore API") |
919 | 895 | dph.mark_job_as_completed(job_id, {"skipped": "datastore-managed"}) |
@@ -992,20 +968,23 @@ def datapusher_plus_flow(job_input: JobInput) -> Optional[str]: |
992 | 968 | except utils.JobError as e: |
993 | 969 | errored = True |
994 | 970 | 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}") |
996 | 973 | prefect_logger.error(f"DataPusher Plus error: {e}") |
997 | 974 | raise |
998 | 975 | except Exception as e: |
999 | 976 | errored = True |
1000 | 977 | tb = traceback.format_tb(sys.exc_info()[2])[-1] + repr(e) |
1001 | 978 | 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 | + ) |
1005 | 983 | prefect_logger.error(f"DataPusher Plus error: {e}") |
1006 | 984 | raise |
1007 | 985 | finally: |
1008 | | - reset_runtime_context(token) |
| 986 | + if token is not None: |
| 987 | + reset_runtime_context(token) |
1009 | 988 | if result_url: |
1010 | 989 | status = "error" if errored else "complete" |
1011 | 990 | saved_ok = callback_datapusher_hook( |
|
0 commit comments