Skip to content

perf(uwsgi): set lazy-app = false so the master preforks the app (MAPCO-11223) - #89

Merged
razbroc merged 2 commits into
feat/otel-postfork-initfrom
chore/uwsgi-lazy-app-false
Aug 2, 2026
Merged

razbroc merged 2 commits into
feat/otel-postfork-initfrom
chore/uwsgi-lazy-app-false

Conversation

@razbroc

@razbroc razbroc commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
Question Answer
Bug fix ✔
New feature ✖
Breaking change ✖
Deprecations ✖
Documentation ✔
Tests added ✖
Chore ✔

Related issues: MAPCO-11223, MAPCO-11221 (sub-tasks of MAPCO-11217)

Stacked on #88. The base of this PR is feat/otel-postfork-init, so the diff shown here is only the config flip. Merge #88 first; GitHub will retarget this to master automatically. Do not merge this one alone — without the _init_telemetry() dispatch from #88, loading the app in the master leaves every worker sharing one set of provider objects and one inherited gRPC channel.

Further information:

What this does

One-line behavioural change in helm/config/mapProxyUwsgi.ini:

-lazy-app = true                      ; Fork workers after app load (required for OTel) todo check this variable effect
+; Load the app once in the master, then fork. Workers share the parsed mapproxy.yaml
+; copy-on-write. app.py builds the OTel providers in a postfork hook so their export
+; threads and gRPC channels are still created per worker — see src/app.py.
+lazy-app = false

The master now parses mapproxy.yaml once 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 = true means workers load the app after fork, not "fork workers after app load" — and carried a stale todo. 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:

Process RSS
master 108.5 MiB
workers (x6) 78.5 / 78.7 / 78.9 / 78.5 / 78.8 / 78.9 MiB
total 472.2 MiB

There is no lazy-app = true arm to compare against. lazy-app is 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 = 2048 ceiling. 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-app is 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, harakiri is 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:

  1. nginx.fullnameOverride collides with the shared raster-serving-dev-mapproxy-nginx-* resource names. A failed install attempt under the colliding name had its cleanup delete the live raster-serving-dev-mapproxy-nginx-route, and the replacement release then claimed tiles-dev.mapcolonies.net and pointed it at a service with no endpoints. That took the dev tile endpoint down with 503s until the route was restored from helm get manifest. Override the nginx fullname, and check oc get routes for host collisions before and after.
  2. The vendored nginx-2.2.1 subchart has a literal tab in configMap: that breaks PyYAML parsing of the rendered manifest, and its nginx.conf subPath mount fails with CreateContainerError ("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's http-socket on 8080 directly.

Testing

Deployed as an isolated release in raster-dev with this config. Load order from the logs:

[otel] imported in uWSGI master (lazy-app=false) - telemetry deferred to postfork hook
WSGI app 0 (mountpoint='') ready in 13 seconds on interpreter ... pid: 1
spawned uWSGI worker 1 (pid: 25) ... spawned uWSGI worker 6 (pid: 30)

Probe / TracerProvider / MeterProvider init 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/WMS GetCapabilities both returned 200.

Unverified: spans landing in the collector backend (only absence of export errors was observed), the atexit flush on a real worker recycle, and cold-start-to-first-response end to end.

@razbroc
razbroc force-pushed the chore/uwsgi-lazy-app-false branch from 817524a to cba9d16 Compare July 27, 2026 13:28
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
razbroc force-pushed the chore/uwsgi-lazy-app-false branch from cba9d16 to 3f3a673 Compare July 27, 2026 13:34
Comment thread helm/config/mapProxyUwsgi.ini Outdated
Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com>
@razbroc
razbroc merged commit 4236052 into feat/otel-postfork-init Aug 2, 2026
4 checks passed
@razbroc
razbroc deleted the chore/uwsgi-lazy-app-false branch August 2, 2026 08:31
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants