Skip to content

Commit e567e4f

Browse files
Fix for spawn-mode subprocess metrics directory db file wiping (#417)
1 parent fc2fce6 commit e567e4f

8 files changed

Lines changed: 226 additions & 58 deletions

File tree

.github/workflows/harness-image.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ jobs:
2929
permissions:
3030
contents: read
3131
packages: write
32+
outputs:
33+
image-tag: ${{ steps.vars.outputs.image-tag }}
3234
steps:
3335
- name: Checkout
3436
uses: actions/checkout@v4
@@ -53,6 +55,10 @@ jobs:
5355
BRANCH="${{ github.ref_name }}"
5456
CLEANED_BRANCH_NAME=$(echo "$BRANCH" | tr '/' '-' | tr '[:upper:]' '[:lower:]')
5557
echo "cleaned-branch-name=$CLEANED_BRANCH_NAME" >> "$GITHUB_OUTPUT"
58+
# The tag the downstream deploy should pull. dispatch-deploy only runs
59+
# on workflow_dispatch, so this always matches the branch image pushed
60+
# below (<branch>-latest), never the unrelated release tag.
61+
echo "image-tag=${CLEANED_BRANCH_NAME}-latest" >> "$GITHUB_OUTPUT"
5662
5763
- name: Docker metadata
5864
id: meta
@@ -92,4 +98,4 @@ jobs:
9298
repository: conductor-oss/oss-ci-util
9399
event-type: sdk_release
94100
client-payload: |-
95-
{"tag": "${{ github.event.release.tag_name || 'latest' }}", "repo": "${{ github.repository }}"}
101+
{"tag": "${{ needs.build-and-push.outputs.image-tag }}", "repo": "${{ github.repository }}"}

METRICS.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ or `clean_dead_pids=True` to remove only files from PIDs that no longer exist.
6868
Both default to `False`. Use a dedicated metrics directory per worker process
6969
group.
7070

71+
Cleanup is owned by the parent process and performed by
72+
`MetricsSettings.clean_metrics_directory()`, which is idempotent per process
73+
(the destructive step runs at most once per directory). Spawned workers only
74+
ensure the directory exists (via `create_metrics_collector`) and never delete
75+
`.db` files, so a newly started or restarted worker can never wipe metrics
76+
belonging to live sibling processes.
77+
78+
`TaskHandler.__init__` calls `clean_metrics_directory()` for you before any
79+
worker is spawned, which is sufficient for worker-only apps. If the parent
80+
process *also* collects metrics before constructing the `TaskHandler` (for
81+
example, registering task/workflow definitions or starting workflows through an
82+
instrumented client), build the parent's collector with
83+
`create_metrics_collector_for_parent(metrics_settings)` instead of
84+
`create_metrics_collector`. The parent factory cleans the directory up front --
85+
before the collector's first write -- and then creates the collector. Because
86+
`clean_metrics_directory()` is idempotent, `TaskHandler`'s later call is then a
87+
no-op and cannot orphan the parent's own `.db` files. For a long-lived parent
88+
that shares the directory, prefer `clean_dead_pids=True` over
89+
`clean_directory=True` -- it never deletes a live process's file regardless of
90+
ordering.
91+
7192
## Selecting Canonical Metrics
7293

7394
Set `WORKER_CANONICAL_METRICS` before the worker starts:
@@ -290,7 +311,13 @@ sum(rate(task_execute_time_seconds_count[5m])) by (taskType)
290311
implementations never mixes stale metric names.
291312
- Pass `clean_dead_pids=True` to `MetricsSettings` to remove `.db` files from
292313
PIDs that no longer exist. Use `clean_directory=True` only when you are sure
293-
no other live process shares the same directory.
314+
no other live process shares the same directory. Cleanup is idempotent per
315+
process and runs in the parent (via `clean_metrics_directory()`, which
316+
`TaskHandler.__init__` calls) before workers spawn; workers never wipe the
317+
directory, so restarts and newly spawned workers preserve sibling metrics. If
318+
the parent also collects, build its collector with
319+
`create_metrics_collector_for_parent()` (cleans up front, then creates) so it
320+
doesn't orphan the parent's own files.
294321
- Restart workers after changing `WORKER_CANONICAL_METRICS`.
295322

296323
### High Cardinality
@@ -342,8 +369,15 @@ unreleased metrics harmonization work. For a summary, see the project
342369
across all processes (main, workers, MetricsProvider).
343370
- `MetricsSettings` gains `clean_directory` (default `False`) to wipe all
344371
`.db` files and `clean_dead_pids` (default `False`) to remove only `.db`
345-
files from PIDs that no longer exist. Both are executed by the factory
346-
against the resolved metrics directory.
372+
files from PIDs that no longer exist. Cleanup is applied by
373+
`MetricsSettings.clean_metrics_directory()`, which is idempotent per process
374+
and invoked by the parent (`TaskHandler.__init__` calls it, or a
375+
parent that also collects builds its collector via
376+
`create_metrics_collector_for_parent()`, which cleans up front then creates)
377+
before workers spawn.
378+
`create_metrics_collector` (the per-worker path) is non-destructive and
379+
only ensures the directory exists, so spawned/restarted workers never wipe
380+
live sibling metrics.
347381
- `CONDUCTOR_MP_START_METHOD` env var (`spawn` / `fork` / `forkserver`;
348382
default `fork` on POSIX, `spawn` on Windows) to control the worker pool's
349383
multiprocessing start method (motivated by a `prometheus_client` lock-fork

harness/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
1010
from conductor.client.http.models.task_def import TaskDef
1111
from conductor.client.orkes_clients import OrkesClients
12-
from conductor.client.telemetry.metrics_factory import create_metrics_collector
12+
from conductor.client.telemetry.metrics_factory import create_metrics_collector_for_parent
1313
from conductor.client.workflow.conductor_workflow import ConductorWorkflow
1414
from conductor.client.workflow.task.simple_task import SimpleTask
1515

@@ -77,7 +77,12 @@ def main() -> None:
7777
clean_dead_pids=True,
7878
)
7979

80-
metrics_collector = create_metrics_collector(metrics_settings)
80+
# The parent collects metrics too (task/workflow registration below and the
81+
# WorkflowGovernor), so use the parent factory: it cleans the directory up
82+
# front -- before the collector's first write -- then creates the collector,
83+
# avoiding orphaning the parent's own .db files. clean_metrics_directory() is
84+
# idempotent per process, so TaskHandler's later call is a no-op.
85+
metrics_collector = create_metrics_collector_for_parent(metrics_settings)
8186
print(f"Prometheus metrics server started on port {metrics_port} ({metrics_collector.collector_name()} metrics)")
8287
clients = OrkesClients(configuration, metrics_collector=metrics_collector)
8388

src/conductor/client/automator/task_handler.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ def __init__(
251251
if metrics_settings is not None:
252252
os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.metrics_directory
253253
logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.metrics_directory}")
254+
# Clean the shared metrics directory in the parent, before any
255+
# worker process is spawned. This is a convenience for worker-only
256+
# apps; clean_metrics_directory() is idempotent per process, so if
257+
# the entrypoint already cleaned up front (recommended when the
258+
# parent itself collects metrics), this call is a no-op and will not
259+
# wipe .db files the parent already began writing. Spawned workers
260+
# call create_metrics_collector (non-destructive) and never clean.
261+
metrics_settings.clean_metrics_directory()
254262

255263
# Store event listeners to pass to each worker process
256264
self.event_listeners = event_listeners or []

src/conductor/client/configuration/settings/metrics_settings.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@
1717
CANONICAL_SUBDIR = "canonical"
1818

1919

20+
# Directories already cleaned in THIS process. Cleanup must run at most once
21+
# per directory per process so that a second caller (e.g. TaskHandler after the
22+
# owning entrypoint already cleaned up front) cannot wipe .db files that a live
23+
# process started writing after the first clean. Safe under spawn because only
24+
# the parent ever cleans; spawned workers never call clean_metrics_directory.
25+
_cleaned_metrics_directories: set = set()
26+
27+
28+
def _reset_cleaned_metrics_directories() -> None:
29+
"""Clear the per-process cleanup guard. Intended for tests so each starts
30+
from a known state and does not leak entries via this module-level global."""
31+
_cleaned_metrics_directories.clear()
32+
33+
2034
def _env_bool(name: str, default: bool) -> bool:
2135
value = os.environ.get(name, "")
2236
if not value:
@@ -100,6 +114,39 @@ def metrics_directory(self) -> str:
100114
return os.path.join(self.directory, self._subdir)
101115
return self.directory
102116

117+
def clean_metrics_directory(self) -> None:
118+
"""Prepare the shared metrics directory, at most once per process.
119+
120+
This is the destructive counterpart to metrics collection and must be
121+
invoked only by the process that owns the metrics lifecycle, and as
122+
early as possible -- before that process (or any worker it spawns)
123+
creates a collector and starts writing ``.db`` files. It must never be
124+
called by a spawned worker: a worker cannot know whether sibling
125+
processes are already live and sharing this directory, so it must never
126+
wipe ``.db`` files.
127+
128+
Idempotent per process: the destructive cleanup runs only on the first
129+
call for a given directory. Later calls (e.g. ``TaskHandler`` invoking
130+
this after the entrypoint already cleaned up front) only ensure the
131+
directory exists, so they cannot wipe files a live process began
132+
writing after the first clean.
133+
134+
On the first call, ensures the directory exists and applies the
135+
configured cleanup:
136+
- ``clean_directory``: remove all prometheus_client ``.db`` files.
137+
- ``clean_dead_pids``: remove only ``.db`` files whose owning PID no
138+
longer exists.
139+
Both are no-ops when their respective flag is ``False``.
140+
"""
141+
os.makedirs(self.metrics_directory, exist_ok=True)
142+
if self.metrics_directory in _cleaned_metrics_directories:
143+
return
144+
_cleaned_metrics_directories.add(self.metrics_directory)
145+
if self.clean_directory:
146+
self._clean_stale_db_files()
147+
if self.clean_dead_pids:
148+
self._clean_dead_pid_files()
149+
103150
def __set_dir(self, dir: str) -> None:
104151
if not os.path.isdir(dir):
105152
try:

src/conductor/client/telemetry/metrics_factory.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,6 @@
2222
)
2323

