dataloom-engine 0.4.1__tar.gz → 0.5.0__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.
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/PKG-INFO +1 -1
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/loom.py +70 -6
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/types.py +3 -2
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/PKG-INFO +1 -1
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/pyproject.toml +1 -1
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/tests/test_loom.py +100 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/LICENSE +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/README.md +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/__init__.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/_optional.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/config.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/exceptions.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/hooks.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/logs.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/processors.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/py.typed +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/sinks.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/sources.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine/weaver.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/SOURCES.txt +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/dependency_links.txt +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/requires.txt +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/top_level.txt +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/setup.cfg +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/tests/test_config.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/tests/test_core.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/tests/test_optional.py +0 -0
- {dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/tests/test_sinks.py +0 -0
|
@@ -6,8 +6,10 @@ Defines the main class responsible for managing the lifecycle of the
|
|
|
6
6
|
worker threads (Weavers) and the distribution of tasks.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import queue
|
|
10
11
|
import threading
|
|
12
|
+
import time
|
|
11
13
|
from typing import TYPE_CHECKING, Optional
|
|
12
14
|
|
|
13
15
|
from dataloom_engine.config import LoomConfig
|
|
@@ -21,6 +23,8 @@ from dataloom_engine.weaver import STOP_SENTINEL, Weaver
|
|
|
21
23
|
if TYPE_CHECKING:
|
|
22
24
|
from dataloom_engine.sources import Source
|
|
23
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
24
28
|
|
|
25
29
|
class Loom:
|
|
26
30
|
"""
|
|
@@ -80,6 +84,8 @@ class Loom:
|
|
|
80
84
|
self.weavers: list[Weaver] = []
|
|
81
85
|
self._stop_lock = threading.Lock()
|
|
82
86
|
self._stopped = False
|
|
87
|
+
# Distinguishes COMPLETED (source exhausted) from STOPPED (interrupted)
|
|
88
|
+
self._source_exhausted = False
|
|
83
89
|
|
|
84
90
|
def __enter__(self) -> "Loom":
|
|
85
91
|
return self
|
|
@@ -127,6 +133,11 @@ class Loom:
|
|
|
127
133
|
if self.stop_event.is_set():
|
|
128
134
|
break
|
|
129
135
|
self._enqueue(batch)
|
|
136
|
+
else:
|
|
137
|
+
# The for/else only runs when the loop ended without a
|
|
138
|
+
# break: the source was exhausted naturally, so stop()
|
|
139
|
+
# may report COMPLETED instead of STOPPED.
|
|
140
|
+
self._source_exhausted = True
|
|
130
141
|
except Exception as e:
|
|
131
142
|
self.state = LoomState.FAILED
|
|
132
143
|
self.hooks.on_error(e)
|
|
@@ -146,13 +157,21 @@ class Loom:
|
|
|
146
157
|
except queue.Full:
|
|
147
158
|
continue
|
|
148
159
|
|
|
149
|
-
def stop(self) -> None:
|
|
160
|
+
def stop(self, timeout: Optional[float] = None) -> None:
|
|
150
161
|
"""
|
|
151
162
|
Signals every component to stop and waits for cleanup.
|
|
152
163
|
Safe to call multiple times or from finally blocks.
|
|
153
164
|
|
|
154
165
|
Items already queued are processed before shutdown: each Weaver
|
|
155
166
|
drains the queue until it finds its stop sentinel.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
timeout: Maximum time in seconds to wait for the Weavers to
|
|
170
|
+
finish. None (default) waits indefinitely. When the
|
|
171
|
+
deadline passes, still-running Weavers are reported via
|
|
172
|
+
hooks.on_error (as LoomError), the sink is closed anyway
|
|
173
|
+
and stop() returns; the leftover daemon threads do not
|
|
174
|
+
block process exit.
|
|
156
175
|
"""
|
|
157
176
|
with self._stop_lock:
|
|
158
177
|
if self._stopped:
|
|
@@ -161,17 +180,42 @@ class Loom:
|
|
|
161
180
|
|
|
162
181
|
self.stop_event.set()
|
|
163
182
|
|
|
183
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
184
|
+
|
|
164
185
|
# One sentinel per Weaver: each thread drains the queue and exits
|
|
165
186
|
# upon consuming its own. This replaces the queue join(), which
|
|
166
187
|
# could block forever if a Weaver died before emptying it.
|
|
167
188
|
for _ in self.weavers:
|
|
168
|
-
self.
|
|
169
|
-
|
|
170
|
-
weaver.join()
|
|
189
|
+
if not self._put_sentinel(deadline):
|
|
190
|
+
break
|
|
171
191
|
|
|
172
|
-
|
|
192
|
+
stuck = []
|
|
193
|
+
for weaver in self.weavers:
|
|
194
|
+
if deadline is None:
|
|
195
|
+
weaver.join()
|
|
196
|
+
else:
|
|
197
|
+
weaver.join(timeout=max(deadline - time.monotonic(), 0))
|
|
198
|
+
if weaver.is_alive():
|
|
199
|
+
stuck.append(weaver)
|
|
200
|
+
|
|
201
|
+
if stuck:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"%d weaver(s) still running after the stop timeout; they are "
|
|
204
|
+
"daemon threads and will not block process exit.",
|
|
205
|
+
len(stuck),
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
self.hooks.on_error(
|
|
209
|
+
LoomError(f"{len(stuck)} weaver(s) did not finish within the stop timeout.")
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.exception("The on_error callback raised an exception.")
|
|
213
|
+
|
|
214
|
+
# Natural exhaustion of the source becomes COMPLETED; an external
|
|
215
|
+
# stop or interruption becomes STOPPED. A FAILED state set by
|
|
216
|
+
# start() on a source error is never overwritten.
|
|
173
217
|
if self.state is LoomState.RUNNING:
|
|
174
|
-
self.state = LoomState.COMPLETED
|
|
218
|
+
self.state = LoomState.COMPLETED if self._source_exhausted else LoomState.STOPPED
|
|
175
219
|
|
|
176
220
|
try:
|
|
177
221
|
self.sink.close()
|
|
@@ -180,3 +224,23 @@ class Loom:
|
|
|
180
224
|
self.hooks.on_error(e)
|
|
181
225
|
|
|
182
226
|
self.hooks.on_stop()
|
|
227
|
+
|
|
228
|
+
def _put_sentinel(self, deadline: Optional[float]) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Enqueues one stop sentinel, giving up when the deadline passes or
|
|
231
|
+
when no Weaver is alive to drain a full queue — a plain blocking
|
|
232
|
+
put() would hang stop() forever in that scenario.
|
|
233
|
+
"""
|
|
234
|
+
while True:
|
|
235
|
+
if deadline is None:
|
|
236
|
+
wait = 0.1
|
|
237
|
+
else:
|
|
238
|
+
wait = min(0.1, max(deadline - time.monotonic(), 0.0))
|
|
239
|
+
try:
|
|
240
|
+
self.task_queue.put(STOP_SENTINEL, timeout=wait)
|
|
241
|
+
return True
|
|
242
|
+
except queue.Full:
|
|
243
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
244
|
+
return False
|
|
245
|
+
if not any(weaver.is_alive() for weaver in self.weavers):
|
|
246
|
+
return False
|
|
@@ -14,5 +14,6 @@ class LoomState(Enum):
|
|
|
14
14
|
|
|
15
15
|
PENDING = "pending"
|
|
16
16
|
RUNNING = "running"
|
|
17
|
-
COMPLETED = "completed"
|
|
18
|
-
|
|
17
|
+
COMPLETED = "completed" # the source was exhausted naturally
|
|
18
|
+
STOPPED = "stopped" # interrupted (external stop() or KeyboardInterrupt) before exhaustion
|
|
19
|
+
FAILED = "failed" # the source raised an error
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dataloom-engine"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "DataLoom: a lightweight and efficient thread orchestration engine for data pipelines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# tests/test_loom.py
|
|
2
2
|
|
|
3
3
|
import threading
|
|
4
|
+
import time
|
|
4
5
|
from typing import Any, Iterator
|
|
5
6
|
|
|
6
7
|
import numpy as np
|
|
@@ -275,6 +276,105 @@ def test_loom_survives_broken_metrics_hook():
|
|
|
275
276
|
assert loom.state is LoomState.COMPLETED
|
|
276
277
|
|
|
277
278
|
|
|
279
|
+
def test_loom_external_stop_sets_stopped_state():
|
|
280
|
+
"""stop() before the source is exhausted must end as STOPPED, not COMPLETED."""
|
|
281
|
+
|
|
282
|
+
class InfiniteSource(Source):
|
|
283
|
+
def __iter__(self) -> Iterator[Any]:
|
|
284
|
+
i = 0
|
|
285
|
+
while True:
|
|
286
|
+
yield np.array([i])
|
|
287
|
+
i += 1
|
|
288
|
+
|
|
289
|
+
hooks = RecordingHooks()
|
|
290
|
+
sink = InMemorySink()
|
|
291
|
+
loom = _make_loom(InfiniteSource(), hooks=hooks, sink=sink)
|
|
292
|
+
|
|
293
|
+
runner = threading.Thread(target=loom.start, daemon=True)
|
|
294
|
+
runner.start()
|
|
295
|
+
# Wait until the pipeline is demonstrably flowing before stopping it
|
|
296
|
+
for _ in range(500):
|
|
297
|
+
with sink._lock:
|
|
298
|
+
if sink.results:
|
|
299
|
+
break
|
|
300
|
+
time.sleep(0.01)
|
|
301
|
+
assert sink.results, "pipeline never produced a result"
|
|
302
|
+
|
|
303
|
+
loom.stop()
|
|
304
|
+
runner.join(timeout=5)
|
|
305
|
+
|
|
306
|
+
assert not runner.is_alive(), "start() did not return after stop()"
|
|
307
|
+
assert loom.state is LoomState.STOPPED
|
|
308
|
+
assert hooks.stopped
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_loom_keyboard_interrupt_sets_stopped_state():
|
|
312
|
+
"""Ctrl+C is an interruption: the state must be STOPPED, not COMPLETED or FAILED."""
|
|
313
|
+
|
|
314
|
+
class InterruptingSource(Source):
|
|
315
|
+
def __iter__(self) -> Iterator[Any]:
|
|
316
|
+
yield np.array([1])
|
|
317
|
+
raise KeyboardInterrupt
|
|
318
|
+
|
|
319
|
+
hooks = RecordingHooks()
|
|
320
|
+
loom = _make_loom(InterruptingSource(), hooks=hooks)
|
|
321
|
+
|
|
322
|
+
with pytest.raises(KeyboardInterrupt):
|
|
323
|
+
loom.start()
|
|
324
|
+
|
|
325
|
+
assert loom.state is LoomState.STOPPED
|
|
326
|
+
assert hooks.stopped # cleanup still ran
|
|
327
|
+
assert hooks.errors == [] # an interruption is not an error
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_loom_stop_timeout_reports_stuck_weaver():
|
|
331
|
+
"""stop(timeout=...) returns even with a hung Processor and reports it via on_error."""
|
|
332
|
+
entered = threading.Event()
|
|
333
|
+
release = threading.Event()
|
|
334
|
+
|
|
335
|
+
class BlockingProcessor(Processor):
|
|
336
|
+
def process(self, batch):
|
|
337
|
+
entered.set()
|
|
338
|
+
release.wait(timeout=10)
|
|
339
|
+
return {"data": batch[0]}
|
|
340
|
+
|
|
341
|
+
class GatedSource(Source):
|
|
342
|
+
"""Yields one batch, then keeps the producer inside the loop until released."""
|
|
343
|
+
|
|
344
|
+
def __iter__(self) -> Iterator[Any]:
|
|
345
|
+
yield np.array([1])
|
|
346
|
+
release.wait(timeout=10)
|
|
347
|
+
|
|
348
|
+
hooks = RecordingHooks()
|
|
349
|
+
config = LoomConfig(output_dir=".", batch_size=1, interval_seconds=0)
|
|
350
|
+
loom = Loom(
|
|
351
|
+
config=config,
|
|
352
|
+
processor=BlockingProcessor(),
|
|
353
|
+
sink=InMemorySink(),
|
|
354
|
+
source=GatedSource(),
|
|
355
|
+
hooks=hooks,
|
|
356
|
+
num_weavers=1,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
runner = threading.Thread(target=loom.start, daemon=True)
|
|
360
|
+
runner.start()
|
|
361
|
+
assert entered.wait(timeout=5), "the weaver never picked up the batch"
|
|
362
|
+
|
|
363
|
+
started = time.monotonic()
|
|
364
|
+
loom.stop(timeout=0.2)
|
|
365
|
+
elapsed = time.monotonic() - started
|
|
366
|
+
|
|
367
|
+
assert elapsed < 5, "stop(timeout) blocked far beyond its deadline"
|
|
368
|
+
assert loom.state is LoomState.STOPPED
|
|
369
|
+
assert any("stop timeout" in str(e) for e in hooks.errors)
|
|
370
|
+
assert hooks.stopped # the sink was closed and on_stop fired despite the stuck weaver
|
|
371
|
+
|
|
372
|
+
# Cleanup: unblock everything so the test leaves no lingering threads
|
|
373
|
+
release.set()
|
|
374
|
+
runner.join(timeout=5)
|
|
375
|
+
assert not runner.is_alive()
|
|
376
|
+
|
|
377
|
+
|
|
278
378
|
@pytest.mark.parametrize("num_weavers", [0, -1])
|
|
279
379
|
def test_loom_rejects_non_positive_num_weavers(num_weavers):
|
|
280
380
|
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dataloom_engine-0.4.1 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|