optio-core 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.
Files changed (64) hide show
  1. {optio_core-0.2.2/src/optio_core.egg-info → optio_core-0.2.4}/PKG-INFO +1 -1
  2. {optio_core-0.2.2 → optio_core-0.2.4}/pyproject.toml +1 -1
  3. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/__init__.py +4 -3
  4. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/lifecycle.py +95 -63
  5. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/models.py +1 -0
  6. {optio_core-0.2.2 → optio_core-0.2.4/src/optio_core.egg-info}/PKG-INFO +1 -1
  7. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core.egg-info/SOURCES.txt +1 -0
  8. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_cancel_propagation.py +13 -2
  9. optio_core-0.2.4/tests/test_shutdown_drain_completion.py +150 -0
  10. {optio_core-0.2.2 → optio_core-0.2.4}/LICENSE +0 -0
  11. {optio_core-0.2.2 → optio_core-0.2.4}/README.md +0 -0
  12. {optio_core-0.2.2 → optio_core-0.2.4}/setup.cfg +0 -0
  13. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/_engine_service.py +0 -0
  14. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/_force_cancel.py +0 -0
  15. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/_generated/optio_engine.py +0 -0
  16. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/_launch_block_store.py +0 -0
  17. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/context.py +0 -0
  18. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/exceptions.py +0 -0
  19. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/executor.py +0 -0
  20. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/migrations/__init__.py +0 -0
  21. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/migrations/m001_status_subdocument.py +0 -0
  22. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/migrations/m002_backfill_child_metadata.py +0 -0
  23. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/migrations/m003_backfill_has_saved_state.py +0 -0
  24. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/migrations/m004_create_expire_at_ttl_index.py +0 -0
  25. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/progress_helpers.py +0 -0
  26. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/scheduler.py +0 -0
  27. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/state_machine.py +0 -0
  28. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core/store.py +0 -0
  29. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core.egg-info/dependency_links.txt +0 -0
  30. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core.egg-info/requires.txt +0 -0
  31. {optio_core-0.2.2 → optio_core-0.2.4}/src/optio_core.egg-info/top_level.txt +0 -0
  32. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_cancel_race_parent_overwrite.py +0 -0
  33. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_child_failure_cancel_distinction.py +0 -0
  34. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_child_failure_structured.py +0 -0
  35. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_child_progress.py +0 -0
  36. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_client_directed_events.py +0 -0
  37. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_context_blob.py +0 -0
  38. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_context_resume.py +0 -0
  39. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_deadline_cancel.py +0 -0
  40. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_deadline_cancel_launchguard.py +0 -0
  41. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_engine_service.py +0 -0
  42. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_engine_service_resolve.py +0 -0
  43. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_executor.py +0 -0
  44. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_group_cancel.py +0 -0
  45. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_group_cancel_purge.py +0 -0
  46. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_heartbeat.py +0 -0
  47. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_launch_guard.py +0 -0
  48. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_lifecycle_reconciliation.py +0 -0
  49. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_migration_m003.py +0 -0
  50. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_migration_m004.py +0 -0
  51. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_models.py +0 -0
  52. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_no_redis.py +0 -0
  53. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_outcomes.py +0 -0
  54. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_parallel.py +0 -0
  55. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_persistent_launch_blocks.py +0 -0
  56. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_progress_helpers.py +0 -0
  57. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_progress_throttle.py +0 -0
  58. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_resync.py +0 -0
  59. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_resync_cancel_stale.py +0 -0
  60. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_scheduler.py +0 -0
  61. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_state_machine.py +0 -0
  62. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_store.py +0 -0
  63. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_task_ttl.py +0 -0
  64. {optio_core-0.2.2 → optio_core-0.2.4}/tests/test_widget_primitives.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Reusable async process management library
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-core"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Reusable async process management library"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -34,7 +34,7 @@ __all__ = [
34
34
  "get_process", "list_processes",
35
35
  "block_launches", "unblock_launches",
36
36
  "group_cancel", "group_cancel_and_wait",
37
- "rpc_server", "store", "MongoStore",
37
+ "rpc_server", "mongo_store", "MongoStore",
38
38
  ]
39
39
 
40
40
 