2424

25-
_cleaned_directories: set = set()
26-
27-
28-
def _reset_cleaned_directories() -> None:
29-
"""Clear the per-process cleanup guard. Intended for tests so that each
30-
test starts from a known-clean state and does not inherit or leak entries
31-
via this module-level global."""
32-
_cleaned_directories.clear()
33-
34-
3525
def create_metrics_collector(settings: MetricsSettings) -> MetricsCollectorBase:
3626
"""
3727
Create the metrics collector indicated by ``settings.is_canonical``.
@@ -41,17 +31,17 @@ def create_metrics_collector(settings: MetricsSettings) -> MetricsCollectorBase:
4131
4232
Returns a fully-initialised collector (legacy or canonical) that satisfies
4333
the MetricsCollector Protocol and can be registered as an event listener.
34+
35+
This is non-destructive: it only ensures the directory exists. It never
36+
deletes ``.db`` files, because it runs in every spawned worker and a worker
37+
must not wipe metrics belonging to live sibling processes. Directory
38+
cleanup is owned by the parent via
39+
``MetricsSettings.clean_metrics_directory()`` (invoked once by
40+
``TaskHandler`` before workers spawn).
4441
"""
4542
metrics_dir = settings.metrics_directory
4643
os.makedirs(metrics_dir, exist_ok=True)
4744

