crazy-workers 0.1.0__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.
- crazy_workers-0.1.0/LICENSE +19 -0
- crazy_workers-0.1.0/PKG-INFO +247 -0
- crazy_workers-0.1.0/README.md +212 -0
- crazy_workers-0.1.0/crazy_workers/__init__.py +5 -0
- crazy_workers-0.1.0/crazy_workers/_bootstrap.py +33 -0
- crazy_workers-0.1.0/crazy_workers/cli/__init__.py +4 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/__init__.py +8 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/lister.py +57 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/params.py +37 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/restorer.py +14 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/starter.py +36 -0
- crazy_workers-0.1.0/crazy_workers/cli/commands/stopper.py +30 -0
- crazy_workers-0.1.0/crazy_workers/cli/discovery.py +93 -0
- crazy_workers-0.1.0/crazy_workers/cli/main.py +85 -0
- crazy_workers-0.1.0/crazy_workers/cli/ui.py +9 -0
- crazy_workers-0.1.0/crazy_workers/core/__init__.py +4 -0
- crazy_workers-0.1.0/crazy_workers/core/engine.py +90 -0
- crazy_workers-0.1.0/crazy_workers/core/manager/__init__.py +89 -0
- crazy_workers-0.1.0/crazy_workers/core/manager/lister.py +67 -0
- crazy_workers-0.1.0/crazy_workers/core/manager/recoverer.py +25 -0
- crazy_workers-0.1.0/crazy_workers/core/manager/starter.py +137 -0
- crazy_workers-0.1.0/crazy_workers/core/manager/stopper.py +58 -0
- crazy_workers-0.1.0/crazy_workers/core/recovery.py +68 -0
- crazy_workers-0.1.0/crazy_workers/database/__init__.py +5 -0
- crazy_workers-0.1.0/crazy_workers/database/schema.py +42 -0
- crazy_workers-0.1.0/crazy_workers/database/storage.py +56 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/PKG-INFO +247 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/SOURCES.txt +32 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/dependency_links.txt +1 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/entry_points.txt +2 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/requires.txt +9 -0
- crazy_workers-0.1.0/crazy_workers.egg-info/top_level.txt +1 -0
- crazy_workers-0.1.0/pyproject.toml +83 -0
- crazy_workers-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2024 GioVanni Colasanto
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crazy-workers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for managing background worker processes with persistent state, automatic recovery, and a CLI.
|
|
5
|
+
Author: GioVanni Colasanto
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Vanni-broUser/crazy-workers
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Vanni-broUser/crazy-workers/issues
|
|
9
|
+
Project-URL: Source, https://github.com/Vanni-broUser/crazy-workers
|
|
10
|
+
Keywords: workers,background,processes,process-manager,task-runner,cli,psutil
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
27
|
+
Requires-Dist: psutil>=5.9.0
|
|
28
|
+
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
|
+
Requires-Dist: coverage; extra == "dev"
|
|
33
|
+
Requires-Dist: flask; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Crazy Workers
|
|
37
|
+
|
|
38
|
+
A Python library for managing background worker processes with persistent state, automatic crash recovery, and a built-in CLI.
|
|
39
|
+
|
|
40
|
+
[](https://www.python.org/)
|
|
41
|
+
[](LICENSE)
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Persistent State** — SQLite database tracks worker status, PIDs, and parameters across restarts.
|
|
46
|
+
- **Process Management** — Start, stop, and monitor background Python scripts as independent OS processes.
|
|
47
|
+
- **Automatic Recovery** — Detects crashed workers and restarts them on application boot.
|
|
48
|
+
- **Child Process Control** — On stop, terminates unmanaged subprocesses while preserving independently-managed nested workers.
|
|
49
|
+
- **CLI Interface** — Manage workers from the terminal with interactive prompts and auto-discovery (see [CLI.md](CLI.md)).
|
|
50
|
+
- **Security** — Built-in protection against path traversal in worker type and key names.
|
|
51
|
+
- **Observability** — Per-worker file logging; all service files (DB, lock, logs) live in a `.service/` folder inside your workers directory.
|
|
52
|
+
- **Zombie Protection** — Distinguishes active processes from zombies using `psutil`.
|
|
53
|
+
- **Gunicorn-safe** — File-based lock prevents concurrent recovery runs across multiple workers.
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install crazy-workers
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or from source:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/Vanni-broUser/crazy-workers
|
|
65
|
+
cd crazy-workers
|
|
66
|
+
pip install .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
### 1. Create a worker script
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# workers/my_worker.py
|
|
75
|
+
import json, sys, time
|
|
76
|
+
|
|
77
|
+
params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
78
|
+
duration = params.get('duration', 60)
|
|
79
|
+
|
|
80
|
+
for _ in range(duration):
|
|
81
|
+
time.sleep(1)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Manage it from Python
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from crazy_workers import WorkerManager
|
|
88
|
+
|
|
89
|
+
manager = WorkerManager('workers')
|
|
90
|
+
|
|
91
|
+
# Start
|
|
92
|
+
success, result = manager.start_worker(
|
|
93
|
+
'my_worker',
|
|
94
|
+
worker_key='job_1',
|
|
95
|
+
parameters={'duration': 30},
|
|
96
|
+
)
|
|
97
|
+
print(result['pid']) # OS process ID
|
|
98
|
+
print(result['status']) # 'RUNNING'
|
|
99
|
+
|
|
100
|
+
# List
|
|
101
|
+
for w in manager.list_workers():
|
|
102
|
+
print(w['worker_key'], w['status'])
|
|
103
|
+
|
|
104
|
+
# Stop
|
|
105
|
+
manager.stop_worker('job_1')
|
|
106
|
+
|
|
107
|
+
# Recover crashed workers (call on app startup)
|
|
108
|
+
restarted = manager.recover_workers()
|
|
109
|
+
|
|
110
|
+
manager.dispose() # releases DB connection; does NOT kill workers
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. Or from the CLI
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
crazy-workers list
|
|
117
|
+
crazy-workers start my_worker --key job_1 --params '{"duration": 30}'
|
|
118
|
+
crazy-workers stop job_1
|
|
119
|
+
crazy-workers restore
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
See [CLI.md](CLI.md) for full CLI documentation.
|
|
123
|
+
|
|
124
|
+
## API Reference
|
|
125
|
+
|
|
126
|
+
### `WorkerManager(workers_dir, create_dir=True)`
|
|
127
|
+
|
|
128
|
+
| Parameter | Type | Default | Description |
|
|
129
|
+
|-----------|------|---------|-------------|
|
|
130
|
+
| `workers_dir` | `str` | `'workers'` | Directory containing worker `.py` scripts |
|
|
131
|
+
| `create_dir` | `bool` | `True` | Create `workers_dir` and `.service/` if they don't exist |
|
|
132
|
+
|
|
133
|
+
### `start_worker(worker_type, worker_key=None, parameters=None, env=None)`
|
|
134
|
+
|
|
135
|
+
| Parameter | Type | Default | Description |
|
|
136
|
+
|-----------|------|---------|-------------|
|
|
137
|
+
| `worker_type` | `str` | — | Filename (without `.py`) of the worker script |
|
|
138
|
+
| `worker_key` | `str` | `worker_type` | Unique identifier; allows multiple instances of the same type |
|
|
139
|
+
| `parameters` | `dict` | `{}` | JSON-serializable dict passed as `sys.argv[1]` to the worker |
|
|
140
|
+
| `env` | `dict` | `None` | Extra environment variables injected into the worker process |
|
|
141
|
+
|
|
142
|
+
Returns `(bool, dict | str)` — `(True, worker_dict)` on success, `(False, error_message)` on failure.
|
|
143
|
+
|
|
144
|
+
### `stop_worker(worker_key)`
|
|
145
|
+
|
|
146
|
+
Gracefully terminates the worker (SIGTERM → SIGKILL after timeout). Returns `(bool, str)`.
|
|
147
|
+
|
|
148
|
+
### `list_workers()`
|
|
149
|
+
|
|
150
|
+
Returns a list of worker dicts including RUNNING, STOPPED, CRASHED, and NEVER_STARTED (filesystem-discovered) workers.
|
|
151
|
+
|
|
152
|
+
### `recover_workers()`
|
|
153
|
+
|
|
154
|
+
Restarts any worker whose DB status is RUNNING but whose process is dead. Uses a file lock to prevent concurrent recovery. Returns a list of restarted keys.
|
|
155
|
+
|
|
156
|
+
### `dispose()`
|
|
157
|
+
|
|
158
|
+
Closes the database connection and clears internal process references. Does **not** kill background workers — they continue running independently.
|
|
159
|
+
|
|
160
|
+
## Worker Script Contract
|
|
161
|
+
|
|
162
|
+
A worker receives its parameters as a JSON string in `sys.argv[1]`:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import json, sys
|
|
166
|
+
|
|
167
|
+
params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
168
|
+
# ... do work ...
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Project Structure
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
crazy_workers/ # Library package
|
|
175
|
+
core/ # WorkerManager, process engine, recovery lock
|
|
176
|
+
cli/ # CLI entry point, commands, discovery
|
|
177
|
+
database/ # SQLAlchemy schema and SQLite storage
|
|
178
|
+
example_app/ # Flask demo application
|
|
179
|
+
app.py
|
|
180
|
+
workers/ # Example worker scripts
|
|
181
|
+
tests/
|
|
182
|
+
core/ # Unit tests for core modules
|
|
183
|
+
cli/ # Unit tests for CLI modules
|
|
184
|
+
database/ # Unit tests for storage layer
|
|
185
|
+
integration/ # Full-stack integration tests (real processes)
|
|
186
|
+
app/ # Tests for the example Flask app
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Flask Integration
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from crazy_workers import WorkerManager
|
|
193
|
+
|
|
194
|
+
def create_app():
|
|
195
|
+
app = Flask(__name__)
|
|
196
|
+
manager = WorkerManager('workers')
|
|
197
|
+
|
|
198
|
+
@app.route('/workers/start', methods=['POST'])
|
|
199
|
+
def start():
|
|
200
|
+
data = request.json
|
|
201
|
+
success, result = manager.start_worker(
|
|
202
|
+
data['worker_type'],
|
|
203
|
+
worker_key=data.get('worker_key'),
|
|
204
|
+
parameters=data.get('parameters', {}),
|
|
205
|
+
)
|
|
206
|
+
return (jsonify(result), 200) if success else (jsonify({'error': result}), 400)
|
|
207
|
+
|
|
208
|
+
manager.recover_workers() # restart any crashed workers on boot
|
|
209
|
+
return app
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
See `example_app/app.py` for a complete example.
|
|
213
|
+
|
|
214
|
+
## Gunicorn / Multi-Process Servers
|
|
215
|
+
|
|
216
|
+
When using a pre-fork server like Gunicorn:
|
|
217
|
+
|
|
218
|
+
- **Recovery is atomic** — a file lock (`.service/workers.db.recovery.lock`) ensures `recover_workers()` runs once even when multiple workers boot simultaneously.
|
|
219
|
+
- **Workers outlive their parent** — if a Gunicorn worker is recycled, background processes keep running. The next recovery cycle re-attaches or restarts them.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
### Setup
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git clone https://github.com/Vanni-broUser/crazy-workers
|
|
227
|
+
cd crazy-workers
|
|
228
|
+
pip install -e .[dev]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Commands
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# Lint and format
|
|
235
|
+
ruff check . --fix && ruff format .
|
|
236
|
+
|
|
237
|
+
# Run tests
|
|
238
|
+
pytest
|
|
239
|
+
|
|
240
|
+
# Run tests with coverage
|
|
241
|
+
coverage run -m pytest && coverage report
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Standards
|
|
245
|
+
|
|
246
|
+
See [AI.md](AI.md) for the full coding and testing standards used in this project.
|
|
247
|
+
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Crazy Workers
|
|
2
|
+
|
|
3
|
+
A Python library for managing background worker processes with persistent state, automatic crash recovery, and a built-in CLI.
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Persistent State** — SQLite database tracks worker status, PIDs, and parameters across restarts.
|
|
11
|
+
- **Process Management** — Start, stop, and monitor background Python scripts as independent OS processes.
|
|
12
|
+
- **Automatic Recovery** — Detects crashed workers and restarts them on application boot.
|
|
13
|
+
- **Child Process Control** — On stop, terminates unmanaged subprocesses while preserving independently-managed nested workers.
|
|
14
|
+
- **CLI Interface** — Manage workers from the terminal with interactive prompts and auto-discovery (see [CLI.md](CLI.md)).
|
|
15
|
+
- **Security** — Built-in protection against path traversal in worker type and key names.
|
|
16
|
+
- **Observability** — Per-worker file logging; all service files (DB, lock, logs) live in a `.service/` folder inside your workers directory.
|
|
17
|
+
- **Zombie Protection** — Distinguishes active processes from zombies using `psutil`.
|
|
18
|
+
- **Gunicorn-safe** — File-based lock prevents concurrent recovery runs across multiple workers.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install crazy-workers
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or from source:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/Vanni-broUser/crazy-workers
|
|
30
|
+
cd crazy-workers
|
|
31
|
+
pip install .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Create a worker script
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# workers/my_worker.py
|
|
40
|
+
import json, sys, time
|
|
41
|
+
|
|
42
|
+
params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
43
|
+
duration = params.get('duration', 60)
|
|
44
|
+
|
|
45
|
+
for _ in range(duration):
|
|
46
|
+
time.sleep(1)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Manage it from Python
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from crazy_workers import WorkerManager
|
|
53
|
+
|
|
54
|
+
manager = WorkerManager('workers')
|
|
55
|
+
|
|
56
|
+
# Start
|
|
57
|
+
success, result = manager.start_worker(
|
|
58
|
+
'my_worker',
|
|
59
|
+
worker_key='job_1',
|
|
60
|
+
parameters={'duration': 30},
|
|
61
|
+
)
|
|
62
|
+
print(result['pid']) # OS process ID
|
|
63
|
+
print(result['status']) # 'RUNNING'
|
|
64
|
+
|
|
65
|
+
# List
|
|
66
|
+
for w in manager.list_workers():
|
|
67
|
+
print(w['worker_key'], w['status'])
|
|
68
|
+
|
|
69
|
+
# Stop
|
|
70
|
+
manager.stop_worker('job_1')
|
|
71
|
+
|
|
72
|
+
# Recover crashed workers (call on app startup)
|
|
73
|
+
restarted = manager.recover_workers()
|
|
74
|
+
|
|
75
|
+
manager.dispose() # releases DB connection; does NOT kill workers
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Or from the CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
crazy-workers list
|
|
82
|
+
crazy-workers start my_worker --key job_1 --params '{"duration": 30}'
|
|
83
|
+
crazy-workers stop job_1
|
|
84
|
+
crazy-workers restore
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See [CLI.md](CLI.md) for full CLI documentation.
|
|
88
|
+
|
|
89
|
+
## API Reference
|
|
90
|
+
|
|
91
|
+
### `WorkerManager(workers_dir, create_dir=True)`
|
|
92
|
+
|
|
93
|
+
| Parameter | Type | Default | Description |
|
|
94
|
+
|-----------|------|---------|-------------|
|
|
95
|
+
| `workers_dir` | `str` | `'workers'` | Directory containing worker `.py` scripts |
|
|
96
|
+
| `create_dir` | `bool` | `True` | Create `workers_dir` and `.service/` if they don't exist |
|
|
97
|
+
|
|
98
|
+
### `start_worker(worker_type, worker_key=None, parameters=None, env=None)`
|
|
99
|
+
|
|
100
|
+
| Parameter | Type | Default | Description |
|
|
101
|
+
|-----------|------|---------|-------------|
|
|
102
|
+
| `worker_type` | `str` | — | Filename (without `.py`) of the worker script |
|
|
103
|
+
| `worker_key` | `str` | `worker_type` | Unique identifier; allows multiple instances of the same type |
|
|
104
|
+
| `parameters` | `dict` | `{}` | JSON-serializable dict passed as `sys.argv[1]` to the worker |
|
|
105
|
+
| `env` | `dict` | `None` | Extra environment variables injected into the worker process |
|
|
106
|
+
|
|
107
|
+
Returns `(bool, dict | str)` — `(True, worker_dict)` on success, `(False, error_message)` on failure.
|
|
108
|
+
|
|
109
|
+
### `stop_worker(worker_key)`
|
|
110
|
+
|
|
111
|
+
Gracefully terminates the worker (SIGTERM → SIGKILL after timeout). Returns `(bool, str)`.
|
|
112
|
+
|
|
113
|
+
### `list_workers()`
|
|
114
|
+
|
|
115
|
+
Returns a list of worker dicts including RUNNING, STOPPED, CRASHED, and NEVER_STARTED (filesystem-discovered) workers.
|
|
116
|
+
|
|
117
|
+
### `recover_workers()`
|
|
118
|
+
|
|
119
|
+
Restarts any worker whose DB status is RUNNING but whose process is dead. Uses a file lock to prevent concurrent recovery. Returns a list of restarted keys.
|
|
120
|
+
|
|
121
|
+
### `dispose()`
|
|
122
|
+
|
|
123
|
+
Closes the database connection and clears internal process references. Does **not** kill background workers — they continue running independently.
|
|
124
|
+
|
|
125
|
+
## Worker Script Contract
|
|
126
|
+
|
|
127
|
+
A worker receives its parameters as a JSON string in `sys.argv[1]`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import json, sys
|
|
131
|
+
|
|
132
|
+
params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
|
133
|
+
# ... do work ...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Project Structure
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
crazy_workers/ # Library package
|
|
140
|
+
core/ # WorkerManager, process engine, recovery lock
|
|
141
|
+
cli/ # CLI entry point, commands, discovery
|
|
142
|
+
database/ # SQLAlchemy schema and SQLite storage
|
|
143
|
+
example_app/ # Flask demo application
|
|
144
|
+
app.py
|
|
145
|
+
workers/ # Example worker scripts
|
|
146
|
+
tests/
|
|
147
|
+
core/ # Unit tests for core modules
|
|
148
|
+
cli/ # Unit tests for CLI modules
|
|
149
|
+
database/ # Unit tests for storage layer
|
|
150
|
+
integration/ # Full-stack integration tests (real processes)
|
|
151
|
+
app/ # Tests for the example Flask app
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Flask Integration
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from crazy_workers import WorkerManager
|
|
158
|
+
|
|
159
|
+
def create_app():
|
|
160
|
+
app = Flask(__name__)
|
|
161
|
+
manager = WorkerManager('workers')
|
|
162
|
+
|
|
163
|
+
@app.route('/workers/start', methods=['POST'])
|
|
164
|
+
def start():
|
|
165
|
+
data = request.json
|
|
166
|
+
success, result = manager.start_worker(
|
|
167
|
+
data['worker_type'],
|
|
168
|
+
worker_key=data.get('worker_key'),
|
|
169
|
+
parameters=data.get('parameters', {}),
|
|
170
|
+
)
|
|
171
|
+
return (jsonify(result), 200) if success else (jsonify({'error': result}), 400)
|
|
172
|
+
|
|
173
|
+
manager.recover_workers() # restart any crashed workers on boot
|
|
174
|
+
return app
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
See `example_app/app.py` for a complete example.
|
|
178
|
+
|
|
179
|
+
## Gunicorn / Multi-Process Servers
|
|
180
|
+
|
|
181
|
+
When using a pre-fork server like Gunicorn:
|
|
182
|
+
|
|
183
|
+
- **Recovery is atomic** — a file lock (`.service/workers.db.recovery.lock`) ensures `recover_workers()` runs once even when multiple workers boot simultaneously.
|
|
184
|
+
- **Workers outlive their parent** — if a Gunicorn worker is recycled, background processes keep running. The next recovery cycle re-attaches or restarts them.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
### Setup
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
git clone https://github.com/Vanni-broUser/crazy-workers
|
|
192
|
+
cd crazy-workers
|
|
193
|
+
pip install -e .[dev]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Commands
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# Lint and format
|
|
200
|
+
ruff check . --fix && ruff format .
|
|
201
|
+
|
|
202
|
+
# Run tests
|
|
203
|
+
pytest
|
|
204
|
+
|
|
205
|
+
# Run tests with coverage
|
|
206
|
+
coverage run -m pytest && coverage report
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Standards
|
|
210
|
+
|
|
211
|
+
See [AI.md](AI.md) for the full coding and testing standards used in this project.
|
|
212
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin launcher invoked by WorkerManager for every worker subprocess.
|
|
3
|
+
Configures logging once so individual worker scripts don't have to.
|
|
4
|
+
|
|
5
|
+
Invocation (managed internally by WorkerManager):
|
|
6
|
+
python -m crazy_workers._bootstrap <worker_path> <json_params>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import runpy
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
19
|
+
stream=sys.stderr,
|
|
20
|
+
force=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Restore sys.argv so the worker sees [worker_path, json_params]
|
|
24
|
+
sys.argv = sys.argv[1:]
|
|
25
|
+
|
|
26
|
+
worker_path = sys.argv[0]
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(worker_path)))
|
|
28
|
+
|
|
29
|
+
runpy.run_path(worker_path, run_name='__main__')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == '__main__':
|
|
33
|
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .lister import list_workers
|
|
2
|
+
from .params import show_params
|
|
3
|
+
from .restorer import restore_workers
|
|
4
|
+
from .starter import start_worker
|
|
5
|
+
from .stopper import stop_worker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ['list_workers', 'show_params', 'start_worker', 'stop_worker', 'restore_workers']
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from ..ui import console
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def list_workers(manager):
|
|
9
|
+
workers = manager.list_workers()
|
|
10
|
+
if not workers:
|
|
11
|
+
console().print('[yellow]No workers found in database.[/yellow]')
|
|
12
|
+
return []
|
|
13
|
+
else:
|
|
14
|
+
table = Table(
|
|
15
|
+
title='[bold cyan]Active & Registered Workers[/bold cyan]', border_style='cyan', header_style='bold magenta'
|
|
16
|
+
)
|
|
17
|
+
table.add_column('#', justify='right', style='dim')
|
|
18
|
+
table.add_column('Key', style='bold')
|
|
19
|
+
table.add_column('Type')
|
|
20
|
+
table.add_column('Status', justify='center')
|
|
21
|
+
table.add_column('PID', justify='right', style='green')
|
|
22
|
+
table.add_column('Last Action', justify='center')
|
|
23
|
+
table.add_column('Params', overflow='ellipsis')
|
|
24
|
+
|
|
25
|
+
for i, w in enumerate(workers, 1):
|
|
26
|
+
status = w['status']
|
|
27
|
+
status_style = 'green' if status == 'RUNNING' else 'yellow'
|
|
28
|
+
if status in ['CRASHED', 'FAILED']:
|
|
29
|
+
status_style = 'bold red'
|
|
30
|
+
elif status == 'STOPPED':
|
|
31
|
+
status_style = 'dim'
|
|
32
|
+
elif status == 'NEVER_STARTED':
|
|
33
|
+
status_style = 'cyan'
|
|
34
|
+
|
|
35
|
+
last_action = '-'
|
|
36
|
+
if status == 'RUNNING' and w.get('last_started_at'):
|
|
37
|
+
dt = datetime.fromisoformat(w['last_started_at'])
|
|
38
|
+
last_action = f'[green]Started {dt.strftime("%H:%M:%S")}[/green]'
|
|
39
|
+
elif w.get('last_stopped_at'):
|
|
40
|
+
dt = datetime.fromisoformat(w['last_stopped_at'])
|
|
41
|
+
last_action = f'[dim]Stopped {dt.strftime("%H:%M:%S")}[/dim]'
|
|
42
|
+
|
|
43
|
+
params_str = json.dumps(w['parameters']) if w['parameters'] else '-'
|
|
44
|
+
if len(params_str) > 30:
|
|
45
|
+
params_str = params_str[:27] + '...'
|
|
46
|
+
|
|
47
|
+
table.add_row(
|
|
48
|
+
str(i),
|
|
49
|
+
w['worker_key'] or '-',
|
|
50
|
+
w['worker_type'],
|
|
51
|
+
f'[{status_style}]{status}[/{status_style}]',
|
|
52
|
+
str(w['pid']) if w['pid'] else '-',
|
|
53
|
+
last_action,
|
|
54
|
+
params_str,
|
|
55
|
+
)
|
|
56
|
+
console().print(table)
|
|
57
|
+
return workers
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from rich.prompt import IntPrompt
|
|
3
|
+
|
|
4
|
+
from ..ui import console, err_console
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def show_params(manager, worker_key):
|
|
8
|
+
|
|
9
|
+
workers = manager.list_workers()
|
|
10
|
+
if not workers:
|
|
11
|
+
console().print('[yellow]No workers found.[/yellow]')
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
if not worker_key:
|
|
15
|
+
# Interactive mode
|
|
16
|
+
active_workers = [w for w in workers if w['worker_key'] is not None]
|
|
17
|
+
|
|
18
|
+
if not active_workers:
|
|
19
|
+
console().print('[yellow]No registered workers to show parameters for.[/yellow]')
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
console().print('\n[bold cyan]Select a worker to show parameters:[/bold cyan]')
|
|
23
|
+
for i, w in enumerate(active_workers, 1):
|
|
24
|
+
status_style = 'green' if w['status'] == 'RUNNING' else 'dim'
|
|
25
|
+
console().print(f' [bold]{i})[/bold] {w["worker_key"]} [{status_style}]({w["status"]})[/{status_style}]')
|
|
26
|
+
|
|
27
|
+
choice = IntPrompt.ask('Enter the number', choices=[str(i) for i in range(1, len(active_workers) + 1)])
|
|
28
|
+
selected_worker = active_workers[choice - 1]
|
|
29
|
+
else:
|
|
30
|
+
selected_worker = next((w for w in workers if w['worker_key'] == worker_key), None)
|
|
31
|
+
if not selected_worker:
|
|
32
|
+
err_console().print(f'[bold red]Error:[/bold red] Worker {worker_key} not found')
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
console().print(f'\n[bold cyan]Parameters for worker:[/bold cyan] {selected_worker["worker_key"]}')
|
|
36
|
+
console().print_json(json.dumps(selected_worker['parameters']))
|
|
37
|
+
return True
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from ..ui import console
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def restore_workers(manager):
|
|
5
|
+
restarted = manager.recover_workers()
|
|
6
|
+
|
|
7
|
+
if restarted:
|
|
8
|
+
console().print(f'[bold green]Successfully restored {len(restarted)} workers:[/bold green]')
|
|
9
|
+
for key in restarted:
|
|
10
|
+
console().print(f' - {key}')
|
|
11
|
+
return True
|
|
12
|
+
else:
|
|
13
|
+
console().print('[yellow]No workers needed restoration.[/yellow]')
|
|
14
|
+
return True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from rich.prompt import IntPrompt
|
|
3
|
+
|
|
4
|
+
from ..ui import console, err_console
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def start_worker(manager, worker_type, worker_key=None, parameters=None):
|
|
8
|
+
|
|
9
|
+
if not worker_type:
|
|
10
|
+
# Interactive mode: list .py files in workers_dir
|
|
11
|
+
try:
|
|
12
|
+
files = [f[:-3] for f in os.listdir(manager.workers_dir) if f.endswith('.py')]
|
|
13
|
+
except Exception as e:
|
|
14
|
+
err_console().print(f'[bold red]Error reading workers directory:[/bold red] {e}')
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
if not files:
|
|
18
|
+
console().print(f'[yellow]No worker scripts found in {manager.workers_dir}[/yellow]')
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
console().print('\n[bold cyan]Select a worker type to start:[/bold cyan]')
|
|
22
|
+
for i, f in enumerate(files, 1):
|
|
23
|
+
console().print(f' [bold]{i})[/bold] {f}')
|
|
24
|
+
|
|
25
|
+
choice = IntPrompt.ask('Enter the number', choices=[str(i) for i in range(1, len(files) + 1)])
|
|
26
|
+
worker_type = files[choice - 1]
|
|
27
|
+
|
|
28
|
+
success, result = manager.start_worker(worker_type, worker_key=worker_key, parameters=parameters)
|
|
29
|
+
if success:
|
|
30
|
+
console().print('[bold green]Success:[/bold green] Worker started')
|
|
31
|
+
console().print(f' [bold]Key:[/bold] {result["worker_key"]}')
|
|
32
|
+
console().print(f' [bold]PID:[/bold] {result["pid"]}')
|
|
33
|
+
else:
|
|
34
|
+
err_console().print(f'[bold red]Error:[/bold red] {result}')
|
|
35
|
+
return False
|
|
36
|
+
return True
|