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 +110 -0
- gpmq/async_batch.py +173 -0
- gpmq/cli.py +432 -0
- gpmq/client.py +325 -0
- gpmq/config.py +383 -0
- gpmq/exceptions.py +117 -0
- gpmq/log.py +54 -0
- gpmq/models/__init__.py +16 -0
- gpmq/models/message.py +22 -0
- gpmq/models/result.py +33 -0
- gpmq/models/status.py +33 -0
- gpmq/models/worker_info.py +27 -0
- gpmq/process.py +20 -0
- gpmq/progress.py +50 -0
- gpmq/publisher/__init__.py +9 -0
- gpmq/publisher/publisher.py +210 -0
- gpmq/publisher/result_handler.py +192 -0
- gpmq/storage/__init__.py +9 -0
- gpmq/storage/audit_store.py +503 -0
- gpmq/storage/redis_adapter.py +660 -0
- gpmq/subscriber/__init__.py +17 -0
- gpmq/subscriber/decorators.py +168 -0
- gpmq/subscriber/examples.py +215 -0
- gpmq/subscriber/handler.py +185 -0
- gpmq/subscriber/subscriber.py +241 -0
- gpmq/subscriber/worker.py +539 -0
- gpmq/subscriber/worker_manager.py +150 -0
- gpmq-0.4.0.dist-info/METADATA +611 -0
- gpmq-0.4.0.dist-info/RECORD +32 -0
- gpmq-0.4.0.dist-info/WHEEL +4 -0
- gpmq-0.4.0.dist-info/entry_points.txt +3 -0
- gpmq-0.4.0.dist-info/licenses/LICENSE +21 -0
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)
|