48-
if metrics_dir not in _cleaned_directories:
49-
_cleaned_directories.add(metrics_dir)
50-
if settings.clean_directory:
51-
settings._clean_stale_db_files()
52-
if settings.clean_dead_pids:
53-
settings._clean_dead_pid_files()
54-
5545
if settings.is_canonical:
5646
from conductor.client.telemetry.canonical_metrics_collector import CanonicalMetricsCollector
5747
logger.info("WORKER_CANONICAL_METRICS is true — using CanonicalMetricsCollector (dir=%s)", metrics_dir)
@@ -60,3 +50,22 @@ def create_metrics_collector(settings: MetricsSettings) -> MetricsCollectorBase:
6050
from conductor.client.telemetry.legacy_metrics_collector import LegacyMetricsCollector
6151
logger.info("Using LegacyMetricsCollector (dir=%s; set WORKER_CANONICAL_METRICS=true for canonical)", metrics_dir)
6252
return LegacyMetricsCollector(settings)
53+
54+
55+
def create_metrics_collector_for_parent(settings: MetricsSettings) -> MetricsCollectorBase:
56+
"""Owner/parent entrypoint: prepare the shared metrics directory, then build
57+
the collector.
58+
59+
Use this from the process that owns the metrics lifecycle (the one that
60+
spawns workers, and that may itself collect metrics before spawning). It
61+
cleans the directory up front -- honoring ``settings.clean_directory`` /
62+
``settings.clean_dead_pids`` -- so the parent's own first write is not
63+
orphaned, then delegates to ``create_metrics_collector``.
64+
65+
Do NOT call this from a spawned worker: workers must use
66+
``create_metrics_collector`` (non-destructive) so they can never wipe ``.db``
67+
files belonging to live sibling processes. ``clean_metrics_directory()`` is
68+
idempotent per process, so a later ``TaskHandler`` call is a no-op.
69+
"""
70+
settings.clean_metrics_directory()
71+
return create_metrics_collector(settings)

tests/unit/automator/test_task_handler.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import multiprocessing
2+
import tempfile
23
import unittest
34
from unittest.mock import Mock
45
from unittest.mock import patch
56

67
from conductor.client.automator.task_handler import TaskHandler
78
from conductor.client.automator.task_runner import TaskRunner
89
from conductor.client.configuration.configuration import Configuration
10+
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
911
from tests.unit.resources.workers import ClassWorker
1012

1113

@@ -41,6 +43,23 @@ def test_start_processes(self):
4143
isinstance(process, multiprocessing.Process)
4244
)
4345

46+
def test_metrics_directory_cleaned_once_in_parent_init(self):
47+
"""TaskHandler.__init__ (parent, pre-spawn) invokes
48+
clean_metrics_directory exactly once. Spawned workers rely on the
49+
non-destructive create_metrics_collector path and never clean, so this
50+
parent-owned call is the single point where .db files are wiped."""
51+
with tempfile.TemporaryDirectory() as tmp_dir:
52+
metrics_settings = MetricsSettings(directory=tmp_dir, clean_directory=True)
53+
metrics_settings.clean_metrics_directory = Mock()
54+
55+
task_handler = TaskHandler(
56+
configuration=Configuration(),
57+
workers=[ClassWorker('task')],
58+
metrics_settings=metrics_settings,
59+
)
60+
with task_handler:
61+
metrics_settings.clean_metrics_directory.assert_called_once_with()
62+
4463

4564
def _get_valid_task_handler():
4665
return TaskHandler(

0 commit comments

Comments
 (0)