@@ -47,7 +47,8 @@ def __getattr__(name: str):
47
47
  """
48
48
  if name == "rpc_server":
49
49
  return _instance.rpc_server
50
- if name == "store":
50
+ if name == "mongo_store":
51
51
  # Runtime-populated: the (db, prefix) binding only exists after init().
52
- return _instance.store
52
+ # NB: not "store" — that name is the optio_core.store submodule.
53
+ return _instance.mongo_store
53
54
  raise AttributeError(f"module 'optio_core' has no attribute {name!r}")
@@ -82,20 +82,24 @@ class Optio:
82
82
  self._owned_rpc_server: bool = False
83
83
  self._scheduler: ProcessScheduler | None = None
84
84
  self._running = False
85
+ self._shutting_down = False
86
+ self._shutdown_task: "asyncio.Task | None" = None
85
87
  self._heartbeat_task: asyncio.Task | None = None
86
88
  self._supervisor_task: asyncio.Task | None = None
87
89
  self._launch_blocks: dict[uuid.UUID, _BlockEntry] = {}
88
90
 
89
91
  @property
90
- def store(self) -> MongoStore:
92
+ def mongo_store(self) -> MongoStore:
91
93
  """The (db, prefix) binding this instance was initialized with.
92
94
 
95
+ Named ``mongo_store`` (not ``store``) because ``optio_core.store`` is a
96
+ submodule — a module-level ``store`` attribute would be shadowed by it.
93
97
  Hand it as-is to helpers that need db+prefix (e.g.
94
98
  ``optio_claudecode.delete_seed``) instead of re-deriving the prefix
95
99
  from your own config. Raises if accessed before ``init()``.
96
100
  """
97
101
  if self._config is None:
98
- raise RuntimeError("optio.store accessed before init()")
102
+ raise RuntimeError("optio.mongo_store accessed before init()")
99
103
  return MongoStore(db=self._config.mongo_db, prefix=self._config.prefix)
100
104
 
101
105
  async def init(
@@ -389,6 +393,11 @@ class Optio:
389
393
  background and the outcome is ok=True with `proc` populated from a
390
394
  post-schedule re-read (state will typically be 'scheduled' or
391
395
  already advanced to 'running' depending on event-loop timing)."""
396
+ # Stop sign: once shutdown has begun, refuse new launches so nothing
397
+ # half-boots into a shutting-down engine. Covers scheduler, RPC, and
398
+ # programmatic launch paths.
399
+ if self._shutting_down:
400
+ return LaunchOutcome(ok=False, reason="shutting-down")
392
401
  proc = await self._resolve(process_id)
393
402
  if proc is None:
394
403
  return LaunchOutcome(ok=False, reason="not-found")
@@ -877,10 +886,15 @@ class Optio:
877
886
  # Set up signal handlers (only works in main thread)
878
887
  try:
879
888
  loop = asyncio.get_event_loop()
889
+
890
+ def _request_shutdown() -> None:
891
+ # Store the task so run() can await it to completion before the
892
+ # loop is torn down; guard against a second signal.
893
+ if self._shutdown_task is None:
894
+ self._shutdown_task = asyncio.create_task(self.shutdown())
895
+
880
896
  for sig in (signal.SIGTERM, signal.SIGINT):
881
- loop.add_signal_handler(
882
- sig, lambda: asyncio.create_task(self.shutdown()),
883
- )
897
+ loop.add_signal_handler(sig, _request_shutdown)
884
898
  except (NotImplementedError, RuntimeError):
885
899
  pass # Signal handlers not available (e.g., in tests)
886
900
 
@@ -906,6 +920,16 @@ class Optio:
906
920
  try:
907
921
  await self._shutdown_event.wait()
908
922
  finally:
923
+ # A signal dispatches shutdown() as a detached task. Wait for its
924
+ # cooperative drain to finish before returning — otherwise main()
925
+ # returns and asyncio.run() tear-down cancels the drain mid-capture.
926
+ # (When shutdown() is called directly rather than via signal,
927
+ # _shutdown_task is None and the caller already awaited it.)
928
+ if self._shutdown_task is not None:
929
+ try:
930
+ await self._shutdown_task
931
+ except Exception:
932
+ logger.exception("shutdown task raised")
909
933
  await self._scheduler.stop()
910
934
 
911
935
  async def shutdown(self, grace_seconds: float | None = None) -> None:
