quapp-common 0.0.15.dev1__tar.gz → 0.0.15.dev4__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 (112) hide show
  1. {quapp_common-0.0.15.dev1/quapp_common.egg-info → quapp_common-0.0.15.dev4}/PKG-INFO +1 -1
  2. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/pyproject.toml +1 -1
  3. quapp_common-0.0.15.dev4/quapp_common/async_tasks/async_invocation_task.py +209 -0
  4. quapp_common-0.0.15.dev4/quapp_common/component/backend/job_heartbeat.py +164 -0
  5. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/backend/job_manager.py +22 -5
  6. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/config/thread_config.py +17 -0
  7. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4/quapp_common.egg-info}/PKG-INFO +1 -1
  8. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common.egg-info/SOURCES.txt +1 -0
  9. quapp_common-0.0.15.dev4/tests/test_compute_process.py +919 -0
  10. quapp_common-0.0.15.dev4/tests/test_job_heartbeat.py +618 -0
  11. quapp_common-0.0.15.dev1/quapp_common/async_tasks/async_invocation_task.py +0 -116
  12. quapp_common-0.0.15.dev1/quapp_common/component/backend/job_heartbeat.py +0 -83
  13. quapp_common-0.0.15.dev1/tests/test_job_heartbeat.py +0 -115
  14. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/LICENSE +0 -0
  15. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/README.md +0 -0
  16. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/__init__.py +0 -0
  17. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/async_tasks/__init__.py +0 -0
  18. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/async_tasks/async_task.py +0 -0
  19. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/async_tasks/export_circuit_task.py +0 -0
  20. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/async_tasks/post_processing_task.py +0 -0
  21. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/__init__.py +0 -0
  22. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/backend/__init__.py +0 -0
  23. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/backend/invocation.py +0 -0
  24. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/backend/job_fetcher.py +0 -0
  25. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/backend/job_fetching.py +0 -0
  26. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/bridge.py +0 -0
  27. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/callback/__init__.py +0 -0
  28. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/callback/update_job_metadata.py +0 -0
  29. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/circuit_adapter.py +0 -0
  30. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/device/__init__.py +0 -0
  31. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/device/device_selection.py +0 -0
  32. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/dispatcher.py +0 -0
  33. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/component/result_serializer.py +0 -0
  34. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/config/__init__.py +0 -0
  35. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/config/log_context.py +0 -0
  36. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/config/logging_config.py +0 -0
  37. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/__init__.py +0 -0
  38. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/async_task/__init__.py +0 -0
  39. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/async_task/circuit_export/__init__.py +0 -0
  40. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/async_task/circuit_export/backend_holder.py +0 -0
  41. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/async_task/circuit_export/circuit_holder.py +0 -0
  42. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/backend/__init__.py +0 -0
  43. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/backend/backend_information.py +0 -0
  44. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/callback/__init__.py +0 -0
  45. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/callback/callback_url.py +0 -0
  46. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/device/__init__.py +0 -0
  47. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/device/circuit_running_option.py +0 -0
  48. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/promise/__init__.py +0 -0
  49. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/promise/post_processing_promise.py +0 -0
  50. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/promise/promise.py +0 -0
  51. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/request/__init__.py +0 -0
  52. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/request/invocation_request.py +0 -0
  53. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/request/job_fetching_request.py +0 -0
  54. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/request/request.py +0 -0
  55. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/response/__init__.py +0 -0
  56. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/response/authentication.py +0 -0
  57. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/response/custom_header.py +0 -0
  58. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/response/error_report.py +0 -0
  59. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/data/response/job_response.py +0 -0
  60. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/__init__.py +0 -0
  61. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/base_enum.py +0 -0
  62. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/error_category.py +0 -0
  63. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/http_header.py +0 -0
  64. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/invocation_step.py +0 -0
  65. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/language.py +0 -0
  66. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/media_type.py +0 -0
  67. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/processing_unit.py +0 -0
  68. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/provider_tag.py +0 -0
  69. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/runtime_phase.py +0 -0
  70. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/sdk.py +0 -0
  71. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/status/__init__.py +0 -0
  72. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/status/job_status.py +0 -0
  73. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/status/status_code.py +0 -0
  74. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/enum/token_type.py +0 -0
  75. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/factory/__init__.py +0 -0
  76. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/factory/device_factory.py +0 -0
  77. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/factory/handler_factory.py +0 -0
  78. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/factory/provider_factory.py +0 -0
  79. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/handler/__init__.py +0 -0
  80. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/handler/handler.py +0 -0
  81. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/__init__.py +0 -0
  82. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/device/__init__.py +0 -0
  83. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/device/custom_device.py +0 -0
  84. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/device/device.py +0 -0
  85. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/invocation.py +0 -0
  86. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/provider/__init__.py +0 -0
  87. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/model/provider/provider.py +0 -0
  88. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/__init__.py +0 -0
  89. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/error_classifier.py +0 -0
  90. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/file_utils.py +0 -0
  91. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/http_utils.py +0 -0
  92. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/invocation_failure.py +0 -0
  93. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/json_parser_utils.py +0 -0
  94. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common/util/response_utils.py +0 -0
  95. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common.egg-info/dependency_links.txt +0 -0
  96. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common.egg-info/requires.txt +0 -0
  97. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/quapp_common.egg-info/top_level.txt +0 -0
  98. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/setup.cfg +0 -0
  99. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_callback_payload.py +0 -0
  100. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_circuit_adapter.py +0 -0
  101. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_dispatcher_runner_error.py +0 -0
  102. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_error_classifier.py +0 -0
  103. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_error_log_dedupe.py +0 -0
  104. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_error_report.py +0 -0
  105. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_invocation_failure.py +0 -0
  106. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_json_parser_utils.py +0 -0
  107. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_language_dispatcher.py +0 -0
  108. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_log_secret_leak.py +0 -0
  109. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_logging_config.py +0 -0
  110. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_no_sdk_dependency.py +0 -0
  111. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_phase_coverage.py +0 -0
  112. {quapp_common-0.0.15.dev1 → quapp_common-0.0.15.dev4}/tests/test_result_serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-common
3
- Version: 0.0.15.dev1
3
+ Version: 0.0.15.dev4
4
4
  Summary: Quapp common library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quapp-common"
7
- version = "0.0.15.dev1"
7
+ version = "0.0.15.dev4"
8
8
  description = "Quapp common library supporting Quapp Platform for Quantum Computing"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "CITYNOW Co. Ltd. ", email = "corp@citynow.vn" }]
@@ -0,0 +1,209 @@
1
+ # Quapp Platform Project
2
+ # async_invocation_task.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+ import asyncio
5
+ import ctypes
6
+ import multiprocessing
7
+ import os
8
+ import signal
9
+ import threading
10
+ import time
11
+
12
+ from starlette.responses import JSONResponse
13
+
14
+ from ..async_tasks.async_task import AsyncTask
15
+ from ..component.backend.job_heartbeat import TERMINAL_STATUSES, ensure_started
16
+ from ..component.backend.job_manager import JobManager
17
+ from ..config.log_context import bind_job
18
+ from ..config.logging_config import logger
19
+ from ..factory.handler_factory import HandlerFactory
20
+ from ..model.invocation import Event, Error, Success
21
+ from ..util.invocation_failure import report_invocation_failure
22
+
23
+ PR_SET_PDEATHSIG = 1
24
+ ORPHAN_EXIT_CODE = 3
25
+ PARENT_POLL_SEC = 5.0
26
+ COMPUTE_PROCESS_PREFIX = 'job-compute'
27
+
28
+
29
+ def fork_context():
30
+ return multiprocessing.get_context('fork')
31
+
32
+
33
+ def set_parent_death_signal() -> bool:
34
+ try:
35
+ libc = ctypes.CDLL('libc.so.6', use_errno=True)
36
+ return libc.prctl(PR_SET_PDEATHSIG, int(signal.SIGKILL), 0, 0, 0) == 0
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ def describe_exit_code(exit_code: int) -> str:
42
+ if exit_code == -int(signal.SIGKILL):
43
+ return ('compute process was killed with SIGKILL, which usually means '
44
+ 'the container ran out of memory')
45
+ if exit_code is not None and exit_code < 0:
46
+ try:
47
+ name = signal.Signals(-exit_code).name
48
+ except ValueError:
49
+ name = f'signal {-exit_code}'
50
+ return f'compute process was terminated by {name}'
51
+ if exit_code == ORPHAN_EXIT_CODE:
52
+ return 'compute process exited because its parent process was gone'
53
+ return f'compute process exited with code {exit_code}'
54
+
55
+
56
+ def _watch_parent(parent_pid: int) -> None:
57
+ while True:
58
+ if os.getppid() != parent_pid:
59
+ os._exit(ORPHAN_EXIT_CODE)
60
+ time.sleep(PARENT_POLL_SEC)
61
+
62
+
63
+ def run_compute(parent_pid: int, target) -> None:
64
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
65
+
66
+ if not set_parent_death_signal():
67
+ logger.warning(
68
+ 'Could not arm PR_SET_PDEATHSIG for the compute process; the '
69
+ 'only remaining guard is a polling thread, which cannot run '
70
+ 'while native code holds the GIL, so an orphaned compute '
71
+ 'process may keep a GPU or a scheduler slot busy')
72
+
73
+ if os.getppid() != parent_pid:
74
+ os._exit(ORPHAN_EXIT_CODE)
75
+
76
+ watcher = threading.Thread(target=_watch_parent, args=(parent_pid,),
77
+ name='compute-parent-watch', daemon=True)
78
+ watcher.start()
79
+
80
+ target()
81
+
82
+
83
+ class AsyncInvocationTask(AsyncTask):
84
+ """
85
+ Handles asynchronous invocation tasks, designed to process events with
86
+ specific handlers and generate immediate responses.
87
+
88
+ The class is aimed at enabling tasks to be processed asynchronously, involving
89
+ a handler factory and functions for event processing and post-processing. It
90
+ executes the handler in a dedicated child process to ensure quick response to
91
+ clients, allowing them to poll for job status updates. Its primary purpose is
92
+ to facilitate efficient asynchronous processing of jobs without blocking the
93
+ main application flow.
94
+
95
+ Attributes:
96
+ handler_factory: Factory instance that produces job handlers.
97
+ event: Event to be processed by the created handler.
98
+ processing_fn: Function to be executed during the job processing stage.
99
+ post_processing_fn: Function to be executed post-job processing.
100
+ """
101
+
102
+ def __init__(self, handler_factory: HandlerFactory, event: Event,
103
+ processing_fn, post_processing_fn):
104
+ super().__init__()
105
+ self.handler_factory = handler_factory
106
+ self.event = event
107
+ self.processing_fn = processing_fn
108
+ self.post_processing_fn = post_processing_fn
109
+
110
+ def do(self) -> JSONResponse:
111
+ """
112
+ Handles executing a job in a child process and provides an immediate response
113
+ with the job's ID and status. Logging is configured dynamically based on the
114
+ provided job ID.
115
+
116
+ Returns:
117
+ JSONResponse: A response object with the status and job ID if the job submission succeeds,
118
+ or an error message if there is an issue.
119
+
120
+ Raises:
121
+ Exception: If any error occurs during job processing, it is logged, and an appropriate error
122
+ response is returned.
123
+ """
124
+ try:
125
+ job_id = self.event.json().get('jobId')
126
+ if job_id is None:
127
+ raise Exception("Job ID is missing from event")
128
+
129
+ # This used to call logger.add(sink=sys.stderr, ...) for EVERY
130
+ # request: after N jobs there were N+1 sinks and every line printed
131
+ # N+1 times. The sinks are configured once in logging_config; all
132
+ # that is needed here is binding the context so every later line
133
+ # carries job_id/trace_id on its own.
134
+ job_log = bind_job(job_id)
135
+
136
+ def _run_handle():
137
+ return self.handler_factory.create_handler(self.event,
138
+ self.processing_fn,
139
+ self.post_processing_fn).handle()
140
+
141
+ JobManager.ensure_registry()
142
+ ensure_started()
143
+
144
+ loop = asyncio.get_running_loop()
145
+ process = fork_context().Process(
146
+ target=run_compute, args=(os.getpid(), _run_handle),
147
+ name=f'{COMPUTE_PROCESS_PREFIX}-{job_id}', daemon=False)
148
+
149
+ try:
150
+ process.start()
151
+ except Exception as start_exception:
152
+ job_log.bind(event='compute_process_start_failed').exception(
153
+ f'Could not start the compute process for job '
154
+ f'{job_id}: {start_exception}')
155
+ JobManager.set_failed(job_id)
156
+ report_invocation_failure(self.event, start_exception,
157
+ phase=None,
158
+ context={'jobId': job_id})
159
+ raise
160
+
161
+ self._watch_compute_process(loop, process, job_id, job_log)
162
+
163
+ # Respond immediately so clients can poll GET /jobs/{job_id}
164
+ return JSONResponse(status_code=200, content=Success(
165
+ JobManager.get_job(job_id)).serialize())
166
+ except Exception as exception:
167
+ logger.exception(f"An error occurred: {exception}")
168
+ return JSONResponse(status_code=400,
169
+ content=Error(exception).serialize())
170
+
171
+ def _watch_compute_process(self, loop, process, job_id, job_log) -> None:
172
+ sentinel = process.sentinel
173
+
174
+ def _on_process_exit():
175
+ loop.remove_reader(sentinel)
176
+ process.join()
177
+ exit_code = process.exitcode
178
+ if exit_code == 0:
179
+ return
180
+ self._report_compute_failure(loop, job_id, job_log, exit_code)
181
+
182
+ loop.add_reader(sentinel, _on_process_exit)
183
+
184
+ def _report_compute_failure(self, loop, job_id, job_log,
185
+ exit_code) -> None:
186
+ reason = describe_exit_code(exit_code)
187
+
188
+ if JobManager.get_status(job_id) in TERMINAL_STATUSES:
189
+ job_log.bind(event='compute_process_exit_ignored').info(
190
+ f'Job {job_id} already reached a terminal state, ignoring '
191
+ f'that the {reason}')
192
+ return
193
+
194
+ job_log.bind(event='compute_process_failed').error(
195
+ f'Job {job_id} lost its compute process: the {reason}')
196
+ JobManager.set_failed(job_id)
197
+ loop.run_in_executor(None, self._send_failure_report, job_id, job_log,
198
+ exit_code, reason)
199
+
200
+ def _send_failure_report(self, job_id, job_log, exit_code, reason) -> None:
201
+ try:
202
+ report_invocation_failure(self.event, RuntimeError(reason),
203
+ phase=None,
204
+ context={'jobId' : job_id,
205
+ 'exitCode': exit_code})
206
+ except Exception as exception:
207
+ job_log.bind(event='callback_failed').exception(
208
+ f'Failed to report the lost compute process of job '
209
+ f'{job_id}: {exception}')
@@ -0,0 +1,164 @@
1
+ # Quapp Platform Project
2
+ # job_heartbeat.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ import math
6
+ import multiprocessing
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+
12
+ import requests
13
+
14
+ from ...config.logging_config import logger
15
+
16
+ REQUEST_TIMEOUT_SEC = 5.0
17
+
18
+ DEFAULT_INTERVAL_SEC = 30.0
19
+ MAX_INTERVAL_SEC = DEFAULT_INTERVAL_SEC
20
+ ORPHAN_POLL_SEC = 5.0
21
+ STOP_GRACE_SEC = 1.0
22
+ TERMINAL_STATUSES = ('DONE', 'FAILED')
23
+
24
+ _context = None
25
+ _condition = threading.Condition()
26
+ _process = None
27
+ _stop_event = None
28
+ _starting = False
29
+
30
+
31
+ def heartbeat_interval_sec() -> float:
32
+ raw = os.getenv('HEARTBEAT_INTERVAL_SEC', '')
33
+ try:
34
+ value = float(raw)
35
+ except ValueError:
36
+ return DEFAULT_INTERVAL_SEC
37
+ if math.isfinite(value) and 0 < value <= MAX_INTERVAL_SEC:
38
+ return value
39
+ return DEFAULT_INTERVAL_SEC
40
+
41
+
42
+ def _log_safely(level: str, message: str) -> None:
43
+ try:
44
+ getattr(logger, level)(message)
45
+ except Exception as exception:
46
+ try:
47
+ print(f'[job-heartbeat] {level}: {message} ({exception})',
48
+ file=sys.stderr, flush=True)
49
+ except Exception:
50
+ pass
51
+
52
+
53
+ def beat_once() -> int:
54
+ from .job_manager import JobManager, META, STATUS, SCHEDULER_CALLBACK_URL
55
+
56
+ sent = 0
57
+ for job_id, data in JobManager.list_jobs().items():
58
+ if data.get(STATUS) in TERMINAL_STATUSES:
59
+ continue
60
+ url = (data.get(META) or {}).get(SCHEDULER_CALLBACK_URL)
61
+ if not url:
62
+ continue
63
+ try:
64
+ response = requests.patch(
65
+ url, json={'status': 'RUNNING', 'heartbeat': True},
66
+ timeout=REQUEST_TIMEOUT_SEC)
67
+ sent += 1
68
+ _log_safely('debug',
69
+ f'Heartbeat for job {job_id} returned {response.status_code}')
70
+ except Exception as exception:
71
+ _log_safely('debug',
72
+ f'Heartbeat for job {job_id} failed: {exception}')
73
+ return sent
74
+
75
+
76
+ def _wait_for_next_beat(parent_pid: int, stop_event, interval: float) -> bool:
77
+ deadline = time.monotonic() + interval
78
+ while True:
79
+ remaining = deadline - time.monotonic()
80
+ if remaining <= 0:
81
+ return True
82
+ if stop_event.wait(min(remaining, ORPHAN_POLL_SEC)):
83
+ return False
84
+ if os.getppid() != parent_pid:
85
+ return False
86
+
87
+
88
+ def _heartbeat_loop(parent_pid: int, stop_event) -> None:
89
+ while os.getppid() == parent_pid:
90
+ try:
91
+ beat_once()
92
+ except Exception as exception:
93
+ _log_safely('exception', f'Heartbeat loop error: {exception}')
94
+ if not _wait_for_next_beat(parent_pid, stop_event,
95
+ heartbeat_interval_sec()):
96
+ return
97
+
98
+
99
+ def _is_running_or_starting(process) -> bool:
100
+ if process is None:
101
+ return False
102
+ return process.pid is None or process.is_alive()
103
+
104
+
105
+ def _shutdown_process(process, stop_event) -> None:
106
+ if stop_event is not None:
107
+ stop_event.set()
108
+
109
+ if process is None or process.pid is None:
110
+ return
111
+
112
+ process.join(timeout=STOP_GRACE_SEC)
113
+ if process.is_alive():
114
+ process.terminate()
115
+ process.join(timeout=STOP_GRACE_SEC)
116
+ if process.is_alive():
117
+ process.kill()
118
+ process.join(timeout=STOP_GRACE_SEC)
119
+
120
+
121
+ def ensure_started() -> None:
122
+ global _context, _process, _stop_event, _starting
123
+
124
+ with _condition:
125
+ while _starting:
126
+ _condition.wait()
127
+ if _is_running_or_starting(_process):
128
+ return
129
+ if _context is None:
130
+ _context = multiprocessing.get_context('fork')
131
+ stop_event = _context.Event()
132
+ process = _context.Process(
133
+ target=_heartbeat_loop, args=(os.getpid(), stop_event),
134
+ name='job-heartbeat', daemon=True)
135
+ _stop_event = stop_event
136
+ _process = process
137
+ _starting = True
138
+
139
+ try:
140
+ process.start()
141
+ finally:
142
+ with _condition:
143
+ _starting = False
144
+ if process.pid is None and _process is process:
145
+ _process = None
146
+ _stop_event = None
147
+ _condition.notify_all()
148
+
149
+ logger.info(
150
+ f'Job heartbeat started with interval {heartbeat_interval_sec()}s')
151
+
152
+
153
+ def stop() -> None:
154
+ global _process, _stop_event
155
+
156
+ with _condition:
157
+ while _starting:
158
+ _condition.wait()
159
+ process = _process
160
+ stop_event = _stop_event
161
+ _process = None
162
+ _stop_event = None
163
+
164
+ _shutdown_process(process, stop_event)
@@ -8,7 +8,7 @@ import atexit
8
8
  import threading
9
9
  import time
10
10
  from datetime import timezone, datetime
11
- from multiprocessing import Manager, current_process
11
+ from multiprocessing import Manager, current_process, parent_process
12
12
  from typing import Any, Callable, Dict, Iterable, Optional, Tuple
13
13
 
14
14
  from ...config.logging_config import logger
@@ -61,13 +61,25 @@ class JobManager:
61
61
  # Ensure a clean shutdown in this process
62
62
  atexit.register(cls._shutdown)
63
63
 
64
+ @classmethod
65
+ def ensure_registry(cls) -> None:
66
+ cls._ensure_initialized()
67
+
68
+ @classmethod
69
+ def is_root_process(cls) -> bool:
70
+ return parent_process() is None
71
+
64
72
  @classmethod
65
73
  def _shutdown(cls) -> None:
66
74
  """
67
- Stop any listener threads in this process and shutdown Manager.
75
+ Stop the heartbeat process, then any listener threads in this process,
76
+ then shutdown Manager.
68
77
  """
69
78
  from .job_heartbeat import stop
70
- stop()
79
+ try:
80
+ stop()
81
+ except Exception as exception:
82
+ logger.exception(f'Error stopping job heartbeat: {exception}')
71
83
 
72
84
  # stop listeners
73
85
  for key, (t, stop_ev) in list(cls._listener_threads.items()):
@@ -103,8 +115,9 @@ class JobManager:
103
115
  cls._jobs[job_id] = data # proxy is process-safe
104
116
  cls._publish(job_id, {"event": "added", **data})
105
117
 
106
- from .job_heartbeat import ensure_started
107
- ensure_started()
118
+ if cls.is_root_process():
119
+ from .job_heartbeat import ensure_started
120
+ ensure_started()
108
121
 
109
122
  @classmethod
110
123
  def update_status(cls, job_id: str, status: str,
@@ -132,6 +145,10 @@ class JobManager:
132
145
  UPDATED_AT: cls._get_current_utc_time()}
133
146
  cls._jobs[job_id] = new_data
134
147
  cls._publish(job_id, {"event": "updated", **new_data})
148
+
149
+ from .job_heartbeat import TERMINAL_STATUSES, ensure_started
150
+ if status not in TERMINAL_STATUSES and cls.is_root_process():
151
+ ensure_started()
135
152
  return None
136
153
 
137
154
  @classmethod
@@ -2,6 +2,9 @@
2
2
  QApp Platform Project thread_config.py Copyright © CITYNOW Co. Ltd. All rights reserved.
3
3
  """
4
4
  import contextvars
5
+ import os
6
+ import queue
7
+ import threading
5
8
  from concurrent.futures import ThreadPoolExecutor
6
9
 
7
10
 
@@ -27,3 +30,17 @@ circuit_exporting_pool = ContextAwareThreadPoolExecutor(
27
30
 
28
31
  circuit_running_pool = ContextAwareThreadPoolExecutor(
29
32
  max_workers=20, thread_name_prefix='circuit-running-pool-')
33
+
34
+
35
+ def reset_pools_after_fork() -> None:
36
+ for pool in (circuit_exporting_pool, circuit_running_pool):
37
+ pool._threads = set()
38
+ pool._idle_semaphore = threading.Semaphore(0)
39
+ pool._work_queue = queue.SimpleQueue()
40
+ pool._shutdown = False
41
+ pool._shutdown_lock = threading.Lock()
42
+ pool._broken = False
43
+
44
+
45
+ if hasattr(os, 'register_at_fork'):
46
+ os.register_at_fork(after_in_child=reset_pools_after_fork)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-common
3
- Version: 0.0.15.dev1
3
+ Version: 0.0.15.dev4
4
4
  Summary: Quapp common library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -91,6 +91,7 @@ quapp_common/util/json_parser_utils.py
91
91
  quapp_common/util/response_utils.py
92
92
  tests/test_callback_payload.py
93
93
  tests/test_circuit_adapter.py
94
+ tests/test_compute_process.py
94
95
  tests/test_dispatcher_runner_error.py
95
96
  tests/test_error_classifier.py
96
97
  tests/test_error_log_dedupe.py