pam-python 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pam_python-0.2.2 → pam_python-0.2.4}/PKG-INFO +16 -13
- {pam_python-0.2.2 → pam_python-0.2.4}/README.md +15 -12
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/__init__.py +1 -1
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/cli.py +1 -1
- pam_python-0.2.4/pam/result_batch_uploader.py +412 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/task_manager.py +18 -19
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/AGENT.md +89 -22
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/pandas/service.test.tmpl +4 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/pandas/service_class.tmpl +18 -5
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/polars/service.test.tmpl +6 -2
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/polars/service_class.tmpl +25 -6
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/tester_task.py +2 -4
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/PKG-INFO +16 -13
- {pam_python-0.2.2 → pam_python-0.2.4}/pyproject.toml +1 -1
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_cli_templates.py +17 -2
- pam_python-0.2.4/tests/test_result_batch_uploader.py +265 -0
- pam_python-0.2.2/pam/result_batch_uploader.py +0 -227
- pam_python-0.2.2/tests/test_result_batch_uploader.py +0 -137
- {pam_python-0.2.2 → pam_python-0.2.4}/LICENSE.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/api.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/interface_task_manager.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/logger.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/models/__init__.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/models/request_command.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/models/sqlite_download.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/__init__.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/area.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/axis.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/bar.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/box_plot.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/bubble.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/candlestick.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/collection.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/definition.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/donut.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/errors.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/fields.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/heatmap.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/line.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/manager.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/metric.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/pie.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/polar_area.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/radar.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/radial_bar.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/range_area.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/range_bar.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/range_transform.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/record.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/scatter.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/schema.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/store.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/table.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/transport.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/reports/treemap.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/request_file_format.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/server.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/service.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/sqlite.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/temp_file_utils.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/buildcmd/pamb +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/buildcmd/pamb-base.sh +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/REPORTS.md +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/dockerignore.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/gitignore.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/main.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/pylintrc.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/run_unit_test.bat +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/run_unit_test.ps1 +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/init/run_unit_test.sh +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/project/pip/Dockerfile +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/project/pip/requirements.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/project/uv/Dockerfile +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/project/uv/pyproject.toml +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/project/uv/python-version +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/common/service.yaml +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/pandas/functions.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/templates/service/polars/functions.tmpl +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam/utils.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/SOURCES.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/dependency_links.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/entry_points.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/requires.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/pam_python.egg-info/top_level.txt +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/setup.cfg +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_range_transform.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_report_store.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_service_dataframe_output.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_sqlite_download_v2.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_area_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_bar_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_box_plot_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_bubble_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_candlestick_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_donut_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_heatmap_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_line_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_metric_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_pie_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_polar_area_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_radar_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_radial_bar_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_range_area_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_range_bar_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_scatter_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_table_report.py +0 -0
- {pam_python-0.2.2 → pam_python-0.2.4}/tests/test_typed_treemap_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pam-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Pam Python Library
|
|
5
5
|
Author-email: Narongrit Kanhanoi <narongrit@pams.ai>
|
|
6
6
|
Project-URL: Homepage, https://github.com/heart/pam-python
|
|
@@ -122,13 +122,13 @@ The runtime calls your service in two main phases.
|
|
|
122
122
|
2. `on_data_input`
|
|
123
123
|
|
|
124
124
|
- Called when CDP sends input files
|
|
125
|
-
- `req.input_files`
|
|
125
|
+
- `req.input_files` is the ordered positional data-source contract configured in PAM
|
|
126
126
|
- `req.file_format` is the typed format sent by PAM
|
|
127
127
|
- Should also return quickly (use a thread if needed)
|
|
128
128
|
|
|
129
129
|
When your service is done:
|
|
130
130
|
|
|
131
|
-
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
131
|
+
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
132
132
|
- Publish managed reports through `self.reports`; do not build report JSON manually
|
|
133
133
|
- Call `self._exit()` to signal completion
|
|
134
134
|
|
|
@@ -151,11 +151,11 @@ Notes:
|
|
|
151
151
|
---
|
|
152
152
|
|
|
153
153
|
**Uploading Results in Batches**
|
|
154
|
-
Polars
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
Polars `LazyFrame`/`DataFrame` and Pandas `DataFrame` results all pass through
|
|
155
|
+
`ResultBatchUploader`. It streams lazy results in bounded chunks, buffers incomplete
|
|
156
|
+
batches across calls, writes complete batches as CSV, and uploads them through PAM.
|
|
157
|
+
The framework does not prescribe page-local/global computation or intermediate
|
|
158
|
+
storage; submit rows when your business result is ready.
|
|
159
159
|
|
|
160
160
|
Recommended usage:
|
|
161
161
|
|
|
@@ -165,6 +165,7 @@ from pam.result_batch_uploader import ResultBatchUploader
|
|
|
165
165
|
batch_uploader = ResultBatchUploader(self, batch_size=50000)
|
|
166
166
|
batch_uploader.upload(df, name="main")
|
|
167
167
|
batch_uploader.flush()
|
|
168
|
+
batch_uploader.wait_for_uploader()
|
|
168
169
|
status = batch_uploader.get_status()
|
|
169
170
|
```
|
|
170
171
|
|
|
@@ -173,9 +174,12 @@ Notes:
|
|
|
173
174
|
- `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
|
|
174
175
|
- `name` separates result streams and enforces one stable column schema per stream.
|
|
175
176
|
- `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
|
|
176
|
-
-
|
|
177
|
-
- `
|
|
178
|
-
- `
|
|
177
|
+
- Uploads run in a bounded background queue so processing can continue while PAM receives CSV batches.
|
|
178
|
+
- `flush()` closes input and queues the final remainder; it does not wait for network completion.
|
|
179
|
+
- Always call `wait_for_uploader()` after `flush()` and before `_exit()`.
|
|
180
|
+
- Failed uploads retry with exponential backoff, then log/record the failed batch and continue.
|
|
181
|
+
- `get_status()` includes buffered, uploaded, failed, and retried counts per stream.
|
|
182
|
+
- Polars `LazyFrame` is supported without collecting the complete result at once.
|
|
179
183
|
|
|
180
184
|
---
|
|
181
185
|
|
|
@@ -304,6 +308,5 @@ After `pam init` and one service:
|
|
|
304
308
|
- Implement your logic in `functions.py`.
|
|
305
309
|
- Wire it into `on_start` and `on_data_input` in your service class.
|
|
306
310
|
- Use the temp utilities to write intermediate files.
|
|
307
|
-
- Keep Polars transformations lazy and
|
|
308
|
-
- Use `ResultBatchUploader` only for eager Polars/Pandas frames that need row batching.
|
|
311
|
+
- Keep Polars transformations lazy and submit final results through `ResultBatchUploader`.
|
|
309
312
|
- Read `REPORTS.md` before implementing a managed report.
|
|
@@ -102,13 +102,13 @@ The runtime calls your service in two main phases.
|
|
|
102
102
|
2. `on_data_input`
|
|
103
103
|
|
|
104
104
|
- Called when CDP sends input files
|
|
105
|
-
- `req.input_files`
|
|
105
|
+
- `req.input_files` is the ordered positional data-source contract configured in PAM
|
|
106
106
|
- `req.file_format` is the typed format sent by PAM
|
|
107
107
|
- Should also return quickly (use a thread if needed)
|
|
108
108
|
|
|
109
109
|
When your service is done:
|
|
110
110
|
|
|
111
|
-
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
111
|
+
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
112
112
|
- Publish managed reports through `self.reports`; do not build report JSON manually
|
|
113
113
|
- Call `self._exit()` to signal completion
|
|
114
114
|
|
|
@@ -131,11 +131,11 @@ Notes:
|
|
|
131
131
|
---
|
|
132
132
|
|
|
133
133
|
**Uploading Results in Batches**
|
|
134
|
-
Polars
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
Polars `LazyFrame`/`DataFrame` and Pandas `DataFrame` results all pass through
|
|
135
|
+
`ResultBatchUploader`. It streams lazy results in bounded chunks, buffers incomplete
|
|
136
|
+
batches across calls, writes complete batches as CSV, and uploads them through PAM.
|
|
137
|
+
The framework does not prescribe page-local/global computation or intermediate
|
|
138
|
+
storage; submit rows when your business result is ready.
|
|
139
139
|
|
|
140
140
|
Recommended usage:
|
|
141
141
|
|
|
@@ -145,6 +145,7 @@ from pam.result_batch_uploader import ResultBatchUploader
|
|
|
145
145
|
batch_uploader = ResultBatchUploader(self, batch_size=50000)
|
|
146
146
|
batch_uploader.upload(df, name="main")
|
|
147
147
|
batch_uploader.flush()
|
|
148
|
+
batch_uploader.wait_for_uploader()
|
|
148
149
|
status = batch_uploader.get_status()
|
|
149
150
|
```
|
|
150
151
|
|
|
@@ -153,9 +154,12 @@ Notes:
|
|
|
153
154
|
- `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
|
|
154
155
|
- `name` separates result streams and enforces one stable column schema per stream.
|
|
155
156
|
- `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
|
|
156
|
-
-
|
|
157
|
-
- `
|
|
158
|
-
- `
|
|
157
|
+
- Uploads run in a bounded background queue so processing can continue while PAM receives CSV batches.
|
|
158
|
+
- `flush()` closes input and queues the final remainder; it does not wait for network completion.
|
|
159
|
+
- Always call `wait_for_uploader()` after `flush()` and before `_exit()`.
|
|
160
|
+
- Failed uploads retry with exponential backoff, then log/record the failed batch and continue.
|
|
161
|
+
- `get_status()` includes buffered, uploaded, failed, and retried counts per stream.
|
|
162
|
+
- Polars `LazyFrame` is supported without collecting the complete result at once.
|
|
159
163
|
|
|
160
164
|
---
|
|
161
165
|
|
|
@@ -284,6 +288,5 @@ After `pam init` and one service:
|
|
|
284
288
|
- Implement your logic in `functions.py`.
|
|
285
289
|
- Wire it into `on_start` and `on_data_input` in your service class.
|
|
286
290
|
- Use the temp utilities to write intermediate files.
|
|
287
|
-
- Keep Polars transformations lazy and
|
|
288
|
-
- Use `ResultBatchUploader` only for eager Polars/Pandas frames that need row batching.
|
|
291
|
+
- Keep Polars transformations lazy and submit final results through `ResultBatchUploader`.
|
|
289
292
|
- Read `REPORTS.md` before implementing a managed report.
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Asynchronous, bounded result batching for PAM CSV uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from pam.utils import log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_BATCH_SIZE = 50000
|
|
15
|
+
DEFAULT_QUEUE_SIZE = 4
|
|
16
|
+
DEFAULT_MAX_RETRIES = 3
|
|
17
|
+
DEFAULT_RETRY_DELAY_SECONDS = 2.0
|
|
18
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS = 30.0
|
|
19
|
+
_FLUSH = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _ResultInput:
|
|
24
|
+
dataframe: Any
|
|
25
|
+
name: str
|
|
26
|
+
options: Optional[dict]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _ResultStream:
|
|
31
|
+
engine: str
|
|
32
|
+
columns: Tuple[object, ...]
|
|
33
|
+
options: Optional[dict]
|
|
34
|
+
buffer: Any
|
|
35
|
+
uploaded_rows: int = 0
|
|
36
|
+
uploaded_batches: int = 0
|
|
37
|
+
failed_rows: int = 0
|
|
38
|
+
failed_batches: int = 0
|
|
39
|
+
retried_uploads: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResultBatchUploader:
|
|
43
|
+
"""Queue result frames and upload bounded CSV batches in one worker.
|
|
44
|
+
|
|
45
|
+
Plugin computation and intermediate storage are outside this class. Calls to
|
|
46
|
+
``upload`` submit final result frames without waiting for network I/O. A
|
|
47
|
+
bounded queue applies backpressure when producers outrun the uploader.
|
|
48
|
+
|
|
49
|
+
``flush`` closes input and places a FIFO completion marker. The worker first
|
|
50
|
+
consumes every earlier result, uploads all complete batches, then uploads all
|
|
51
|
+
remaining rows. ``wait_for_uploader`` is the completion barrier that must
|
|
52
|
+
return before a service exits.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, service, batch_size: int = DEFAULT_BATCH_SIZE):
|
|
56
|
+
self._service = service
|
|
57
|
+
self.batch_size = self._resolve_positive_int("batch_size", batch_size)
|
|
58
|
+
self.queue_size = self._resolve_positive_int(
|
|
59
|
+
"upload_queue_size", DEFAULT_QUEUE_SIZE
|
|
60
|
+
)
|
|
61
|
+
self.max_retries = self._resolve_non_negative_int(
|
|
62
|
+
"upload_max_retries", DEFAULT_MAX_RETRIES
|
|
63
|
+
)
|
|
64
|
+
self.retry_delay_seconds = self._resolve_non_negative_float(
|
|
65
|
+
"upload_retry_delay_seconds", DEFAULT_RETRY_DELAY_SECONDS
|
|
66
|
+
)
|
|
67
|
+
self.retry_max_delay_seconds = self._resolve_non_negative_float(
|
|
68
|
+
"upload_retry_max_delay_seconds", DEFAULT_RETRY_MAX_DELAY_SECONDS
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._streams: Dict[str, _ResultStream] = {}
|
|
72
|
+
self._queue: queue.Queue = queue.Queue(maxsize=self.queue_size)
|
|
73
|
+
self._submission_lock = threading.Lock()
|
|
74
|
+
self._state_lock = threading.RLock()
|
|
75
|
+
self._completion = threading.Event()
|
|
76
|
+
self._accepting = True
|
|
77
|
+
self._flush_requested = False
|
|
78
|
+
self._fatal_error: Optional[BaseException] = None
|
|
79
|
+
self._state = "open"
|
|
80
|
+
|
|
81
|
+
self._worker = threading.Thread(
|
|
82
|
+
target=self._run_worker,
|
|
83
|
+
name=f"{self.__class__.__name__}-worker",
|
|
84
|
+
daemon=True,
|
|
85
|
+
)
|
|
86
|
+
self._worker.start()
|
|
87
|
+
|
|
88
|
+
def upload(
|
|
89
|
+
self,
|
|
90
|
+
dataframe: Any,
|
|
91
|
+
name: str = "default",
|
|
92
|
+
options: Optional[dict] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Submit a final result without waiting for CSV/network upload."""
|
|
95
|
+
stream_name = self._normalize_name(name)
|
|
96
|
+
normalized_options = self._normalize_options(options)
|
|
97
|
+
submitted = self._clone_submitted_dataframe(dataframe)
|
|
98
|
+
|
|
99
|
+
with self._submission_lock:
|
|
100
|
+
with self._state_lock:
|
|
101
|
+
if not self._accepting:
|
|
102
|
+
raise RuntimeError("ResultBatchUploader no longer accepts results")
|
|
103
|
+
if self._fatal_error is not None:
|
|
104
|
+
raise RuntimeError("ResultBatchUploader worker failed") from self._fatal_error
|
|
105
|
+
self._queue.put(_ResultInput(submitted, stream_name, normalized_options))
|
|
106
|
+
|
|
107
|
+
def flush(self) -> None:
|
|
108
|
+
"""Close input and enqueue one terminal flush marker without blocking."""
|
|
109
|
+
with self._submission_lock:
|
|
110
|
+
with self._state_lock:
|
|
111
|
+
if self._flush_requested:
|
|
112
|
+
return
|
|
113
|
+
self._accepting = False
|
|
114
|
+
self._flush_requested = True
|
|
115
|
+
self._state = "flush_requested"
|
|
116
|
+
self._queue.put(_FLUSH)
|
|
117
|
+
|
|
118
|
+
def wait_for_uploader(self) -> None:
|
|
119
|
+
"""Wait for all queued/retried uploads and the final remainder."""
|
|
120
|
+
with self._state_lock:
|
|
121
|
+
if not self._flush_requested:
|
|
122
|
+
raise RuntimeError("flush() must be called before wait_for_uploader()")
|
|
123
|
+
self._completion.wait()
|
|
124
|
+
with self._state_lock:
|
|
125
|
+
if self._fatal_error is not None:
|
|
126
|
+
raise RuntimeError("ResultBatchUploader worker failed") from self._fatal_error
|
|
127
|
+
|
|
128
|
+
def get_status(self) -> Dict[str, dict]:
|
|
129
|
+
with self._state_lock:
|
|
130
|
+
return {
|
|
131
|
+
name: {
|
|
132
|
+
"buffered_rows": len(stream.buffer),
|
|
133
|
+
"uploaded_rows": stream.uploaded_rows,
|
|
134
|
+
"uploaded_batches": stream.uploaded_batches,
|
|
135
|
+
"failed_rows": stream.failed_rows,
|
|
136
|
+
"failed_batches": stream.failed_batches,
|
|
137
|
+
"retried_uploads": stream.retried_uploads,
|
|
138
|
+
}
|
|
139
|
+
for name, stream in self._streams.items()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def state(self) -> str:
|
|
144
|
+
with self._state_lock:
|
|
145
|
+
return self._state
|
|
146
|
+
|
|
147
|
+
def _run_worker(self) -> None:
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
item = self._queue.get()
|
|
151
|
+
try:
|
|
152
|
+
if item is _FLUSH:
|
|
153
|
+
if self._fatal_error is None:
|
|
154
|
+
self._flush_all_streams()
|
|
155
|
+
with self._state_lock:
|
|
156
|
+
self._state = (
|
|
157
|
+
"failed" if self._fatal_error is not None else "completed"
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
if self._fatal_error is None:
|
|
161
|
+
self._consume_input(item)
|
|
162
|
+
except BaseException as exc: # keep worker alive until the flush barrier
|
|
163
|
+
self._record_fatal_error(exc)
|
|
164
|
+
finally:
|
|
165
|
+
self._queue.task_done()
|
|
166
|
+
finally:
|
|
167
|
+
with self._state_lock:
|
|
168
|
+
self._accepting = False
|
|
169
|
+
if self._state not in {"completed", "failed"}:
|
|
170
|
+
self._state = "failed"
|
|
171
|
+
self._completion.set()
|
|
172
|
+
|
|
173
|
+
def _consume_input(self, item: _ResultInput) -> None:
|
|
174
|
+
dataframe = item.dataframe
|
|
175
|
+
if self._is_polars_lazyframe(dataframe):
|
|
176
|
+
for batch in dataframe.collect_batches(
|
|
177
|
+
chunk_size=self.batch_size,
|
|
178
|
+
maintain_order=True,
|
|
179
|
+
engine="streaming",
|
|
180
|
+
):
|
|
181
|
+
self._consume_eager(batch, item.name, item.options)
|
|
182
|
+
return
|
|
183
|
+
self._consume_eager(dataframe, item.name, item.options)
|
|
184
|
+
|
|
185
|
+
def _consume_eager(
|
|
186
|
+
self,
|
|
187
|
+
dataframe: Any,
|
|
188
|
+
name: str,
|
|
189
|
+
options: Optional[dict],
|
|
190
|
+
) -> None:
|
|
191
|
+
engine, normalized = self._normalize_eager_dataframe(dataframe)
|
|
192
|
+
batches = []
|
|
193
|
+
with self._state_lock:
|
|
194
|
+
stream = self._get_or_create_stream(name, engine, normalized, options)
|
|
195
|
+
if len(normalized) == 0:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
remaining = normalized
|
|
199
|
+
if len(stream.buffer) > 0:
|
|
200
|
+
rows_needed = self.batch_size - len(stream.buffer)
|
|
201
|
+
stream.buffer = self._concat(
|
|
202
|
+
engine,
|
|
203
|
+
stream.buffer,
|
|
204
|
+
self._slice(engine, remaining, 0, rows_needed),
|
|
205
|
+
)
|
|
206
|
+
remaining = self._slice(
|
|
207
|
+
engine,
|
|
208
|
+
remaining,
|
|
209
|
+
rows_needed,
|
|
210
|
+
len(remaining) - rows_needed,
|
|
211
|
+
)
|
|
212
|
+
if len(stream.buffer) == self.batch_size:
|
|
213
|
+
batches.append(self._clone(engine, stream.buffer))
|
|
214
|
+
stream.buffer = self._empty_frame(engine, stream.buffer)
|
|
215
|
+
|
|
216
|
+
while len(remaining) >= self.batch_size:
|
|
217
|
+
batches.append(self._slice(engine, remaining, 0, self.batch_size))
|
|
218
|
+
remaining = self._slice(
|
|
219
|
+
engine,
|
|
220
|
+
remaining,
|
|
221
|
+
self.batch_size,
|
|
222
|
+
len(remaining) - self.batch_size,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if len(remaining) > 0:
|
|
226
|
+
stream.buffer = self._clone(engine, remaining)
|
|
227
|
+
|
|
228
|
+
for batch in batches:
|
|
229
|
+
self._upload_batch(name, stream, batch)
|
|
230
|
+
|
|
231
|
+
def _flush_all_streams(self) -> None:
|
|
232
|
+
pending = []
|
|
233
|
+
with self._state_lock:
|
|
234
|
+
for name, stream in self._streams.items():
|
|
235
|
+
if len(stream.buffer) == 0:
|
|
236
|
+
continue
|
|
237
|
+
pending.append((name, stream, self._clone(stream.engine, stream.buffer)))
|
|
238
|
+
stream.buffer = self._empty_frame(stream.engine, stream.buffer)
|
|
239
|
+
for name, stream, batch in pending:
|
|
240
|
+
self._upload_batch(name, stream, batch)
|
|
241
|
+
|
|
242
|
+
def _upload_batch(self, name: str, stream: _ResultStream, dataframe: Any) -> None:
|
|
243
|
+
attempts = self.max_retries + 1
|
|
244
|
+
for attempt in range(1, attempts + 1):
|
|
245
|
+
try:
|
|
246
|
+
self._service._upload_result(
|
|
247
|
+
self._clone(stream.engine, dataframe),
|
|
248
|
+
stream.options,
|
|
249
|
+
)
|
|
250
|
+
with self._state_lock:
|
|
251
|
+
stream.uploaded_rows += len(dataframe)
|
|
252
|
+
stream.uploaded_batches += 1
|
|
253
|
+
return
|
|
254
|
+
except Exception as exc: # network/upload failures are retryable
|
|
255
|
+
if attempt < attempts:
|
|
256
|
+
with self._state_lock:
|
|
257
|
+
stream.retried_uploads += 1
|
|
258
|
+
delay = min(
|
|
259
|
+
self.retry_delay_seconds * (2 ** (attempt - 1)),
|
|
260
|
+
self.retry_max_delay_seconds,
|
|
261
|
+
)
|
|
262
|
+
log(
|
|
263
|
+
f"Result upload retry stream={name} rows={len(dataframe)} "
|
|
264
|
+
f"attempt={attempt + 1}/{attempts} delay={delay}s error={exc}",
|
|
265
|
+
level="WARNING",
|
|
266
|
+
)
|
|
267
|
+
if delay > 0:
|
|
268
|
+
time.sleep(delay)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
with self._state_lock:
|
|
272
|
+
stream.failed_rows += len(dataframe)
|
|
273
|
+
stream.failed_batches += 1
|
|
274
|
+
log(
|
|
275
|
+
f"Result upload skipped after retries stream={name} "
|
|
276
|
+
f"rows={len(dataframe)} attempts={attempts} error={exc}",
|
|
277
|
+
level="ERROR",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _record_fatal_error(self, exc: BaseException) -> None:
|
|
281
|
+
with self._state_lock:
|
|
282
|
+
if self._fatal_error is None:
|
|
283
|
+
self._fatal_error = exc
|
|
284
|
+
self._accepting = False
|
|
285
|
+
log(f"Result uploader worker failed: {exc}", level="ERROR")
|
|
286
|
+
|
|
287
|
+
def _get_or_create_stream(
|
|
288
|
+
self,
|
|
289
|
+
name: str,
|
|
290
|
+
engine: str,
|
|
291
|
+
dataframe: Any,
|
|
292
|
+
options: Optional[dict],
|
|
293
|
+
) -> _ResultStream:
|
|
294
|
+
columns = tuple(dataframe.columns)
|
|
295
|
+
stream = self._streams.get(name)
|
|
296
|
+
if stream is None:
|
|
297
|
+
stream = _ResultStream(
|
|
298
|
+
engine=engine,
|
|
299
|
+
columns=columns,
|
|
300
|
+
options=options,
|
|
301
|
+
buffer=self._empty_frame(engine, dataframe),
|
|
302
|
+
)
|
|
303
|
+
self._streams[name] = stream
|
|
304
|
+
return stream
|
|
305
|
+
if stream.engine != engine:
|
|
306
|
+
raise ValueError(f"Result stream '{name}' dataframe engine changed")
|
|
307
|
+
if stream.columns != columns:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"Result stream '{name}' columns changed from "
|
|
310
|
+
f"{list(stream.columns)!r} to {list(columns)!r}"
|
|
311
|
+
)
|
|
312
|
+
if stream.options != options:
|
|
313
|
+
raise ValueError(f"Result stream '{name}' upload options changed")
|
|
314
|
+
return stream
|
|
315
|
+
|
|
316
|
+
def _runtime_parameters(self) -> dict:
|
|
317
|
+
return getattr(self._service.request, "runtime_parameters", {})
|
|
318
|
+
|
|
319
|
+
def _resolve_positive_int(self, key: str, fallback: int) -> int:
|
|
320
|
+
try:
|
|
321
|
+
value = int(self._runtime_parameters().get(key, fallback))
|
|
322
|
+
except (TypeError, ValueError):
|
|
323
|
+
return fallback
|
|
324
|
+
return value if value > 0 else fallback
|
|
325
|
+
|
|
326
|
+
def _resolve_non_negative_int(self, key: str, fallback: int) -> int:
|
|
327
|
+
try:
|
|
328
|
+
value = int(self._runtime_parameters().get(key, fallback))
|
|
329
|
+
except (TypeError, ValueError):
|
|
330
|
+
return fallback
|
|
331
|
+
return value if value >= 0 else fallback
|
|
332
|
+
|
|
333
|
+
def _resolve_non_negative_float(self, key: str, fallback: float) -> float:
|
|
334
|
+
try:
|
|
335
|
+
value = float(self._runtime_parameters().get(key, fallback))
|
|
336
|
+
except (TypeError, ValueError):
|
|
337
|
+
return fallback
|
|
338
|
+
return value if value >= 0 else fallback
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def _clone_submitted_dataframe(cls, dataframe: Any):
|
|
342
|
+
if cls._is_polars_lazyframe(dataframe):
|
|
343
|
+
return dataframe.clone()
|
|
344
|
+
_, normalized = cls._normalize_eager_dataframe(dataframe)
|
|
345
|
+
return normalized
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _normalize_eager_dataframe(dataframe: Any) -> tuple[str, Any]:
|
|
349
|
+
module_name = type(dataframe).__module__.split(".", 1)[0]
|
|
350
|
+
class_name = type(dataframe).__name__
|
|
351
|
+
if module_name == "polars" and class_name == "DataFrame":
|
|
352
|
+
return "polars", dataframe.clone()
|
|
353
|
+
if module_name == "pandas" and class_name == "DataFrame":
|
|
354
|
+
return "pandas", dataframe.reset_index(drop=True).copy()
|
|
355
|
+
raise TypeError(
|
|
356
|
+
"dataframe must be a Polars LazyFrame, Polars DataFrame, "
|
|
357
|
+
"or Pandas DataFrame"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _is_polars_lazyframe(dataframe: Any) -> bool:
|
|
362
|
+
return (
|
|
363
|
+
type(dataframe).__module__.split(".", 1)[0] == "polars"
|
|
364
|
+
and type(dataframe).__name__ == "LazyFrame"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _clone(engine: str, dataframe: Any):
|
|
369
|
+
if engine == "polars":
|
|
370
|
+
return dataframe.clone()
|
|
371
|
+
return dataframe.reset_index(drop=True).copy()
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _empty_frame(engine: str, dataframe: Any):
|
|
375
|
+
if engine == "polars":
|
|
376
|
+
return dataframe.head(0)
|
|
377
|
+
return dataframe.iloc[0:0].copy()
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _slice(engine: str, dataframe: Any, offset: int, length: int):
|
|
381
|
+
length = max(0, length)
|
|
382
|
+
if engine == "polars":
|
|
383
|
+
return dataframe.slice(offset, length)
|
|
384
|
+
return dataframe.iloc[offset:offset + length].reset_index(drop=True)
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _concat(engine: str, left: Any, right: Any):
|
|
388
|
+
if len(left) == 0:
|
|
389
|
+
return ResultBatchUploader._clone(engine, right)
|
|
390
|
+
if len(right) == 0:
|
|
391
|
+
return ResultBatchUploader._clone(engine, left)
|
|
392
|
+
if engine == "polars":
|
|
393
|
+
import polars as pl
|
|
394
|
+
|
|
395
|
+
return pl.concat([left, right], how="vertical")
|
|
396
|
+
import pandas as pd
|
|
397
|
+
|
|
398
|
+
return pd.concat([left, right], ignore_index=True)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _normalize_name(name: str) -> str:
|
|
402
|
+
if not isinstance(name, str) or not name.strip():
|
|
403
|
+
raise ValueError("name must be a non-empty string")
|
|
404
|
+
return name.strip()
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _normalize_options(options: Optional[dict]) -> Optional[dict]:
|
|
408
|
+
if options is None:
|
|
409
|
+
return None
|
|
410
|
+
if not isinstance(options, dict):
|
|
411
|
+
raise ValueError("options must be a dictionary or None")
|
|
412
|
+
return dict(options)
|
|
@@ -305,7 +305,10 @@ class TaskManager(ITaskManager):
|
|
|
305
305
|
|
|
306
306
|
def service_upload_result(self, service: Service, file_path, options=None):
|
|
307
307
|
"""
|
|
308
|
-
Uploads
|
|
308
|
+
Uploads one result file synchronously.
|
|
309
|
+
|
|
310
|
+
ResultBatchUploader owns background concurrency and invokes this method
|
|
311
|
+
from its worker, so failures must propagate for retry handling.
|
|
309
312
|
"""
|
|
310
313
|
endpoint = service.request.response_api
|
|
311
314
|
payload = None
|
|
@@ -318,25 +321,21 @@ class TaskManager(ITaskManager):
|
|
|
318
321
|
is_priority = options.get("is_priority")
|
|
319
322
|
payload["is_priority"] = str(is_priority).lower() if isinstance(is_priority, bool) else is_priority
|
|
320
323
|
|
|
321
|
-
def handle_upload_response(response):
|
|
322
|
-
"""Logs the response after the upload completes."""
|
|
323
|
-
if response is None:
|
|
324
|
-
log(f"Response from upload to {endpoint}: None")
|
|
325
|
-
return
|
|
326
|
-
try:
|
|
327
|
-
response_data = response.json()
|
|
328
|
-
except ValueError:
|
|
329
|
-
response_data = response.text
|
|
330
|
-
log(f"Response from upload to {endpoint}: {response_data}")
|
|
331
|
-
|
|
332
|
-
def upload_wrapper():
|
|
333
|
-
"""Wrapper for the upload to handle response logging."""
|
|
334
|
-
response = self.api.http_upload(endpoint, file_path, payload)
|
|
335
|
-
handle_upload_response(response)
|
|
336
|
-
|
|
337
324
|
log(f"Uploading Result to: {endpoint}")
|
|
338
|
-
|
|
339
|
-
|
|
325
|
+
response = self.api.http_upload(endpoint, file_path, payload)
|
|
326
|
+
if response is None:
|
|
327
|
+
raise RuntimeError(f"No response from result upload to {endpoint}")
|
|
328
|
+
if not response.ok:
|
|
329
|
+
raise RuntimeError(
|
|
330
|
+
f"Result upload failed status={response.status_code} "
|
|
331
|
+
f"endpoint={endpoint}"
|
|
332
|
+
)
|
|
333
|
+
try:
|
|
334
|
+
response_data = response.json()
|
|
335
|
+
except ValueError:
|
|
336
|
+
response_data = response.text
|
|
337
|
+
log(f"Response from upload to {endpoint}: {response_data}")
|
|
338
|
+
return response
|
|
340
339
|
|
|
341
340
|
def service_upload_report(self, service: Service, file_path):
|
|
342
341
|
"""
|