@@ -920,67 +944,75 @@ class Optio:
920
944
  """
921
945
  logger.info("Shutdown requested")
922
946
  self._running = False
947
+ # Stop sign: refuse new launches (scheduler, RPC, programmatic) for the
948
+ # entire drain so nothing half-boots into a shutting-down engine.
949
+ self._shutting_down = True
923
950
 
924
- # 1. Heartbeat
925
- if self._heartbeat_task:
926
- self._heartbeat_task.cancel()
927
- try:
928
- await self._heartbeat_task
929
- except asyncio.CancelledError:
930
- pass
931
- self._heartbeat_task = None
932
-
933
- # 2. Signal shutdown to the main run() loop
934
- if hasattr(self, '_shutdown_event'):
935
- self._shutdown_event.set()
951
+ try:
952
+ # 1. Heartbeat
953
+ if self._heartbeat_task:
954
+ self._heartbeat_task.cancel()
955
+ try:
956
+ await self._heartbeat_task
957
+ except asyncio.CancelledError:
958
+ pass
959
+ self._heartbeat_task = None
960
+
961
+ # 2. RPC server (clamator)
962
+ if self.rpc_server is not None and self._owned_rpc_server:
963
+ grace_ms = int(
964
+ (grace_seconds if grace_seconds is not None else self._config.cancel_grace_seconds) * 1000
965
+ )
966
+ await self.rpc_server.stop(grace_ms=grace_ms)
936
967
 
937
- # 2b. RPC server (clamator)
938
- if self.rpc_server is not None and self._owned_rpc_server:
939
- grace_ms = int(
940
- (grace_seconds if grace_seconds is not None else self._config.cancel_grace_seconds) * 1000
968
+ # 3. Cancel everything via the unified mechanism.
969
+ grace = (
970
+ grace_seconds
971
+ if grace_seconds is not None
972
+ else self._config.cancel_grace_seconds
941
973
  )
942
- await self.rpc_server.stop(grace_ms=grace_ms)
943
-
944
- # 3. Cancel everything via the unified mechanism.
945
- grace = (
946
- grace_seconds
947
- if grace_seconds is not None
948
- else self._config.cancel_grace_seconds
949
- )
950
- if self._executor:
951
- now_mono = time.monotonic()
952
- for oid in list(self._executor._cancellation_flags.keys()):
953
- entry = self._executor._cancellation_flags.get(oid)
954
- if entry is None:
955
- continue
956
- entry.flag.set()
957
- if entry.deadline is None:
958
- entry.deadline = now_mono + grace
959
-
960
- # Wait for entries to drain. The supervisor handles force-cancel.
961
- ceiling = time.monotonic() + grace + 5.0
962
- while self._executor._cancellation_flags and time.monotonic() < ceiling:
963
- await asyncio.sleep(0.1)
964
-
965
- # Belt and braces: anything still left, force-cancel directly.
966
- # (Handles the case where the supervisor was slow or already stopped.)
967
- for oid in list(self._executor._cancellation_flags.keys()):
968
- await self._executor.force_cancel(oid)
969
-
970
- # 4. Stop supervisor (after final force-cancel pass).
971
- if self._supervisor_task:
972
- self._supervisor_task.cancel()
973
- try:
974
- await self._supervisor_task
975
- except asyncio.CancelledError:
976
- pass
977
- self._supervisor_task = None
978
-
979
- # 5. Redis
980
- if self._redis:
981
- await self._redis.aclose()
982
-
983
- logger.info("Shutdown complete")
974
+ if self._executor:
975
+ now_mono = time.monotonic()
976
+ for oid in list(self._executor._cancellation_flags.keys()):
977
+ entry = self._executor._cancellation_flags.get(oid)
978
+ if entry is None:
979
+ continue
980
+ entry.flag.set()
981
+ if entry.deadline is None:
982
+ entry.deadline = now_mono + grace
983
+
984
+ # Wait for entries to drain. The supervisor handles force-cancel.
985
+ ceiling = time.monotonic() + grace + 5.0
986
+ while self._executor._cancellation_flags and time.monotonic() < ceiling:
987
+ await asyncio.sleep(0.1)
988
+
989
+ # Belt and braces: anything still left, force-cancel directly.
990
+ # (Handles the case where the supervisor was slow or already stopped.)
991
+ for oid in list(self._executor._cancellation_flags.keys()):
992
+ await self._executor.force_cancel(oid)
993
+
994
+ # 4. Stop supervisor (after final force-cancel pass).
995
+ if self._supervisor_task:
996
+ self._supervisor_task.cancel()
997
+ try:
998
+ await self._supervisor_task
999
+ except asyncio.CancelledError:
1000
+ pass
1001
+ self._supervisor_task = None
1002
+
1003
+ # 5. Redis
1004
+ if self._redis:
1005
+ await self._redis.aclose()
1006
+
1007
+ logger.info("Shutdown complete")
1008
+ finally:
1009
+ # Unblock run() ONLY after the drain has fully run (or failed). The
1010
+ # finally guarantees run() still unblocks if the drain raised —
1011
+ # otherwise run() would hang until the external SIGKILL backstop.
1012
+ # (Moved here from an early "step 2": setting it before the drain let
1013
+ # run() return and asyncio.run() tear-down cancel the drain.)
1014
+ if hasattr(self, '_shutdown_event'):
1015
+ self._shutdown_event.set()
984
1016
 
985
1017
  async def _reconcile_interrupted_processes(self) -> None:
986
1018
  """Mark processes left in active states by a previous session as failed.
