Skip to content

Commit da7dcd0

Browse files
committed
Bugfix: release multi capture memory after analysis
- track collected counts independently from retained samples - build multi results ZIPs from persisted transaction records - release in-memory samples after stop analysis and ZIP export - add best-effort Linux heap trimming after cleanup - add pytest coverage for operation memory release - 2026-03-19 01:31:54
1 parent c67749c commit da7dcd0

9 files changed

Lines changed: 153 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66

77
[project]
88
name = "pypnm-docsis"
9-
version = "1.5.5.0"
9+
version = "1.5.5.1"
1010
description = "DOCSIS 3.x/4.0 Proactive Network Maintenance Toolkit"
1111
readme = "README.md"
1212
requires-python = ">=3.10"

src/pypnm/api/routes/advance/common/abstract/multi_capture_router.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
MultiCaptureOperationIdResponse,
2020
)
2121
from pypnm.api.routes.common.classes.file_capture.capture_group import CaptureGroup
22+
from pypnm.api.routes.common.classes.file_capture.pnm_file_opearation import (
23+
OperationCaptureGroupResolver,
24+
)
2225
from pypnm.config.system_config_settings import SystemConfigSettings
2326
from pypnm.lib.types import GroupId, OperationId
2427

@@ -62,17 +65,26 @@ def _build_results_zip_response(self, operation_id: OperationId, filename_prefix
6265
6366
Missing files are skipped with warnings.
6467
"""
65-
service = self._get_service_or_404(operation_id)
66-
samples = service.results(operation_id)
67-
6868
pnm_dir = str(SystemConfigSettings.pnm_dir())
69-
cm = getattr(service, "cm", None)
70-
mac = getattr(getattr(cm, "get_mac_address", None), "mac_address", "unknown")
69+
resolver = OperationCaptureGroupResolver()
70+
transaction_models = resolver.get_transaction_models_for_operation(operation_id)
71+
72+
mac = "unknown"
73+
if transaction_models:
74+
mac = str(transaction_models[0].mac_address)
75+
else:
76+
op_record = OperationManager.get_operation_record(operation_id)
77+
if isinstance(op_record, dict):
78+
metadata = op_record.get("metadata")
79+
if isinstance(metadata, dict):
80+
md_mac = metadata.get("mac_address")
81+
if isinstance(md_mac, str) and md_mac.strip():
82+
mac = md_mac
7183

7284
buf = io.BytesIO()
7385
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf:
74-
for sample in samples:
75-
filename = getattr(sample, "filename", "")
86+
for model in transaction_models:
87+
filename = str(model.filename)
7688
if not str(filename).strip():
7789
continue
7890

@@ -87,6 +99,7 @@ def _build_results_zip_response(self, operation_id: OperationId, filename_prefix
8799

88100
buf.seek(0)
89101
headers = {"Content-Disposition": f"attachment; filename={filename_prefix}_{mac}_{operation_id}.zip"}
102+
self._release_operation_memory(operation_id)
90103
return StreamingResponse(buf, media_type="application/zip", headers=headers)
91104

92105
def _repair_capture_group_from_service_samples(self, operation_id: OperationId, capture_group_id: GroupId) -> int:
@@ -141,3 +154,15 @@ def _build_operation_id_response(self, operation_name: MultiCaptureOperation) ->
141154
"""Return persisted operation records for a canonical multi-capture operation family."""
142155
operations = OperationManager.list_operation_records_by_name(operation_name.value)
143156
return MultiCaptureOperationIdResponse(status="success", message=None, operations=operations)
157+
158+
def _release_operation_memory(self, operation_id: OperationId) -> None:
159+
"""Release transient in-memory capture state when a service is still registered."""
160+
try:
161+
service = self.getService(operation_id)
162+
except Exception:
163+
return
164+
165+
try:
166+
service.release_operation_memory(operation_id)
167+
except Exception as exc:
168+
self.logger.debug("Operation memory release failed for %s: %s", operation_id, exc)

src/pypnm/api/routes/advance/common/capture_service.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
MessageResponseType,
2626
)
2727
from pypnm.api.routes.common.service.status_codes import ServiceStatusCode
28+
from pypnm.lib.memory import ProcessMemory
2829
from pypnm.lib.types import GroupId, OperationId, TimeStamp
2930
from pypnm.lib.utils import Generate
3031

@@ -129,6 +130,7 @@ async def start(self) -> tuple[GroupId, OperationId]:
129130
"duration": self.duration,
130131
"interval": self.interval,
131132
"time_remaining": self.time_remaining,
133+
"collected": 0,
132134
"samples": [],
133135
"task": None,
134136
}
@@ -162,15 +164,14 @@ async def _runner() -> None:
162164
if isinstance(msg_rsp.payload, list):
163165
samples = self._process_captures(msg_rsp)
164166
for sample in samples:
165-
self._ops[operation_id]["samples"].append(sample)
166-
if sample.transaction_id:
167-
self._cap_group.add_transaction(sample.transaction_id)
167+
self._append_operation_sample(operation_id, sample)
168168
self.logger.debug(f"[{operation_id}] Captured sample txn={sample.transaction_id}")
169169
else:
170170
status_name = msg_rsp.status.name if isinstance(msg_rsp.status, ServiceStatusCode) else str(msg_rsp.status)
171171
err = f"Capture response returned no transaction payload (status={status_name})"
172172
self.logger.warning(f"[{operation_id}] {err}")
173-
self._ops[operation_id]["samples"].append(
173+
self._append_operation_sample(
174+
operation_id,
174175
CaptureSample(
175176
timestamp=cast(TimeStamp, iteration_ts),
176177
transaction_id="",
@@ -182,10 +183,10 @@ async def _runner() -> None:
182183
except Exception as exc:
183184
error_msg = str(exc)
184185
self.logger.error(f"[{operation_id}] Capture error: {error_msg}", exc_info=True)
185-
self._ops[operation_id]["samples"].append(CaptureSample(timestamp = cast(TimeStamp, iteration_ts),
186-
transaction_id = "",
187-
filename = "",
188-
error = error_msg))
186+
self._append_operation_sample(
187+
operation_id,
188+
CaptureSample(timestamp=cast(TimeStamp, iteration_ts), transaction_id="", filename="", error=error_msg),
189+
)
189190

190191
# Complete if still running
191192
if self._ops[operation_id]["state"] == OperationState.RUNNING:
@@ -209,15 +210,14 @@ async def _runner() -> None:
209210
if isinstance(msg_rsp.payload, list):
210211
samples = self._process_captures(msg_rsp)
211212
for sample in samples:
212-
self._ops[operation_id]["samples"].append(sample)
213-
if sample.transaction_id:
214-
self._cap_group.add_transaction(sample.transaction_id)
213+
self._append_operation_sample(operation_id, sample)
215214
self.logger.info(f"[{operation_id}] Captured sample txn={sample.transaction_id}")
216215
else:
217216
status_name = msg_rsp.status.name if isinstance(msg_rsp.status, ServiceStatusCode) else str(msg_rsp.status)
218217
err = f"Final capture response returned no transaction payload (status={status_name})"
219218
self.logger.warning(f"[{operation_id}] {err}")
220-
self._ops[operation_id]["samples"].append(
219+
self._append_operation_sample(
220+
operation_id,
221221
CaptureSample(
222222
timestamp=cast(TimeStamp, iteration_ts),
223223
transaction_id="",
@@ -229,11 +229,10 @@ async def _runner() -> None:
229229
except Exception as exc:
230230
error_msg = str(exc)
231231
self.logger.error(f"[{operation_id}] Capture error: {error_msg}", exc_info=True)
232-
self._ops[operation_id]["samples"].append(
233-
CaptureSample(timestamp = cast(TimeStamp, iteration_ts),
234-
transaction_id = "",
235-
filename = "",
236-
error =error_msg))
232+
self._append_operation_sample(
233+
operation_id,
234+
CaptureSample(timestamp=cast(TimeStamp, iteration_ts), transaction_id="", filename="", error=error_msg),
235+
)
237236
except asyncio.CancelledError:
238237
if operation_id in self._ops and self._ops[operation_id]["state"] == OperationState.RUNNING:
239238
self._ops[operation_id]["state"] = OperationState.CANCELLED
@@ -278,6 +277,20 @@ def getOperationID(self) -> OperationId:
278277
def getOperation(self, operation_id:OperationId) -> dict[str, dict[str, Any]]:
279278
return self._ops[operation_id]
280279

280+
def _append_operation_sample(self, operation_id: OperationId, sample: CaptureSample) -> None:
281+
"""
282+
Append a capture sample and update operation-level counters.
283+
284+
Args:
285+
operation_id: Operation receiving the sample.
286+
sample: Parsed capture sample to retain.
287+
"""
288+
op = self._ops[operation_id]
289+
op["samples"].append(sample)
290+
op["collected"] = int(op.get("collected", 0)) + 1
291+
if sample.transaction_id:
292+
self._cap_group.add_transaction(sample.transaction_id)
293+
281294
def getOperationState(self,operation_id:OperationId) -> OperationState:
282295
return self._ops[operation_id]["state"]
283296

@@ -314,7 +327,7 @@ def status(self, operation_id: OperationId) -> dict[str, Any]:
314327

315328
return {
316329
"state": op["state"],
317-
"collected": len(op["samples"]),
330+
"collected": int(op.get("collected", 0)),
318331
"time_remaining": op.get("time_remaining", 0)
319332
}
320333

@@ -331,6 +344,26 @@ def results(self, operation_id: OperationId) -> list[CaptureSample]:
331344
op = self._ops.get(operation_id)
332345
return op["samples"] if op else []
333346

347+
def release_operation_memory(self, operation_id: OperationId) -> None:
348+
"""
349+
Drop retained in-memory samples and task references for an operation.
350+
351+
Args:
352+
operation_id: Operation whose transient in-memory state should be released.
353+
"""
354+
op = self._ops.get(operation_id)
355+
if not op:
356+
return
357+
358+
released_samples = len(op["samples"])
359+
op["samples"] = []
360+
op["task"] = None
361+
362+
if released_samples > 0:
363+
self.logger.info("[%s] Released %d retained samples from memory", operation_id, released_samples)
364+
365+
ProcessMemory.release_unused_memory()
366+
334367
def stop(self, operation_id: OperationId) -> None:
335368
"""
336369
Signal the background runner to stop after the current iteration.

src/pypnm/api/routes/advance/multi_ds_chan_est/router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def stop_capture(operationId: OperationId) -> MultiChanEstStatusResponse:
182182

183183
service.stop(operation_id)
184184
status = service.status(operation_id)
185+
self._release_operation_memory(operation_id)
185186
return MultiChanEstStatusResponse(
186187
mac_address = service.cm.get_mac_address.mac_address,
187188
system_description = service.get_system_description(),
@@ -281,6 +282,7 @@ def analysis(request: MultiChanEstAnalysisRequest) -> MultiChanEstimationAnalysi
281282
"system_description": analysis_result.system_description,
282283
}
283284

285+
self._release_operation_memory(request.operation_id)
284286
return MultiChanEstimationAnalysisResponse(
285287
mac_address = mac,
286288
status = status,
@@ -292,6 +294,7 @@ def analysis(request: MultiChanEstAnalysisRequest) -> MultiChanEstimationAnalysi
292294
try:
293295
rpt = engine.build_report()
294296
self.logger.info(f"[analysis] Built archive report for group {capture_group_id}")
297+
self._release_operation_memory(request.operation_id)
295298
return PnmFileService().get_file(FileType.ARCHIVE, rpt.name)
296299

297300
except Exception as e:

src/pypnm/api/routes/advance/multi_rxmer/router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def stop_capture(operationId: OperationId) -> MultiRxMerStatusResponse:
334334

335335
service.stop(operation_id)
336336
status = service.status(operation_id)
337+
self._release_operation_memory(operation_id)
337338

338339
return MultiRxMerStatusResponse(
339340
mac_address=service.cm.get_mac_address.mac_address,
@@ -512,6 +513,7 @@ def analysis(request: MultiRxMerAnalysisRequest) -> MultiRxMerAnalysisResponse |
512513
"mac_address": mac_address,
513514
"system_description": response_system_description,
514515
}
516+
self._release_operation_memory(request.operation_id)
515517
return MultiRxMerAnalysisResponse(
516518
mac_address = mac_address,
517519
status = status_code,
@@ -521,6 +523,7 @@ def analysis(request: MultiRxMerAnalysisRequest) -> MultiRxMerAnalysisResponse |
521523

522524
elif output_type == OutputType.ARCHIVE:
523525
rpt = engine.build_report()
526+
self._release_operation_memory(request.operation_id)
524527
return PnmFileService().get_file(FileType.ARCHIVE, rpt.name)
525528

526529
else:

src/pypnm/api/routes/advance/multi_us_ofdma_pre_eq/router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def stop_capture(operationId: OperationId) -> MultiUsOfdmaPreEqStatusResponse:
182182

183183
service.stop(operation_id)
184184
status = service.status(operation_id)
185+
self._release_operation_memory(operation_id)
185186
return MultiUsOfdmaPreEqStatusResponse(
186187
mac_address = service.cm.get_mac_address.mac_address,
187188
system_description = service.get_system_description(),
@@ -279,6 +280,7 @@ def analysis(request: MultiUsOfdmaPreEqAnalysisRequest) -> MultiUsOfdmaPreEqAnal
279280
"system_description": analysis_result.system_description,
280281
}
281282

283+
self._release_operation_memory(request.operation_id)
282284
return MultiUsOfdmaPreEqAnalysisResponse(
283285
mac_address = mac,
284286
status = status,
@@ -290,6 +292,7 @@ def analysis(request: MultiUsOfdmaPreEqAnalysisRequest) -> MultiUsOfdmaPreEqAnal
290292
try:
291293
rpt = engine.build_report()
292294
self.logger.info(f"[analysis] Built archive report for group {capture_group_id}")
295+
self._release_operation_memory(request.operation_id)
293296
return PnmFileService().get_file(FileType.ARCHIVE, rpt.name)
294297

295298
except Exception as e:

src/pypnm/lib/memory.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026
3+
4+
from __future__ import annotations
5+
6+
import ctypes
7+
import gc
8+
import logging
9+
10+
11+
class ProcessMemory:
12+
"""Helpers for best-effort process memory reclamation on long-running services."""
13+
14+
@staticmethod
15+
def release_unused_memory() -> None:
16+
"""
17+
Trigger Python garbage collection and best-effort libc heap trimming.
18+
19+
This is primarily useful after large analysis/capture objects have been
20+
released and the service wants to return unused heap pages to the OS on
21+
Linux/glibc systems. Failures are intentionally ignored.
22+
"""
23+
gc.collect()
24+
25+
try:
26+
libc = ctypes.CDLL("libc.so.6")
27+
malloc_trim = getattr(libc, "malloc_trim", None)
28+
if callable(malloc_trim):
29+
malloc_trim(0)
30+
except Exception as exc:
31+
logging.getLogger(ProcessMemory.__name__).debug(
32+
"malloc_trim not available or failed: %s",
33+
exc,
34+
)

src/pypnm/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
__all__ = ["__version__"]
77

88
# MAJOR.MINOR.MAINTENANCE.BUILD
9-
__version__: str = "1.5.5.0"
9+
__version__: str = "1.5.5.1"

tests/test_capture_service_transaction_guard.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
MultiCaptureOperation,
1414
MultiCaptureOperationModel,
1515
)
16+
from pypnm.api.routes.common.classes.file_capture.capture_sample import CaptureSample
1617
from pypnm.api.routes.common.extended.common_messaging_service import (
1718
MessageResponse,
1819
MessageResponseType,
@@ -176,3 +177,27 @@ async def test_capture_service_stop_cancels_background_task(monkeypatch: pytest.
176177

177178
assert service._ops[operation_id]["state"] == capture_service.OperationState.STOPPED
178179
assert task.cancelled() is True or task.done() is True
180+
181+
182+
def test_release_operation_memory_keeps_collected_count(monkeypatch: pytest.MonkeyPatch) -> None:
183+
service = _FakeCaptureService(duration=0, interval=0)
184+
service._ops["op-1"] = {
185+
"state": capture_service.OperationState.COMPLETED,
186+
"collected": 2,
187+
"samples": [
188+
CaptureSample(timestamp=1, transaction_id="tx1", filename="a.bin", error=None),
189+
CaptureSample(timestamp=2, transaction_id="tx2", filename="b.bin", error=None),
190+
],
191+
"task": object(),
192+
"time_remaining": 0,
193+
}
194+
195+
released_calls: list[str] = []
196+
monkeypatch.setattr(capture_service.ProcessMemory, "release_unused_memory", lambda: released_calls.append("released"))
197+
198+
service.release_operation_memory(OperationId("op-1"))
199+
200+
assert service._ops["op-1"]["samples"] == []
201+
assert service._ops["op-1"]["task"] is None
202+
assert service.status(OperationId("op-1"))["collected"] == 2
203+
assert released_calls == ["released"]

0 commit comments

Comments
 (0)