gpmq 0.4.0__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.
gpmq/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """gpmq - GPMQ (General Purpose Message Queue) - 面向个人项目的 Python 分布式消息队列包."""
2
+
3
+ # Process policy
4
+ from gpmq.process import use_spawn
5
+
6
+ # Logging
7
+ from gpmq.log import get_logger
8
+
9
+ # Config
10
+ from gpmq.config import (
11
+ GPMQConfig,
12
+ HandlerConfig,
13
+ PublisherConfig,
14
+ SubscriberConfig,
15
+ set_config_manager,
16
+ get_config_manager,
17
+ get_subscriber,
18
+ get_client,
19
+ )
20
+
21
+ # Models
22
+ from gpmq.models import (
23
+ Message,
24
+ ProcessResult,
25
+ ProcessStatus,
26
+ WorkerState,
27
+ WorkerStatus,
28
+ WorkerInfo,
29
+ ConsumerGroupWorkers,
30
+ )
31
+
32
+ # Exceptions
33
+ from gpmq.exceptions import (
34
+ DuplicateMessageTypeError,
35
+ GPMQError,
36
+ HandlerTypeMismatchError,
37
+ InvalidMessageError,
38
+ InvalidTargetSubscriberError,
39
+ RedisConnectionError,
40
+ )
41
+
42
+ # Storage
43
+ from gpmq.storage import AuditStore, RedisAdapter
44
+
45
+ # Publisher
46
+ from gpmq.publisher import Publisher, ResultHandler
47
+
48
+ # Client
49
+ from gpmq.client import GPMQClient
50
+
51
+ # Progress
52
+ from gpmq.progress import GPMQProgress
53
+
54
+ # Async Batch
55
+ from gpmq.async_batch import GPMQAsyncBatchHandler
56
+
57
+ # Subscriber
58
+ from gpmq.subscriber import worker_context_processor
59
+ from gpmq.subscriber.handler import HandlerRegistry, message_handler
60
+ from gpmq.subscriber.subscriber import Subscriber
61
+
62
+ __version__ = "0.4.0"
63
+
64
+ __all__ = [
65
+ # Process policy
66
+ "use_spawn",
67
+ # Logging
68
+ "get_logger",
69
+ # Config
70
+ "GPMQConfig",
71
+ "HandlerConfig",
72
+ "PublisherConfig",
73
+ "SubscriberConfig",
74
+ "set_config_manager",
75
+ "get_config_manager",
76
+ "get_subscriber",
77
+ "get_client",
78
+ # Models
79
+ "Message",
80
+ "ProcessResult",
81
+ "ProcessStatus",
82
+ "WorkerState",
83
+ "WorkerStatus",
84
+ "WorkerInfo",
85
+ "ConsumerGroupWorkers",
86
+ # Exceptions
87
+ "GPMQError",
88
+ "RedisConnectionError",
89
+ "InvalidMessageError",
90
+ "InvalidTargetSubscriberError",
91
+ "DuplicateMessageTypeError",
92
+ "HandlerTypeMismatchError",
93
+ # Storage
94
+ "AuditStore",
95
+ "RedisAdapter",
96
+ # Publisher
97
+ "Publisher",
98
+ "ResultHandler",
99
+ # Client
100
+ "GPMQClient",
101
+ # Progress
102
+ "GPMQProgress",
103
+ # Async Batch
104
+ "GPMQAsyncBatchHandler",
105
+ # Subscriber
106
+ "message_handler",
107
+ "worker_context_processor",
108
+ "HandlerRegistry",
109
+ "Subscriber",
110
+ ]
gpmq/async_batch.py ADDED
@@ -0,0 +1,173 @@
1
+ """GPMQAsyncBatchHandler - batch async message processing."""
2
+
3
+ import time
4
+ from typing import Optional, Union
5
+
6
+ from gpmq.client import GPMQClient
7
+ from gpmq.models.result import ProcessResult, ProcessStatus
8
+ from gpmq.progress import GPMQProgress
9
+ from gpmq.publisher.result_handler import ResultHandler
10
+
11
+
12
+ class GPMQAsyncBatchHandler:
13
+ """Batch async message processor.
14
+
15
+ Collects messages from send_message() calls and provides
16
+ unified wait_all() with sliding window concurrency control,
17
+ progress reporting and timeout control.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ client: GPMQClient,
23
+ progress: Optional[GPMQProgress] = None,
24
+ timeout: Optional[float] = None,
25
+ queue_size: int = 32,
26
+ ) -> None:
27
+ if queue_size < 1:
28
+ raise ValueError(f"queue_size must be >= 1, got {queue_size}")
29
+ self._client = client
30
+ self._progress = progress
31
+ self._timeout = timeout
32
+ self._queue_size = queue_size
33
+ self._pending_messages: list[
34
+ tuple[str, dict, Optional[str], Optional[Union[str, list[str]]]]
35
+ ] = []
36
+ self._all_results: list[dict[str, ProcessResult]] = []
37
+ self._waited = False
38
+
39
+ def send_message(
40
+ self,
41
+ message_type: str,
42
+ payload: dict,
43
+ correlation_id: Optional[str] = None,
44
+ target_subscribers: Optional[Union[str, list[str]]] = None,
45
+ ) -> None:
46
+ """Store message for deferred publishing.
47
+
48
+ Args:
49
+ message_type: Type of message to publish.
50
+ payload: Message payload dict.
51
+ correlation_id: Optional correlation ID.
52
+ target_subscribers: Optional subscriber name or list of names.
53
+ Stored as-is and forwarded to ``client.publish_async`` during
54
+ ``wait_all``; normalization and validation happen there.
55
+ """
56
+ self._pending_messages.append(
57
+ (message_type, payload, correlation_id, target_subscribers)
58
+ )
59
+
60
+ def wait_all(self) -> list[dict[str, ProcessResult]]:
61
+ """Wait for all messages to be sent and results collected.
62
+
63
+ Uses a sliding window: sends up to queue_size messages initially,
64
+ then replenishes one-for-one as results complete. This limits
65
+ concurrent threads to queue_size regardless of total message count.
66
+
67
+ Returns:
68
+ List of result dicts, one per message, in send_message() order.
69
+ """
70
+ total = len(self._pending_messages)
71
+ if total == 0:
72
+ return []
73
+
74
+ if self._waited:
75
+ return self._all_results
76
+
77
+ if self._progress:
78
+ self._progress.create(total)
79
+ self._progress.start()
80
+
81
+ deadline = time.monotonic() + self._timeout if self._timeout else None
82
+
83
+ # Send initial batch
84
+ window_size = min(self._queue_size, total)
85
+ active: list[tuple[int, ResultHandler]] = [] # (original_index, handler)
86
+ next_idx = 0
87
+
88
+ for i in range(window_size):
89
+ msg_type, payload, corr_id, target = self._pending_messages[i]
90
+ handler = self._client.publish_async(msg_type, payload, corr_id, target)
91
+ active.append((i, handler))
92
+ next_idx = i + 1
93
+
94
+ result_map: dict[int, dict[str, ProcessResult]] = {}
95
+
96
+ while True:
97
+ if deadline is not None and time.monotonic() >= deadline:
98
+ break
99
+
100
+ if not active:
101
+ break
102
+
103
+ # Wait on current active window
104
+ active_handlers = [h for _, h in active]
105
+ wait_results = self._client.wait_all(
106
+ active_handlers, timeout=1.0, allow_timeout_retry=True
107
+ )
108
+
109
+ # Separate completed vs still-pending
110
+ # With allow_timeout_retry=True, TIMEOUT in results means
111
+ # "subscriber hasn't responded yet" — so "no TIMEOUT" = complete
112
+ new_active = []
113
+ for (orig_idx, _handler), result in zip(active, wait_results):
114
+ if all(pr.status != ProcessStatus.TIMEOUT for pr in result.values()):
115
+ result_map[orig_idx] = result
116
+ # Refill: send next pending message
117
+ if next_idx < total:
118
+ msg_type, payload, corr_id, target = self._pending_messages[next_idx]
119
+ try:
120
+ new_handler = self._client.publish_async(
121
+ msg_type, payload, corr_id, target
122
+ )
123
+ new_active.append((next_idx, new_handler))
124
+ except Exception as exc:
125
+ raise RuntimeError(
126
+ f"async_batch aborted: publish_async failed for "
127
+ f"message index {next_idx} "
128
+ f"(type={msg_type!r}, payload={payload!r})"
129
+ ) from exc
130
+ next_idx += 1
131
+ else:
132
+ new_active.append((orig_idx, _handler))
133
+
134
+ active = new_active
135
+
136
+ total_completed = len(result_map)
137
+ if self._progress:
138
+ self._progress.update(total_completed)
139
+
140
+ if not active:
141
+ break
142
+
143
+ # Final cleanup: PubSub unsubscribe and save TIMEOUT results to audit
144
+ if active:
145
+ active_handlers = [h for _, h in active]
146
+ active_indices = [idx for idx, _ in active]
147
+ final_results = self._client.wait_all(
148
+ active_handlers, timeout=1.0, allow_timeout_retry=False
149
+ )
150
+ for orig_idx, result in zip(active_indices, final_results):
151
+ result_map[orig_idx] = result
152
+
153
+ self._all_results = [result_map.get(i, {}) for i in range(total)]
154
+
155
+ if self._progress:
156
+ self._progress.finish()
157
+
158
+ self._waited = True
159
+ return self._all_results
160
+
161
+ def get_all_result(self) -> list[dict[str, ProcessResult]]:
162
+ """Get all message processing results.
163
+
164
+ Available after wait_all() or context manager exit.
165
+ Returns empty list if called before wait_all().
166
+ """
167
+ return self._all_results
168
+
169
+ def __enter__(self) -> "GPMQAsyncBatchHandler":
170
+ return self
171
+
172
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
173
+ self.wait_all()
gpmq/cli.py ADDED
@@ -0,0 +1,432 @@
1
+ """GPMQ CLI - Command-line interface for GPMQ message queue management."""
2
+
3
+ import json
4
+ from contextlib import contextmanager
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import click
9
+ from gpconfig import GPConfigManager
10
+
11
+ from gpmq.config import (
12
+ GPMQConfig,
13
+ get_config_manager,
14
+ get_subscriber,
15
+ set_config_manager,
16
+ )
17
+ from gpmq.models.result import ProcessStatus
18
+ from gpmq.storage.audit_store import AuditStore
19
+
20
+
21
+ def _init_cfg_mgr(config_path: str | None) -> GPConfigManager:
22
+ """Initialize GPConfigManager and set it as global config manager.
23
+
24
+ Args:
25
+ config_path: Optional path to config folder containing global_env.yaml.
26
+ If not provided, GPConfigManager will search via env variable
27
+ GPMQ_CLI_CFG_PATH or user home directory.
28
+
29
+ Returns:
30
+ Initialized GPConfigManager instance.
31
+ """
32
+ cfg_mgr = GPConfigManager(
33
+ project_name="gpmq_cli",
34
+ cfg_folder=config_path,
35
+ )
36
+ set_config_manager(cfg_mgr)
37
+ return cfg_mgr
38
+
39
+
40
+ def _lazy_init_cfg_mgr(ctx: click.Context, param: click.Parameter, value: str | None) -> None:
41
+ """Callback for --config option on subcommands: init GPConfigManager if not done."""
42
+ if get_config_manager() is not None:
43
+ return
44
+ _init_cfg_mgr(value)
45
+
46
+
47
+ def _config_option(func):
48
+ """Decorator that adds --config option to a subcommand for lazy init."""
49
+ return click.option(
50
+ "--config",
51
+ default=None,
52
+ expose_value=False,
53
+ callback=_lazy_init_cfg_mgr,
54
+ help="Configuration folder path (must contain global_env.yaml). "
55
+ "If not provided, searches GPMQ_CLI_CFG_PATH env var or user home.",
56
+ )(func)
57
+
58
+
59
+ def _format_output(data: Any, fmt: str) -> str:
60
+ """Format data for CLI output.
61
+
62
+ Args:
63
+ data: Data to format (list of dicts or dict).
64
+ fmt: Output format - "json" or "table".
65
+
66
+ Returns:
67
+ Formatted string.
68
+ """
69
+ if fmt == "json":
70
+ return json.dumps(data, indent=2, default=str)
71
+
72
+ if fmt == "table":
73
+ if not data:
74
+ return ""
75
+
76
+ # Handle dict (stats) vs list of dicts (query results)
77
+ if isinstance(data, dict):
78
+ lines = []
79
+ for key, value in data.items():
80
+ if isinstance(value, dict):
81
+ lines.append(f"{key}:")
82
+ for k, v in value.items():
83
+ lines.append(f" {k}: {v}")
84
+ else:
85
+ lines.append(f"{key}: {value}")
86
+ return "\n".join(lines)
87
+
88
+ # List of dicts - column-aligned table
89
+ if isinstance(data, list) and len(data) > 0:
90
+ columns = list(data[0].keys())
91
+ # Calculate column widths
92
+ widths = {}
93
+ for col in columns:
94
+ widths[col] = len(str(col))
95
+ for row in data:
96
+ widths[col] = max(widths[col], len(str(row.get(col, ""))))
97
+
98
+ # Build header
99
+ header = " ".join(str(col).ljust(widths[col]) for col in columns)
100
+ separator = " ".join("-" * widths[col] for col in columns)
101
+ # Build rows
102
+ rows = []
103
+ for row in data:
104
+ line = " ".join(
105
+ str(row.get(col, "")).ljust(widths[col]) for col in columns
106
+ )
107
+ rows.append(line)
108
+
109
+ return "\n".join([header, separator] + rows)
110
+
111
+ return ""
112
+
113
+ raise ValueError(f"Unsupported output format: {fmt!r} (expected 'json' or 'table')")
114
+
115
+
116
+ def _resolve_audit_config(cfg_path: str) -> tuple[str, bool]:
117
+ """Resolve audit_db_path and enable_audit for a given component config path.
118
+
119
+ Merges common config with the specific component config, following the
120
+ same pattern as get_subscriber() and get_client().
121
+
122
+ Args:
123
+ cfg_path: Dot-notation config path, e.g. "gpmq.subscribers.data_loader"
124
+ or "gpmq.publisher.main".
125
+
126
+ Returns:
127
+ Tuple of (audit_db_path, enable_audit).
128
+ """
129
+ cfg_mgr = get_config_manager()
130
+ common_dict = cfg_mgr.get_config(GPMQConfig.default_cfg_path).model_dump()
131
+ specific = cfg_mgr.get_config(cfg_path)
132
+ specific_set = specific.model_dump(exclude_unset=True)
133
+ merged = {**common_dict, **specific_set}
134
+
135
+ return merged.get("audit_db_path", "gpmq_audit.db"), merged.get("enable_audit", True)
136
+
137
+
138
+ @contextmanager
139
+ def _get_audit_store(db_path: str):
140
+ """Context manager that creates and closes an AuditStore.
141
+
142
+ Args:
143
+ db_path: Path to the SQLite audit database file.
144
+
145
+ Yields:
146
+ AuditStore instance.
147
+ """
148
+ store = AuditStore(db_path)
149
+ store.connect()
150
+ try:
151
+ yield store
152
+ finally:
153
+ store.close()
154
+
155
+
156
+ def _parse_iso_datetime(value: str) -> datetime:
157
+ """Parse ISO format datetime string.
158
+
159
+ Args:
160
+ value: ISO datetime string (e.g., '2026-01-01T00:00:00').
161
+
162
+ Returns:
163
+ datetime object.
164
+
165
+ Raises:
166
+ click.BadParameter: If the string cannot be parsed.
167
+ """
168
+ try:
169
+ return datetime.fromisoformat(value)
170
+ except ValueError:
171
+ raise click.BadParameter(
172
+ f"Invalid datetime format: {value}. Use ISO format like 2026-01-01T00:00:00"
173
+ )
174
+
175
+
176
+ @contextmanager
177
+ def _create_redis_adapter():
178
+ """Context manager that creates, connects, and disconnects a RedisAdapter.
179
+
180
+ Uses the global GPConfigManager to load configuration.
181
+
182
+ Yields:
183
+ Connected RedisAdapter instance.
184
+ """
185
+ from gpmq.storage.redis_adapter import RedisAdapter
186
+
187
+ cfg_mgr = get_config_manager()
188
+ config = cfg_mgr.get_config(GPMQConfig.default_cfg_path)
189
+ adapter = RedisAdapter(config)
190
+ adapter.connect()
191
+ try:
192
+ yield adapter
193
+ finally:
194
+ adapter.disconnect()
195
+
196
+
197
+ @click.group()
198
+ @click.option(
199
+ "--config",
200
+ default=None,
201
+ help="Configuration folder path (must contain global_env.yaml). "
202
+ "If not provided, searches GPMQ_CLI_CFG_PATH env var or user home.",
203
+ )
204
+ @click.pass_context
205
+ def cli(ctx: click.Context, config: str | None) -> None:
206
+ """GPMQ - General Purpose Message Queue CLI."""
207
+ from gpmq import use_spawn
208
+ use_spawn()
209
+ ctx.ensure_object(dict)
210
+ if config is not None:
211
+ _init_cfg_mgr(config)
212
+
213
+
214
+ @cli.group()
215
+ @click.pass_context
216
+ def audit(ctx: click.Context) -> None:
217
+ """Audit message and result records."""
218
+ pass
219
+
220
+
221
+ @audit.command("query")
222
+ @_config_option
223
+ @click.argument("component", metavar="COMPONENT_NAME")
224
+ @click.option("--type", "message_type", default=None, help="Filter by message type.")
225
+ @click.option(
226
+ "--status",
227
+ "status_filter",
228
+ default=None,
229
+ help="Filter by processing status (success/failure/exception/timeout).",
230
+ )
231
+ @click.option("--correlation-id", default=None, help="Filter by correlation ID.")
232
+ @click.option("--from", "from_time", default=None, help="Start time (ISO format).")
233
+ @click.option("--to", "to_time", default=None, help="End time (ISO format).")
234
+ @click.option("--limit", default=100, type=int, help="Max results (default: 100).")
235
+ @click.option(
236
+ "--format",
237
+ "fmt",
238
+ type=click.Choice(["json", "table"]),
239
+ default="table",
240
+ help="Output format.",
241
+ )
242
+ @click.pass_context
243
+ def audit_query(
244
+ ctx: click.Context,
245
+ component: str,
246
+ message_type: str | None,
247
+ status_filter: str | None,
248
+ correlation_id: str | None,
249
+ from_time: str | None,
250
+ to_time: str | None,
251
+ limit: int,
252
+ fmt: str,
253
+ ) -> None:
254
+ """Query audit records with optional filters."""
255
+ audit_db_path, enable_audit = _resolve_audit_config(component)
256
+ if not enable_audit:
257
+ click.echo(f"Audit store is disabled for '{component}'.")
258
+ return
259
+
260
+ click.echo(f"Audit store: {audit_db_path}")
261
+
262
+ with _get_audit_store(audit_db_path) as store:
263
+ results = []
264
+
265
+ if correlation_id:
266
+ results = store.query_by_correlation_id(correlation_id)
267
+ elif status_filter:
268
+ try:
269
+ status_enum = ProcessStatus(status_filter)
270
+ except ValueError:
271
+ valid = ", ".join(s.value for s in ProcessStatus)
272
+ raise click.BadParameter(
273
+ f"Invalid status: {status_filter}. Valid values: {valid}"
274
+ )
275
+ results = store.query_by_status(status_enum, limit=limit)
276
+ elif message_type:
277
+ start = _parse_iso_datetime(from_time) if from_time else None
278
+ end = _parse_iso_datetime(to_time) if to_time else None
279
+ results = store.query_by_type(
280
+ message_type, start_time=start, end_time=end, limit=limit
281
+ )
282
+ elif from_time and to_time:
283
+ start = _parse_iso_datetime(from_time)
284
+ end = _parse_iso_datetime(to_time)
285
+ results = store.query_by_time_range(start, end, limit=limit)
286
+ else:
287
+ # Fallback: no filters, get recent
288
+ click.echo(
289
+ "No filters specified. Use --type, --status, --correlation-id, or --from/--to."
290
+ )
291
+ return
292
+
293
+ click.echo(_format_output(results, fmt))
294
+
295
+
296
+ @audit.command("cleanup")
297
+ @_config_option
298
+ @click.argument("component", metavar="COMPONENT_NAME")
299
+ @click.option(
300
+ "--before",
301
+ required=True,
302
+ help="Delete records before this timestamp (ISO format).",
303
+ )
304
+ @click.option("--type", "message_type", default=None, help="Delete by message type.")
305
+ @click.option(
306
+ "--format",
307
+ "fmt",
308
+ type=click.Choice(["json", "table"]),
309
+ default="table",
310
+ help="Output format.",
311
+ )
312
+ @click.pass_context
313
+ def audit_cleanup(
314
+ ctx: click.Context, component: str, before: str, message_type: str | None, fmt: str
315
+ ) -> None:
316
+ """Delete old audit records."""
317
+ audit_db_path, enable_audit = _resolve_audit_config(component)
318
+ if not enable_audit:
319
+ click.echo(f"Audit store is disabled for '{component}'.")
320
+ return
321
+
322
+ click.echo(f"Audit store: {audit_db_path}")
323
+ before_dt = _parse_iso_datetime(before)
324
+
325
+ with _get_audit_store(audit_db_path) as store:
326
+ if message_type:
327
+ count = store.cleanup_by_type_before(message_type, before_dt)
328
+ else:
329
+ count = store.cleanup_by_time(before_dt)
330
+
331
+ result = {"deleted_count": count}
332
+ click.echo(_format_output(result, fmt))
333
+
334
+
335
+ @audit.command("stats")
336
+ @_config_option
337
+ @click.argument("component", metavar="COMPONENT_NAME")
338
+ @click.option(
339
+ "--format",
340
+ "fmt",
341
+ type=click.Choice(["json", "table"]),
342
+ default="table",
343
+ help="Output format.",
344
+ )
345
+ @click.pass_context
346
+ def audit_stats(ctx: click.Context, component: str, fmt: str) -> None:
347
+ """Show audit statistics."""
348
+ audit_db_path, enable_audit = _resolve_audit_config(component)
349
+ if not enable_audit:
350
+ click.echo(f"Audit store is disabled for '{component}'.")
351
+ return
352
+
353
+ click.echo(f"Audit store: {audit_db_path}")
354
+
355
+ with _get_audit_store(audit_db_path) as store:
356
+ stats = store.stats()
357
+ click.echo(_format_output(stats, fmt))
358
+
359
+
360
+ @cli.command()
361
+ @_config_option
362
+ @click.option("--stream", required=True, help="Stream name to clear.")
363
+ @click.pass_context
364
+ def clear(ctx: click.Context, stream: str) -> None:
365
+ """Clear a Redis Stream (requires confirmation)."""
366
+ with _create_redis_adapter() as adapter:
367
+ if click.confirm(f"Are you sure you want to clear stream '{stream}'?"):
368
+ adapter.clear_stream(stream)
369
+ click.echo(f"Stream '{stream}' cleared.")
370
+ else:
371
+ click.echo("Cancelled.")
372
+
373
+
374
+ @cli.command()
375
+ @_config_option
376
+ @click.option(
377
+ "--format",
378
+ "fmt",
379
+ type=click.Choice(["json", "table"]),
380
+ default="table",
381
+ help="Output format.",
382
+ )
383
+ @click.pass_context
384
+ def status(ctx: click.Context, fmt: str) -> None:
385
+ """Show system status: active subscribers and their info."""
386
+ with _create_redis_adapter() as adapter:
387
+ names = adapter.get_active_subscribers()
388
+ subscribers = []
389
+ for name in names:
390
+ info = adapter.get_subscriber_info(name)
391
+ if info:
392
+ entry = {"name": name}
393
+ entry.update(info)
394
+ subscribers.append(entry)
395
+ click.echo(_format_output(subscribers, fmt))
396
+
397
+
398
+ def _run_worker(subscriber_name: str) -> None:
399
+ """Common worker startup logic.
400
+
401
+ Args:
402
+ subscriber_name: Dot-notation config path, e.g. "gpmq.subscribers.data_loader".
403
+ """
404
+ sub = get_subscriber(subscriber_name)
405
+ click.echo(f"Subscriber '{sub.name}' started. Press Ctrl+C to stop.")
406
+ sub.run_forever()
407
+ click.echo("Subscriber stopped.")
408
+
409
+
410
+ @cli.command()
411
+ @_config_option
412
+ @click.argument("subscriber_name")
413
+ @click.pass_context
414
+ def worker(ctx: click.Context, subscriber_name: str) -> None:
415
+ """Start a GPMQ subscriber worker process."""
416
+ _run_worker(subscriber_name)
417
+
418
+
419
+ @click.command()
420
+ @click.argument("subscriber_name")
421
+ @click.option(
422
+ "--config",
423
+ default=None,
424
+ help="Configuration folder path (must contain global_env.yaml). "
425
+ "If not provided, searches GPMQ_CLI_CFG_PATH env var or user home.",
426
+ )
427
+ def gpmq_worker(subscriber_name: str, config: str | None) -> None:
428
+ """Start a GPMQ subscriber worker process."""
429
+ from gpmq import use_spawn
430
+ use_spawn()
431
+ _init_cfg_mgr(config)
432
+ _run_worker(subscriber_name)