bunqueue-client 0.1.5__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.
bunqueue/flow.py ADDED
@@ -0,0 +1,257 @@
1
+ """FlowProducer: parent/child job trees, chains, and fan-in flows.
2
+
3
+ Mirrors the TS FlowProducer (TCP mode): children are pushed BEFORE their
4
+ parent; the parent carries dependsOn/childrenIds; each child is then linked
5
+ via UpdateParent. On any failure every already-created job is cancelled
6
+ (atomic rollback).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional, Sequence
12
+
13
+ from .connection import Connection, TlsOption, _compact
14
+ from .errors import CommandError
15
+ from .job import Job
16
+ from .options import job_options, job_payload
17
+ from .telemetry import TelemetryHandler
18
+
19
+
20
+ class FlowNode:
21
+ """Result node: the created job plus its (optional) children nodes."""
22
+
23
+ def __init__(self, job: Job, children: Optional[List["FlowNode"]] = None) -> None:
24
+ self.job = job
25
+ self.children = children or []
26
+
27
+
28
+ class FlowProducer:
29
+ """Create dependent job hierarchies across one or more queues.
30
+
31
+ Flow tree entries: ``{"name", "queueName", "data", "opts", "children": [...]}``
32
+ (same shape as BullMQ / the TS client).
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ host: str = "localhost",
38
+ port: int = 6789,
39
+ token: Optional[str] = None,
40
+ tls: TlsOption = None,
41
+ connection: Optional[Connection] = None,
42
+ on_telemetry: Optional[TelemetryHandler] = None,
43
+ ) -> None:
44
+ self.connection = connection or Connection(
45
+ host=host, port=port, token=token, tls=tls, on_telemetry=on_telemetry
46
+ )
47
+ self._owns_connection = connection is None
48
+
49
+ # ------------------------------------------------------------------- api
50
+
51
+ def add(self, flow: Dict[str, Any], opts: Optional[Dict[str, Any]] = None) -> FlowNode:
52
+ """Add a flow tree. Children are processed BEFORE their parent."""
53
+ created: List[str] = []
54
+ try:
55
+ return self._add_node(flow, None, created, opts or {})
56
+ except Exception:
57
+ self._cleanup(created)
58
+ raise
59
+
60
+ def add_bulk(self, flows: Sequence[Dict[str, Any]]) -> List[FlowNode]:
61
+ results: List[FlowNode] = []
62
+ created: List[str] = []
63
+ try:
64
+ for flow in flows:
65
+ results.append(self._add_node(flow, None, created, {}))
66
+ return results
67
+ except Exception:
68
+ self._cleanup(created)
69
+ raise
70
+
71
+ def add_chain(self, steps: Sequence[Dict[str, Any]]) -> List[str]:
72
+ """Sequential chain: step[0] -> step[1] -> ... (each depends on the previous)."""
73
+ job_ids: List[str] = []
74
+ prev_id: Optional[str] = None
75
+ try:
76
+ for step in steps:
77
+ data = {"__flowParentId": prev_id, **(step.get("data") or {})}
78
+ job_id = self._push(
79
+ step["queueName"],
80
+ job_payload(step["name"], data),
81
+ step.get("opts") or {},
82
+ depends_on=[prev_id] if prev_id else None,
83
+ )
84
+ job_ids.append(job_id)
85
+ prev_id = job_id
86
+ return job_ids
87
+ except Exception:
88
+ self._cleanup(job_ids)
89
+ raise
90
+
91
+ def add_bulk_then(
92
+ self, parallel: Sequence[Dict[str, Any]], final: Dict[str, Any]
93
+ ) -> Dict[str, Any]:
94
+ """Fan-in: N independent jobs converging into one final job.
95
+
96
+ The final job records the parallel ids as childrenIds so its processor
97
+ can read their results via ``get_children_values``.
98
+ """
99
+ parallel_ids: List[str] = []
100
+ try:
101
+ for step in parallel:
102
+ parallel_ids.append(
103
+ self._push(
104
+ step["queueName"],
105
+ job_payload(step["name"], step.get("data")),
106
+ step.get("opts") or {},
107
+ )
108
+ )
109
+ final_data = {"__flowParentIds": parallel_ids, **(final.get("data") or {})}
110
+ final_id = self._push_with_children(
111
+ final["queueName"],
112
+ job_payload(final["name"], final_data),
113
+ final.get("opts") or {},
114
+ child_ids=parallel_ids,
115
+ parent_id=None,
116
+ )
117
+ return {"parallel_ids": parallel_ids, "final_id": final_id}
118
+ except Exception:
119
+ self._cleanup(parallel_ids)
120
+ raise
121
+
122
+ def get_flow(self, job_id: str, depth: Optional[int] = None) -> Optional[FlowNode]:
123
+ """Reconstruct a flow tree from a root job id (GetJob recursion).
124
+
125
+ ``depth`` limits recursion; ``None`` means unlimited (parity with the
126
+ TS client's ``Number.POSITIVE_INFINITY`` default). A missing job — the
127
+ root or any child removed via removeOnComplete/cancel — yields ``None``
128
+ and is skipped, returning the surviving partial tree instead of raising.
129
+ A ``visited`` set guards against cycles in ``childrenIds`` so unlimited
130
+ depth cannot recurse forever.
131
+ """
132
+ return self._get_flow(job_id, depth, set())
133
+
134
+ def _get_flow(
135
+ self, job_id: str, depth: Optional[int], visited: set
136
+ ) -> Optional[FlowNode]:
137
+ if job_id in visited:
138
+ return None # cycle guard: childrenIds already on the current path
139
+ visited.add(job_id)
140
+ try:
141
+ response = self.connection.call({"cmd": "GetJob", "id": job_id})
142
+ except CommandError as exc:
143
+ if "not found" in str(exc).lower():
144
+ return None # root or a since-removed child -> skip
145
+ raise # a real server error must not masquerade as a missing child
146
+ raw = response.get("job")
147
+ if not raw:
148
+ return None
149
+ job = Job(raw, self.connection)
150
+ children: List[FlowNode] = []
151
+ if depth is None or depth > 0:
152
+ next_depth = None if depth is None else depth - 1
153
+ for child_id in job.children_ids:
154
+ child = self._get_flow(child_id, next_depth, visited)
155
+ if child:
156
+ children.append(child)
157
+ return FlowNode(job, children)
158
+
159
+ def close(self) -> None:
160
+ if self._owns_connection:
161
+ self.connection.close()
162
+
163
+ def __enter__(self) -> "FlowProducer":
164
+ return self
165
+
166
+ def __exit__(self, *exc: Any) -> None:
167
+ self.close()
168
+
169
+ # ------------------------------------------------------------- internals
170
+
171
+ def _add_node(
172
+ self,
173
+ node: Dict[str, Any],
174
+ parent_ref: Optional[Dict[str, str]],
175
+ created: List[str],
176
+ flow_opts: Dict[str, Any],
177
+ ) -> FlowNode:
178
+ child_nodes: List[FlowNode] = []
179
+ child_ids: List[str] = []
180
+
181
+ for child in node.get("children") or []:
182
+ child_node = self._add_node(
183
+ child, {"id": "pending", "queue": node["queueName"]}, created, flow_opts
184
+ )
185
+ child_nodes.append(child_node)
186
+ child_ids.append(child_node.job.id)
187
+
188
+ queue_defaults = (flow_opts.get("queues_options") or {}).get(node["queueName"]) or {}
189
+ merged_opts = {**queue_defaults, **(node.get("opts") or {})}
190
+
191
+ data: Dict[str, Any] = dict(node.get("data") or {})
192
+ if parent_ref:
193
+ data["__parentId"] = parent_ref["id"]
194
+ data["__parentQueue"] = parent_ref["queue"]
195
+ if child_ids:
196
+ data["__childrenIds"] = child_ids
197
+
198
+ job_id = self._push_with_children(
199
+ node["queueName"],
200
+ job_payload(node["name"], data),
201
+ merged_opts,
202
+ child_ids=child_ids,
203
+ parent_id=parent_ref["id"] if parent_ref else None,
204
+ )
205
+ created.append(job_id)
206
+
207
+ job = Job({"id": job_id, "queue": node["queueName"], "data": data}, self.connection)
208
+ return FlowNode(job, child_nodes)
209
+
210
+ def _push(
211
+ self,
212
+ queue: str,
213
+ data: Dict[str, Any],
214
+ opts: Dict[str, Any],
215
+ depends_on: Optional[List[str]] = None,
216
+ ) -> str:
217
+ command = {
218
+ "cmd": "PUSH",
219
+ "queue": queue,
220
+ "data": data,
221
+ **job_options(**opts),
222
+ }
223
+ if depends_on:
224
+ command["dependsOn"] = depends_on
225
+ response = self.connection.call(_compact(command))
226
+ return str(response.get("id"))
227
+
228
+ def _push_with_children(
229
+ self,
230
+ queue: str,
231
+ data: Dict[str, Any],
232
+ opts: Dict[str, Any],
233
+ child_ids: List[str],
234
+ parent_id: Optional[str],
235
+ ) -> str:
236
+ command = {
237
+ "cmd": "PUSH",
238
+ "queue": queue,
239
+ "data": data,
240
+ **job_options(**opts),
241
+ "parentId": parent_id,
242
+ "childrenIds": child_ids or None,
243
+ "dependsOn": child_ids or None,
244
+ }
245
+ response = self.connection.call(_compact(command))
246
+ job_id = str(response.get("id"))
247
+ # Link children to their real parent id (placeholder was 'pending')
248
+ for child_id in child_ids:
249
+ self.connection.call({"cmd": "UpdateParent", "childId": child_id, "parentId": job_id})
250
+ return job_id
251
+
252
+ def _cleanup(self, job_ids: List[str]) -> None:
253
+ for job_id in job_ids:
254
+ try:
255
+ self.connection.call({"cmd": "Cancel", "id": job_id})
256
+ except Exception: # noqa: BLE001 - best-effort rollback
257
+ pass
bunqueue/job.py ADDED
@@ -0,0 +1,179 @@
1
+ """Job wrapper: read view over the server job record + per-job operations.
2
+
3
+ Parity with the TS client's Job object (TCP mode): progress, logs, lock
4
+ management, state transitions, children values.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+ from .connection import Connection, _compact
12
+
13
+
14
+ class Job:
15
+ """A job as seen by producers (from queries) or workers (with lock token)."""
16
+
17
+ def __init__(
18
+ self,
19
+ raw: Dict[str, Any],
20
+ connection: Optional[Connection] = None,
21
+ token: Optional[str] = None,
22
+ on_progress: Optional[Callable[["Job", Any], None]] = None,
23
+ ) -> None:
24
+ self.raw = raw
25
+ self.token = token
26
+ self._conn = connection
27
+ self._on_progress = on_progress
28
+
29
+ # ------------------------------------------------------------ properties
30
+
31
+ @property
32
+ def id(self) -> str:
33
+ return str(self.raw.get("id"))
34
+
35
+ @property
36
+ def queue(self) -> str:
37
+ return str(self.raw.get("queue"))
38
+
39
+ @property
40
+ def data(self) -> Any:
41
+ return self.raw.get("data")
42
+
43
+ @property
44
+ def name(self) -> Optional[str]:
45
+ """Job name (stored inside ``data.name``, mirroring the JS SDK)."""
46
+ data = self.raw.get("data")
47
+ if isinstance(data, dict) and data.get("name") is not None:
48
+ return str(data["name"])
49
+ return None
50
+
51
+ @property
52
+ def priority(self) -> int:
53
+ return int(self.raw.get("priority") or 0)
54
+
55
+ @property
56
+ def attempts(self) -> int:
57
+ return int(self.raw.get("attempts") or 0)
58
+
59
+ @property
60
+ def max_attempts(self) -> int:
61
+ return int(self.raw.get("maxAttempts") or 0)
62
+
63
+ @property
64
+ def created_at(self) -> Optional[int]:
65
+ value = self.raw.get("createdAt")
66
+ return int(value) if value is not None else None
67
+
68
+ @property
69
+ def started_at(self) -> Optional[int]:
70
+ value = self.raw.get("startedAt")
71
+ return int(value) if value is not None else None
72
+
73
+ @property
74
+ def completed_at(self) -> Optional[int]:
75
+ value = self.raw.get("completedAt")
76
+ return int(value) if value is not None else None
77
+
78
+ @property
79
+ def custom_id(self) -> Optional[str]:
80
+ value = self.raw.get("customId")
81
+ return str(value) if value is not None else None
82
+
83
+ @property
84
+ def tags(self) -> List[str]:
85
+ return list(self.raw.get("tags") or [])
86
+
87
+ @property
88
+ def group_id(self) -> Optional[str]:
89
+ value = self.raw.get("groupId")
90
+ return str(value) if value is not None else None
91
+
92
+ @property
93
+ def parent_id(self) -> Optional[str]:
94
+ value = self.raw.get("parentId")
95
+ return str(value) if value is not None else None
96
+
97
+ @property
98
+ def children_ids(self) -> List[str]:
99
+ return [str(c) for c in (self.raw.get("childrenIds") or [])]
100
+
101
+ @property
102
+ def depends_on(self) -> List[str]:
103
+ return [str(c) for c in (self.raw.get("dependsOn") or [])]
104
+
105
+ @property
106
+ def progress(self) -> int:
107
+ return int(self.raw.get("progress") or 0)
108
+
109
+ @property
110
+ def stacktrace(self) -> Optional[List[str]]:
111
+ value = self.raw.get("stacktrace")
112
+ return list(value) if value else None
113
+
114
+ @property
115
+ def state(self) -> Optional[str]:
116
+ value = self.raw.get("state")
117
+ return str(value) if value is not None else None
118
+
119
+ # ------------------------------------------------------------ operations
120
+
121
+ def _call(self, command: Dict[str, Any]) -> Dict[str, Any]:
122
+ if self._conn is None:
123
+ raise RuntimeError("Job is not bound to a connection")
124
+ return self._conn.call(_compact(command))
125
+
126
+ def update_progress(self, progress: int, message: Optional[str] = None) -> None:
127
+ """Report processing progress (0-100) to the server."""
128
+ self._call({"cmd": "Progress", "id": self.id, "progress": progress, "message": message})
129
+ if self._on_progress is not None:
130
+ self._on_progress(self, progress)
131
+
132
+ def log(self, message: str, level: Optional[str] = None) -> None:
133
+ """Append a log row to the job (AddLog)."""
134
+ self._call({"cmd": "AddLog", "id": self.id, "message": message, "level": level})
135
+
136
+ def heartbeat(self) -> None:
137
+ """Renew this job's lock (stall detection)."""
138
+ self._call({"cmd": "JobHeartbeat", "id": self.id, "token": self.token})
139
+
140
+ def extend_lock(self, duration_ms: int) -> None:
141
+ self._call({"cmd": "ExtendLock", "id": self.id, "token": self.token, "duration": duration_ms})
142
+
143
+ def get_state(self) -> str:
144
+ return str(self._call({"cmd": "GetState", "id": self.id}).get("state"))
145
+
146
+ def get_children_values(self) -> Dict[str, Any]:
147
+ response = self._call({"cmd": "GetChildrenValues", "id": self.id})
148
+ data = response.get("data")
149
+ if isinstance(data, dict) and "values" in data:
150
+ return dict(data.get("values") or {})
151
+ return dict(response.get("values") or {})
152
+
153
+ def remove(self) -> None:
154
+ self._call({"cmd": "Cancel", "id": self.id})
155
+
156
+ def discard(self) -> None:
157
+ self._call({"cmd": "Discard", "id": self.id})
158
+
159
+ def promote(self) -> None:
160
+ self._call({"cmd": "Promote", "id": self.id})
161
+
162
+ def retry(self) -> None:
163
+ """failed -> waiting (BullMQ contract; MoveToWait over TCP)."""
164
+ self._call({"cmd": "MoveToWait", "id": self.id})
165
+
166
+ def update_data(self, data: Any) -> None:
167
+ self._call({"cmd": "Update", "id": self.id, "data": data})
168
+
169
+ def change_priority(self, priority: int, lifo: Optional[bool] = None) -> None:
170
+ self._call({"cmd": "ChangePriority", "id": self.id, "priority": priority, "lifo": lifo})
171
+
172
+ def change_delay(self, delay_ms: int) -> None:
173
+ self._call({"cmd": "ChangeDelay", "id": self.id, "delay": delay_ms})
174
+
175
+ def move_to_delayed(self, delay_ms: int) -> None:
176
+ self._call({"cmd": "MoveToDelayed", "id": self.id, "delay": delay_ms})
177
+
178
+ def __repr__(self) -> str: # pragma: no cover
179
+ return f"Job(id={self.id!r}, queue={self.queue!r}, name={self.name!r})"
bunqueue/options.py ADDED
@@ -0,0 +1,126 @@
1
+ """Job option mapping: pythonic kwargs -> wire fields (parity with TS client)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional, Sequence, Union
6
+
7
+ from .connection import _compact
8
+
9
+ Backoff = Union[int, Dict[str, Any]] # ms or {"type": "fixed"|"exponential", "delay", "maxDelay"}
10
+
11
+
12
+ def job_payload(name: str, data: Any) -> Dict[str, Any]:
13
+ """Mirror the JS SDK: the job name travels inside ``data``."""
14
+ if data is None:
15
+ return {"name": name}
16
+ if isinstance(data, dict):
17
+ return {"name": name, **data}
18
+ return {"name": name, "payload": data}
19
+
20
+
21
+ def build_cron_job_options(job_opts: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
22
+ """Map a cron template's Pythonic job options to the wire ``CronJobOptions``.
23
+
24
+ Mirrors the TS reference ``buildCronJobOptions`` (issue #86): the server
25
+ reads only camelCase keys (``maxAttempts``/``removeOnComplete``/…), so
26
+ SDK-native names (``attempts``/``remove_on_complete``/…) must be renamed
27
+ or the cron-spawned jobs silently fall back to JOB_DEFAULTS. Returns
28
+ ``None`` when nothing relevant is set (server keeps its own defaults).
29
+ """
30
+ if not job_opts:
31
+ return None
32
+ out: Dict[str, Any] = {}
33
+ if job_opts.get("attempts") is not None:
34
+ out["maxAttempts"] = job_opts["attempts"]
35
+ if job_opts.get("backoff") is not None:
36
+ out["backoff"] = job_opts["backoff"]
37
+ if job_opts.get("timeout") is not None:
38
+ out["timeout"] = job_opts["timeout"]
39
+ if job_opts.get("delay") is not None:
40
+ out["delay"] = job_opts["delay"]
41
+ if job_opts.get("stall_timeout") is not None:
42
+ out["stallTimeout"] = job_opts["stall_timeout"]
43
+ if isinstance(job_opts.get("remove_on_complete"), bool):
44
+ out["removeOnComplete"] = job_opts["remove_on_complete"]
45
+ if isinstance(job_opts.get("remove_on_fail"), bool):
46
+ out["removeOnFail"] = job_opts["remove_on_fail"]
47
+ return out or None
48
+
49
+
50
+ def job_options(
51
+ *,
52
+ priority: Optional[int] = None,
53
+ delay: Optional[int] = None,
54
+ attempts: Optional[int] = None,
55
+ backoff: Optional[Backoff] = None,
56
+ ttl: Optional[int] = None,
57
+ timeout: Optional[int] = None,
58
+ job_id: Optional[str] = None,
59
+ unique_key: Optional[str] = None,
60
+ deduplication: Optional[Dict[str, Any]] = None, # {"id", "ttl", "extend", "replace"}
61
+ depends_on: Optional[Sequence[str]] = None,
62
+ parent_id: Optional[str] = None,
63
+ children_ids: Optional[Sequence[str]] = None,
64
+ tags: Optional[Sequence[str]] = None,
65
+ group_id: Optional[str] = None,
66
+ lifo: Optional[bool] = None,
67
+ remove_on_complete: Optional[bool] = None,
68
+ remove_on_fail: Optional[bool] = None,
69
+ stall_timeout: Optional[int] = None,
70
+ durable: Optional[bool] = None,
71
+ repeat: Optional[Dict[str, Any]] = None, # {"every", "limit", "pattern", ...} camelCase wire keys
72
+ debounce: Optional[Dict[str, Any]] = None, # {"id", "ttl"}
73
+ stack_trace_limit: Optional[int] = None,
74
+ keep_logs: Optional[int] = None,
75
+ size_limit: Optional[int] = None,
76
+ timestamp: Optional[int] = None,
77
+ fail_parent_on_failure: Optional[bool] = None,
78
+ remove_dependency_on_failure: Optional[bool] = None,
79
+ ignore_dependency_on_failure: Optional[bool] = None,
80
+ continue_parent_on_failure: Optional[bool] = None,
81
+ ) -> Dict[str, Any]:
82
+ """Translate SDK options into the TCP PUSH field set (see buildPushPayload #88/#90)."""
83
+ dedup = None
84
+ if deduplication:
85
+ unique_key = unique_key or deduplication.get("id")
86
+ dedup = _compact(
87
+ {
88
+ "ttl": deduplication.get("ttl"),
89
+ "extend": deduplication.get("extend"),
90
+ "replace": deduplication.get("replace"),
91
+ }
92
+ ) or None
93
+ return _compact(
94
+ {
95
+ "priority": priority,
96
+ "delay": delay,
97
+ "maxAttempts": attempts,
98
+ "backoff": backoff,
99
+ "ttl": ttl,
100
+ "timeout": timeout,
101
+ "jobId": job_id,
102
+ "uniqueKey": unique_key,
103
+ "dedup": dedup,
104
+ "dependsOn": list(depends_on) if depends_on else None,
105
+ "parentId": parent_id,
106
+ "childrenIds": list(children_ids) if children_ids else None,
107
+ "tags": list(tags) if tags else None,
108
+ "groupId": group_id,
109
+ "lifo": lifo,
110
+ "removeOnComplete": remove_on_complete,
111
+ "removeOnFail": remove_on_fail,
112
+ "stallTimeout": stall_timeout,
113
+ "durable": durable,
114
+ "repeat": repeat,
115
+ "debounceId": debounce.get("id") if debounce else None,
116
+ "debounceTtl": debounce.get("ttl") if debounce else None,
117
+ "stackTraceLimit": stack_trace_limit,
118
+ "keepLogs": keep_logs,
119
+ "sizeLimit": size_limit,
120
+ "timestamp": timestamp,
121
+ "failParentOnFailure": fail_parent_on_failure,
122
+ "removeDependencyOnFailure": remove_dependency_on_failure,
123
+ "ignoreDependencyOnFailure": ignore_dependency_on_failure,
124
+ "continueParentOnFailure": continue_parent_on_failure,
125
+ }
126
+ )
bunqueue/py.typed ADDED
File without changes