ailogger-client-dev 0.1.51__py3-none-any.whl

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.
@@ -0,0 +1,247 @@
1
+ """
2
+ Queued commands: submit a job, observe it, and return its finished result.
3
+
4
+ Some commands of the service are ALWAYS queued: a call is answered with a job
5
+ identity, never with the result, and the result is read through the queue's
6
+ job status. This module is the one place the client knows how to do that. It
7
+ carries two kinds of members:
8
+
9
+ - the PRIVATE machinery every typed queued method is built on - submitting a
10
+ command and taking the job identity out of the answer, and waiting for a job
11
+ to finish and taking its result out of the job-status answer. The caller of
12
+ a typed method never sees a job identity, a poll or an envelope unless it
13
+ asks for the job identity;
14
+ - the public typed ``messages_import_submit``, the no-wait form of the
15
+ ``messages_import`` command, which submits the job and returns its identity.
16
+ (The waiting form, ``messages_import``, is declared with the other message
17
+ methods.)
18
+
19
+ POLLING AND ENVELOPES BELONG HERE. Everything about reading a job - the poll
20
+ interval, the deadline, which answers are terminal, where in the job-status
21
+ answer the command's result lives, how a failed job or a refusing result
22
+ becomes a typed error of this client - is this module's, so a consumer of the
23
+ client (the pipeline included) holds none of it.
24
+
25
+ NETWORK LAW. This module imports nothing from the adapter framework and adds
26
+ no transport of its own; it reaches the service only through the invocation
27
+ seam and the job-observation surface of ``ailogger_client.invocation``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import time
34
+ from collections.abc import Mapping
35
+ from typing import Any, Final
36
+
37
+ from .errors import (
38
+ CompletionFailedError,
39
+ CompletionTimeoutError,
40
+ QueuedProtocolError,
41
+ ResultUnavailableError,
42
+ refusal_from_answer,
43
+ )
44
+
45
+ __all__ = ["QueuedMixin", "find_job_id"]
46
+
47
+ #: How often a waiting caller asks the queue for the job's status, in seconds.
48
+ _POLL_SECONDS: Final[float] = 1.0
49
+
50
+ #: The longest a waiting caller waits for one job to finish, in seconds.
51
+ _WAIT_SECONDS: Final[float] = 900.0
52
+
53
+ #: Job-status values that end a job without a usable result.
54
+ _FAILED_STATUSES: Final[frozenset[str]] = frozenset(
55
+ {"failed", "error", "timeout", "stopped", "cancelled"}
56
+ )
57
+
58
+ #: The key the framework's job-status tracking keeps the last queue answer under.
59
+ _LAST_QUEUE_POLL: Final[str] = "last_queue_poll"
60
+
61
+
62
+ #: The wrappers a job identity may sit inside in a submit answer.
63
+ _ANSWER_WRAPPERS: Final[tuple[str, ...]] = ("data", "result")
64
+
65
+
66
+ def find_job_id(answer: object) -> str | None:
67
+ """
68
+ The job identity a submit answer carries, if it carries one.
69
+
70
+ An always-queued command is answered with a job identity at the top of the
71
+ answer or inside the answer's ``data`` / ``result`` wrapper; a direct
72
+ command's answer carries none. Nothing deeper is looked at, so a field
73
+ that merely happens to be called ``job_id`` inside some result is never
74
+ taken for one.
75
+ """
76
+ if not isinstance(answer, Mapping):
77
+ return None
78
+ found = answer.get("job_id")
79
+ if isinstance(found, str) and found.strip():
80
+ return found.strip()
81
+ for wrapper in _ANSWER_WRAPPERS:
82
+ nested = answer.get(wrapper)
83
+ if isinstance(nested, Mapping):
84
+ found = nested.get("job_id")
85
+ if isinstance(found, str) and found.strip():
86
+ return found.strip()
87
+ return None
88
+
89
+
90
+ def _find_result_array(node: object) -> list[Any] | None:
91
+ """
92
+ The array a finished job answered with: the ``data`` of the first success
93
+ envelope whose data is an array, searched through the job-status answer.
94
+ """
95
+ if isinstance(node, Mapping):
96
+ data = node.get("data")
97
+ if node.get("success") is True and isinstance(data, list):
98
+ return data
99
+ for value in node.values():
100
+ nested = _find_result_array(value)
101
+ if nested is not None:
102
+ return nested
103
+ elif isinstance(node, list):
104
+ for value in node:
105
+ nested = _find_result_array(value)
106
+ if nested is not None:
107
+ return nested
108
+ return None
109
+
110
+
111
+ def _find_refusal(node: object, command: str) -> Exception | None:
112
+ """A refusing result envelope inside the job-status answer, if there is one."""
113
+ if isinstance(node, Mapping):
114
+ if node.get("success") is False:
115
+ refusal = refusal_from_answer(dict(node), command)
116
+ if refusal is not None:
117
+ return refusal
118
+ for value in node.values():
119
+ nested = _find_refusal(value, command)
120
+ if nested is not None:
121
+ return nested
122
+ elif isinstance(node, list):
123
+ for value in node:
124
+ nested = _find_refusal(value, command)
125
+ if nested is not None:
126
+ return nested
127
+ return None
128
+
129
+
130
+ class QueuedMixin:
131
+ """
132
+ Mixin providing the machinery of always-queued commands and the no-wait
133
+ typed method of ``messages_import``.
134
+
135
+ This mixin expects to be composed with InvocationMixin, which provides
136
+ ``_invoke`` and the job-observation surface (``get_job_status``).
137
+ """
138
+
139
+ async def _invoke(
140
+ self,
141
+ command: str,
142
+ params: dict[str, Any] | None = None,
143
+ *,
144
+ wait_for_result: bool = True,
145
+ ) -> Any:
146
+ """Private invocation entry point, provided by InvocationMixin."""
147
+ raise NotImplementedError # Provided by InvocationMixin
148
+
149
+ async def get_job_status(
150
+ self,
151
+ job_id: str,
152
+ *,
153
+ sync_from_queue: bool = False,
154
+ ) -> Any:
155
+ """Job observation, provided by InvocationMixin."""
156
+ raise NotImplementedError # Provided by InvocationMixin
157
+
158
+ async def _submit_queued(self, command: str, params: dict[str, Any]) -> str:
159
+ """
160
+ Submit an always-queued command and return its job identity.
161
+
162
+ Raises:
163
+ QueuedProtocolError: When the service answered without a job
164
+ identity - an always-queued command must never be answered
165
+ inline.
166
+ ServiceRefusalError: When the whole call is refused (nothing is
167
+ written and no job exists).
168
+ """
169
+ answer = await self._invoke(command, params, wait_for_result=False)
170
+ job_id = find_job_id(answer)
171
+ if job_id is None:
172
+ raise QueuedProtocolError(
173
+ f"{command} was not queued: the service answered without a "
174
+ "job identity"
175
+ )
176
+ return job_id
177
+
178
+ async def _await_queued_result(
179
+ self,
180
+ job_id: str,
181
+ *,
182
+ command: str,
183
+ timeout: float | None = None,
184
+ ) -> list[Any]:
185
+ """
186
+ Wait for a queued job to finish and return the array it answered with.
187
+
188
+ Raises:
189
+ CompletionTimeoutError: When the job does not finish in time.
190
+ CompletionFailedError: When the job ends failed.
191
+ ServiceRefusalError: When the job's own result is a refusal.
192
+ ResultUnavailableError: When the job ended without a result array.
193
+ """
194
+ limit = _WAIT_SECONDS if timeout is None else timeout
195
+ deadline = time.monotonic() + limit
196
+ while True:
197
+ snapshot = await self.get_job_status(job_id, sync_from_queue=True)
198
+ if snapshot.is_terminal:
199
+ break
200
+ if time.monotonic() >= deadline:
201
+ raise CompletionTimeoutError(
202
+ f"{command} job {job_id} did not finish within {limit} seconds"
203
+ )
204
+ await asyncio.sleep(_POLL_SECONDS)
205
+ summary = snapshot.summary
206
+ raw: object = summary.get(_LAST_QUEUE_POLL, summary)
207
+ status = str(snapshot.execution_status or "").lower()
208
+ refusal = _find_refusal(raw, command)
209
+ if refusal is not None:
210
+ raise refusal
211
+ if status in _FAILED_STATUSES:
212
+ raise CompletionFailedError(
213
+ f"{command} job {job_id} ended {status!r}: {raw!r}"
214
+ )
215
+ result = _find_result_array(raw)
216
+ if result is None:
217
+ raise ResultUnavailableError(
218
+ f"{command} job {job_id} ended {status!r} without a result array"
219
+ )
220
+ return result
221
+
222
+ async def messages_import_submit(self, messages: list[dict[str, Any]]) -> str:
223
+ """Submit a batch import without waiting and return its job identity.
224
+
225
+ ``messages_import`` is ALWAYS queued: the service answers with a job
226
+ identity and runs the import in a worker. This is the no-wait form:
227
+ it returns that identity as a plain string. The job's result - one
228
+ ``{message_id, success}`` entry per item - is what the waiting form,
229
+ ``messages_import``, returns; a caller of this method can read it
230
+ through ``get_job_status``.
231
+
232
+ Args:
233
+ messages: The items to import - exactly the ``append_message``
234
+ parameter set per item, with ``message_id`` and
235
+ ``occurred_at`` required. Passed through unchanged; the
236
+ service validates them.
237
+
238
+ Returns:
239
+ The job identity.
240
+
241
+ Raises:
242
+ ServiceRefusalError: When the whole call is refused (nothing is
243
+ written and no job is created).
244
+ QueuedProtocolError: When the service answered inline instead of
245
+ queueing.
246
+ """
247
+ return await self._submit_queued("messages_import", {"messages": messages})