perf(uwsgi): set lazy-app = false so the master preforks the app (MAPCO-11223) - #89
Merged
Merged
Conversation
razbroc
force-pushed
the
chore/uwsgi-lazy-app-false
branch
from
July 27, 2026 13:28
817524a to
cba9d16
Compare
Flip the chart's uwsgi ini to load mapproxy.yaml once in the master and
fork workers from it, so the parsed config is shared copy-on-write
instead of being parsed once per worker.
Safe only on top of the _init_telemetry() dispatch: app.py detects that
it was imported in the master and defers provider construction to a
postfork hook, so each worker still gets its own export threads and its
own gRPC channels.
The replaced comment claimed the opposite of the value it annotated
("Fork workers after app load (required for OTel)" on lazy-app = true)
and carried a stale todo.
Refs: MAPCO-11223, MAPCO-11221
razbroc
force-pushed
the
chore/uwsgi-lazy-app-false
branch
from
July 27, 2026 13:34
cba9d16 to
3f3a673
Compare
CL-SHLOMIKONCHA
approved these changes
Aug 2, 2026
Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com>
razbroc
added a commit
that referenced
this pull request
Aug 2, 2026
…atch (MAPCO-11222) (#88) * refactor(otel): build providers in _init_telemetry() with a postfork hook Move the TracerProvider/MeterProvider construction out of import scope into _init_telemetry(), and dispatch on uwsgi.worker_id() at the bottom of the module to decide when to call it: - imported in the uWSGI master (lazy-app = false) -> defer via postfork - imported in a worker (lazy-app = true) -> initialise eagerly - not under uWSGI at all -> initialise eagerly Instrumentors are installed at import time and bind to ProxyTracer objects, which resolve once a real provider is set, so the explicit tracer_provider= kwargs are no longer needed and were removed. Both providers are flushed from an atexit handler so the final batch is exported when a worker recycles. This commit is behaviour-preserving under the chart's current lazy-app = true: the module is imported in the worker, so telemetry initialises eagerly exactly as before. Flipping lazy-app is a separate change. Also corrects the readme's claim that a master-initialised BatchSpanProcessor has its export thread die silently on fork. Verified false against SDK 1.44.0, where BatchSpanProcessor delegates to BatchProcessor in opentelemetry.sdk._shared_internal and an at-fork handler restarts the thread in the child. Refs: MAPCO-11222, MAPCO-11221 * fix(otel): guard uwsgidecorators import and correct the atexit flush claim Three review findings from PR #88. 1. uwsgidecorators raises a bare Exception, not ImportError, when the uWSGI master is disabled: if uwsgi.masterpid() == 0: raise Exception("you have to enable the uWSGI master process ...") The dispatch caught only ImportError, so any uWSGI run without `master = true` died while importing app.py — and with `need-app = true` uWSGI then refuses to boot. Before this refactor the module never imported uwsgi at all, so this was a regression. The deployed chart sets master = true, so dev and prod were unaffected; debug runs and consumer-supplied inis were not. It was also imported unconditionally, including on the worker branch where postfork is never used. Now imported only in the master branch, wrapped in `except Exception`, falling back to eager init so a masterless worker gets telemetry rather than none. 2. The atexit flush was documented as guaranteeing the final batch is exported on worker recycle. It does not. uWSGI's python plugin skips Py_Finalize(), and therefore all atexit handlers, when the worker is hijacked, is busy in a request, or runs async; SIGKILL paths (harakiri, worker-reload-mercy expiry) bypass it entirely. Softened to best-effort in both the readme and the _shutdown_telemetry docstring, with the conditions spelled out. 3. The readme described the postfork hook as the shipped path, which is not true while the chart has lazy-app = true. Reworded to lead with the worker_id dispatch and to stay accurate under either setting. Refs: MAPCO-11221, MAPCO-11222 * fix(otel): bind provider globals to the installed provider, not the built one _init_telemetry() assigned the module global before calling set_tracer_provider / set_meter_provider, then reassigned it to a fresh fallback provider in the except handler. Both setters are FIRST-WINS -- a second call logs "Overriding of current TracerProvider is not allowed" and does nothing (verified against the SDK in the image). So if anything raised *after* the set succeeded -- a broken log handler from log.ini is the realistic trigger, since an _otel_log call sits between the set and the end of the block -- the global ended up pointing at an inert fallback while the real provider stayed globally installed. _shutdown_telemetry() then shut down the orphan and never flushed the live BatchSpanProcessor queue: the exact opposite of what the atexit handler exists to do. Both blocks now build into a local, and a finally clause binds the global from trace.get_tracer_provider() / metrics.get_meter_provider() so it always tracks whatever is actually installed. A Proxy*Provider means nothing was installed and has no shutdown(), so the global is left None -- which _shutdown_telemetry already skips. Reproduced against the image's SDK: under the old pattern the global was not the live provider; under the new one it is, and calling shutdown() on it flushes a span that the orphan would have dropped. Refs: MAPCO-11221, MAPCO-11222 * perf(uwsgi): set lazy-app = false so the master preforks the app (MAPCO-11223) (#89) * perf(uwsgi): set lazy-app = false so the master preforks the app Flip the chart's uwsgi ini to load mapproxy.yaml once in the master and fork workers from it, so the parsed config is shared copy-on-write instead of being parsed once per worker. Safe only on top of the _init_telemetry() dispatch: app.py detects that it was imported in the master and defers provider construction to a postfork hook, so each worker still gets its own export threads and its own gRPC channels. The replaced comment claimed the opposite of the value it annotated ("Fork workers after app load (required for OTel)" on lazy-app = true) and carried a stale todo. Refs: MAPCO-11223, MAPCO-11221 * Update helm/config/mapProxyUwsgi.ini Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com> --------- Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com> --------- Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Related issues: MAPCO-11223, MAPCO-11221 (sub-tasks of MAPCO-11217)
Further information:
What this does
One-line behavioural change in
helm/config/mapProxyUwsgi.ini:The master now parses
mapproxy.yamlonce and forks workers from it, instead of each of the 6 workers parsing it independently. Also updates the corresponding readme row.Note the comment being replaced described the opposite of the value it annotated —
lazy-app = truemeans workers load the app after fork, not "fork workers after app load" — and carried a staletodo. That inversion is probably why this was never revisited.Why the payoff is uncertain, and what I would want before merging
I want to be straight about this rather than let a green test imply more than it shows.
Measured RSS on the deployed test release with
lazy-app = false:There is no
lazy-app = truearm to compare against.lazy-appis hardcoded in this ini rather than exposed as a Helm value, so a controlled A/B was not possible on the deployed chart. That is MAPCO-11224, and it is the reason that sub-task cannot be closed as a pass.Worse for the motivation: worker RSS of ~79 MiB sits against a
reload-on-rss = 2048ceiling. Memory pressure does not appear to be an active problem here, so the copy-on-write saving may be worth very little in practice. Two further caveats on the numbers above — measurement was taken shortly after startup, which overstates CoW sharing before refcount churn erodes it, and CPython dirties shared pages as object refcounts change.So the honest framing is: the correctness work in #88 stands on its own; this flip is an optimisation whose benefit is unquantified. A reviewer could reasonably ask to park this until
lazy-appis a Helm value and the comparison exists. I would not object.The startup cost is real and measured, though: 13 seconds for the master to load the WSGI app. That is a single serial load before any worker exists, where previously 6 workers loaded in parallel. Startup and liveness probe timing therefore needs a look — MAPCO-11225. For the record,
harakiriis not relevant to this: it is a per-request timeout, not a startup timeout.Deployment hazard worth knowing about
Testing this required installing the chart standalone rather than through the umbrella release. Two traps, both hit:
nginx.fullnameOverridecollides with the sharedraster-serving-dev-mapproxy-nginx-*resource names. A failed install attempt under the colliding name had its cleanup delete the liveraster-serving-dev-mapproxy-nginx-route, and the replacement release then claimedtiles-dev.mapcolonies.netand pointed it at a service with no endpoints. That took the dev tile endpoint down with 503s until the route was restored fromhelm get manifest. Override the nginx fullname, and checkoc get routesfor host collisions before and after.nginx-2.2.1subchart has a literal tab inconfigMap:that breaks PyYAML parsing of the rendered manifest, and itsnginx.confsubPathmount fails withCreateContainerError("not a directory"). Pre-existing, not caused by anything in this repo, but it means the nginx container will not start in a standalone install — test against the app'shttp-socketon 8080 directly.Testing
Deployed as an isolated release in
raster-devwith this config. Load order from the logs:Probe /
TracerProvider/MeterProviderinit lines then appear once per worker, 6x each — confirming providers are built per worker after the fork, not inherited. Worker pids are contiguous 25-30, the signature of a single prefork rather than six independent app loads.Serving verified equivalent to the deployed
v1.9.2: the same WMTS tile returned a byte-identical 2662-byte 256x256 RGBA PNG, and WMTS/WMSGetCapabilitiesboth returned 200.Unverified: spans landing in the collector backend (only absence of export errors was observed), the
atexitflush on a real worker recycle, and cold-start-to-first-response end to end.