@@ -15,6 +15,7 @@ class LaunchOutcome:
15
15
  ok: bool
16
16
  reason: Literal[
17
17
  "not-found", "not-launchable", "launch-blocked", "no-resume-support",
18
+ "shutting-down",
18
19
  ] | None = None
19
20
  proc: dict[str, Any] | None = None
20
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Reusable async process management library
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -55,6 +55,7 @@ tests/test_progress_throttle.py
55
55
  tests/test_resync.py
56
56
  tests/test_resync_cancel_stale.py
57
57
  tests/test_scheduler.py
58
+ tests/test_shutdown_drain_completion.py
58
59
  tests/test_state_machine.py
59
60
  tests/test_store.py
60
61
  tests/test_task_ttl.py
@@ -578,8 +578,19 @@ async def test_force_cancel_cascade_catches_late_optout_child(mongo_db):
578
578
  await late_child_spawned.wait()
579
579
 
580
580
  # Within force-cancel window, late child exists in DB and is active.
581
- late_proc = await get_process_by_process_id(mongo_db, prefix, "late")
582
- assert late_proc is not None
581
+ # spawn() schedules child persistence as a background task
582
+ # (context.py: asyncio.create_task(_run())); it does not await the DB
583
+ # write. Poll for the late child to appear and be active within the
584
+ # force-cancel window instead of reading eagerly (the eager read raced
585
+ # cold-start persistence and failed when this test ran first/alone).
586
+ late_proc = None
587
+ end = _time.monotonic() + 0.4
588
+ while _time.monotonic() < end:
589
+ late_proc = await get_process_by_process_id(mongo_db, prefix, "late")
590
+ if late_proc is not None and late_proc["status"]["state"] in {"running", "scheduled"}:
591
+ break
592
+ await asyncio.sleep(0.01)
593
+ assert late_proc is not None, "late child never persisted/active within window"
583
594
  assert late_proc["status"]["state"] in {"running", "scheduled"}
584
595
 
585
596
  # After grace + cascade, late child reaches terminal.
