Skip to content

Commit f2d7826

Browse files
authored
fix(pii): load models only in workers and recycle them before memory runs out (#9060)
* fix(pii): load models only in workers and recycle them before memory runs out The uvicorn supervisor imports the app before spawning workers, so the module-level engine build left it holding an idle ninth copy of every model. Engines now build in the FastAPI lifespan, which runs only in serving processes. With more than one worker, each now exits after a jittered request count and the supervisor respawns it, bounding per-worker memory growth that otherwise reached the task limit about 11 days after a deploy. * improvement(pii): move the start command into pii-entrypoint.sh and build engines in lifespan
1 parent e1451e9 commit f2d7826

3 files changed

Lines changed: 59 additions & 33 deletions

File tree

‎apps/pii/server.py‎

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import json
1010
import logging
1111
import time
12+
from collections.abc import AsyncIterator
13+
from contextlib import asynccontextmanager
1214
from typing import Any
1315

1416
import regex as regex_module
@@ -174,10 +176,14 @@ def build_analyzer() -> AnalyzerEngine:
174176
logger.addHandler(_log_handler)
175177
logger.propagate = False
176178

177-
logger.info("building analyzer (spacy)")
178-
analyzer = build_analyzer()
179-
batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer)
180-
anonymizer = AnonymizerEngine()
179+
# Built per worker by `lifespan`, never at import: with --workers N the uvicorn
180+
# supervisor imports this module too, and a module-level build left it holding an
181+
# idle copy of every model.
182+
analyzer: AnalyzerEngine
183+
batch_analyzer: BatchAnalyzerEngine
184+
anonymizer: AnonymizerEngine
185+
NER_ENTITIES: frozenset
186+
_BLANK_ARTIFACTS: dict
181187

182188
# Every entity the spaCy NER recognizers can actually produce. A request touching
183189
# any of these must run spaCy; a request naming only non-NER (regex/checksum)
@@ -207,30 +213,6 @@ def _producible_ner_entities() -> frozenset:
207213
return frozenset(producible)
208214

209215

210-
NER_ENTITIES = _SPACY_NER_FLOOR | (
211-
frozenset(
212-
entity
213-
for recognizer in analyzer.registry.recognizers
214-
if isinstance(recognizer, SpacyRecognizer)
215-
for entity in recognizer.supported_entities
216-
)
217-
& _producible_ner_entities()
218-
)
219-
220-
# One blank NlpArtifacts per language, built once at startup. Passing these to
221-
# analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely:
222-
# the pattern recognizers still match on the raw text, SpacyRecognizer finds
223-
# nothing in blank artifacts (and can only be consulted at all for claimed-but-
224-
# unproducible entities like PHONE_NUMBER), and score_threshold is unset so
225-
# detection is identical.
226-
# Only context-based score boosting (which needs real tokens) is unavailable — an
227-
# accepted trade for skipping NER on the hot block-output path. Read-only, so it
228-
# is safe to share across requests and workers.
229-
_BLANK_ARTIFACTS = {
230-
language: analyzer.nlp_engine.process_text("", language)
231-
for language in SUPPORTED_LANGUAGES
232-
}
233-
234216

235217
def _regex_only(entities: list[str] | None, score_threshold: float | None) -> bool:
236218
"""True when the spaCy NLP pass can be skipped: the request names entities, none
@@ -308,7 +290,39 @@ def _analyze_many(
308290
)
309291

310292

311-
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None)
293+
@asynccontextmanager
294+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
295+
global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS
296+
logger.info("building analyzer (spacy)")
297+
analyzer = build_analyzer()
298+
batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer)
299+
anonymizer = AnonymizerEngine()
300+
NER_ENTITIES = _SPACY_NER_FLOOR | (
301+
frozenset(
302+
entity
303+
for recognizer in analyzer.registry.recognizers
304+
if isinstance(recognizer, SpacyRecognizer)
305+
for entity in recognizer.supported_entities
306+
)
307+
& _producible_ner_entities()
308+
)
309+
# One blank NlpArtifacts per language, built once at startup. Passing these to
310+
# analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely:
311+
# the pattern recognizers still match on the raw text, SpacyRecognizer finds
312+
# nothing in blank artifacts (and can only be consulted at all for claimed-but-
313+
# unproducible entities like PHONE_NUMBER), and score_threshold is unset so
314+
# detection is identical.
315+
# Only context-based score boosting (which needs real tokens) is unavailable — an
316+
# accepted trade for skipping NER on the hot block-output path. Read-only, so it
317+
# is safe to share across requests.
318+
_BLANK_ARTIFACTS = {
319+
language: analyzer.nlp_engine.process_text("", language)
320+
for language in SUPPORTED_LANGUAGES
321+
}
322+
yield
323+
324+
325+
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None, lifespan=lifespan)
312326

313327

314328
@app.exception_handler(RequestValidationError)

‎docker/pii-entrypoint.sh‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
# Starts uvicorn as PID 1 (exec) so SIGTERM reaches it directly. Quoted expansions
3+
# make a malformed PII_WORKERS fail uvicorn's arg parsing, not the shell.
4+
set -- --host 0.0.0.0 --port 5001 --workers "${PII_WORKERS:-1}" --timeout-graceful-shutdown 300
5+
6+
# Worker memory grows with uptime, so with a supervisor (more than one worker) each
7+
# worker exits after a jittered request count, health checks included, and is
8+
# respawned. A single worker has no supervisor to respawn it, so it never recycles.
9+
if [ "${PII_WORKERS:-1}" -gt 1 ] 2>/dev/null; then
10+
set -- "$@" --limit-max-requests "${PII_MAX_REQUESTS:-4000}" \
11+
--limit-max-requests-jitter "${PII_MAX_REQUESTS_JITTER:-2000}"
12+
fi
13+
14+
exec uvicorn server:app "$@"

‎docker/pii.Dockerfile‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ RUN groupadd -g 1001 pii && \
3939
chown -R pii:pii /app
4040

4141
COPY --chown=pii:pii apps/pii/server.py ./
42+
COPY --chown=pii:pii --chmod=755 docker/pii-entrypoint.sh ./
4243

4344
USER pii
4445

@@ -63,7 +64,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \
6364
# Worker count is env-driven so ONE image scales per task size: set PII_WORKERS to
6465
# the task's vCPU count (each worker loads the models independently, ~3 GB each, so
6566
# size task memory ≈ PII_WORKERS × 3 GB + overhead). Defaults to 1 for local/small.
66-
# `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM.
67-
# Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather
68-
# than being interpreted by the shell.
69-
CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""]
67+
CMD ["./pii-entrypoint.sh"]

0 commit comments

Comments
 (0)