@@ -0,0 +1,150 @@
1
+ """Shutdown must drain cooperating tasks BEFORE unblocking run().
2
+
3
+ Spec: docs/superpowers/specs/2026-06-02-graceful-engine-shutdown-capture-design.md
4
+ (Addendum). Regression guard for the run()/shutdown() ordering race: the
5
+ signal handler dispatched shutdown() as a detached task that set
6
+ _shutdown_event before the cooperative drain, so run() returned and
7
+ asyncio.run() teardown cancelled the drain mid-capture.
8
+ """
9
+ import asyncio
10
+
11
+ import pytest
12
+
13
+ from optio_core.lifecycle import Optio
14
+ from optio_core.models import TaskInstance
15
+
16
+ pytestmark = pytest.mark.asyncio
17
+
18
+
19
+ async def test_shutdown_event_fires_only_after_drain(mongo_db):
20
+ prefix = "shut1"
21
+ running = asyncio.Event()
22
+ captured = asyncio.Event()
23
+
24
+ async def task(ctx):
25
+ running.set()
26
+ # Cooperate with cancel, then simulate a snapshot capture that takes
27
+ # real time inside the cooperative window.
28
+ for _ in range(200):
29
+ if ctx.cancellation_flag.is_set():
30
+ break
31
+ await asyncio.sleep(0.02)
32
+ await asyncio.sleep(0.3) # "capture"
33
+ captured.set()
34
+
35
+ ti = TaskInstance(process_id="t.cap", name="Cap", params={}, execute=task)
36
+
37
+ async def gen(_s, _f):
38
+ return [ti]
39
+
40
+ optio = Optio()
41
+ await optio.init(
42
+ mongo_db=mongo_db, prefix=prefix,
43
+ get_task_definitions=gen, cancel_grace_seconds=2.0,
44
+ )
45
+ run_task = asyncio.create_task(optio.run())
46
+ try:
47
+ await optio.launch("t.cap", session_id=None)
48
+ await asyncio.wait_for(running.wait(), timeout=5)
49
+
50
+ # Mimic the signal handler: dispatch shutdown as a detached task.
51
+ shutdown_task = asyncio.create_task(optio.shutdown())
52
+
53
+ # The invariant: by the time run()'s unblock event is set, the
54
+ # cooperative drain must already have completed (task captured).
55
+ await asyncio.wait_for(optio._shutdown_event.wait(), timeout=5)
56
+ assert captured.is_set(), (
57
+ "_shutdown_event fired before the running task finished its "
58
+ "cooperative capture — run() would return and the loop tear-down "
59
+ "would abort the drain"
60
+ )
61
+
62
+ await asyncio.wait_for(shutdown_task, timeout=5)
63
+ finally:
64
+ await asyncio.wait_for(run_task, timeout=5)
65
+
66
+
67
+ async def test_launch_refused_during_shutdown(mongo_db):
68
+ prefix = "shut2"
69
+ running = asyncio.Event()
70
+
71
+ async def slow(ctx):
72
+ running.set()
73
+ for _ in range(200):
74
+ if ctx.cancellation_flag.is_set():
75
+ return
76
+ await asyncio.sleep(0.02)
77
+
78
+ a = TaskInstance(process_id="t.a", name="A", params={}, execute=slow)
79
+ b = TaskInstance(process_id="t.b", name="B", params={}, execute=slow)
80
+
81
+ async def gen(_s, _f):
82
+ return [a, b]
83
+
84
+ optio = Optio()
85
+ await optio.init(
86
+ mongo_db=mongo_db, prefix=prefix,
87
+ get_task_definitions=gen, cancel_grace_seconds=2.0,
88
+ )
89
+ run_task = asyncio.create_task(optio.run())
90
+ try:
91
+ await optio.launch("t.a", session_id=None)
92
+ await asyncio.wait_for(running.wait(), timeout=5)
93
+
94
+ shutdown_task = asyncio.create_task(optio.shutdown())
95
+ # Give shutdown() a tick to set _shutting_down at its start.
96
+ await asyncio.sleep(0.05)
97
+
98
+ outcome = await optio.launch("t.b", session_id=None)
99
+ assert outcome.ok is False
100
+ assert outcome.reason == "shutting-down"
101
+
102
+ await asyncio.wait_for(shutdown_task, timeout=5)
103
+ finally:
104
+ await asyncio.wait_for(run_task, timeout=5)
105
+
106
+
107
+ async def test_shutdown_drains_parent_and_child(mongo_db):
108
+ prefix = "shut3"
109
+ parent_running = asyncio.Event()
110
+ child_running = asyncio.Event()
111
+ child_captured = asyncio.Event()
112
+
113
+ async def child(ctx):
114
+ child_running.set()
115
+ for _ in range(200):
116
+ if ctx.cancellation_flag.is_set():
117
+ break
118
+ await asyncio.sleep(0.02)
119
+ await asyncio.sleep(0.2)
120
+ child_captured.set()
121
+
122
+ async def parent(ctx):
123
+ parent_running.set()
124
+ await ctx.run_child(execute=child, process_id="p.child", name="Child", params={})
125
+
126
+ parent_ti = TaskInstance(process_id="p.parent", name="Parent", params={}, execute=parent)
127
+
128
+ async def gen(_s, _f):
129
+ return [parent_ti]
130
+
131
+ optio = Optio()
132
+ await optio.init(
133
+ mongo_db=mongo_db, prefix=prefix,
134
+ get_task_definitions=gen, cancel_grace_seconds=2.0,
135
+ )
136
+ run_task = asyncio.create_task(optio.run())
137
+ try:
138
+ await optio.launch("p.parent", session_id=None)
139
+ await asyncio.wait_for(parent_running.wait(), timeout=5)
140
+ await asyncio.wait_for(child_running.wait(), timeout=5)
141
+
142
+ shutdown_task = asyncio.create_task(optio.shutdown())
143
+ await asyncio.wait_for(optio._shutdown_event.wait(), timeout=6)
144
+
145
+ # The child cooperated and captured before the event fired.
146
+ assert child_captured.is_set()
147
+
148
+ await asyncio.wait_for(shutdown_task, timeout=5)
149
+ finally:
150
+ await asyncio.wait_for(run_task, timeout=5)
File without changes
File without changes
File without changes