celery-liveops 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.
- celery_liveops-0.1.0/.devcontainer/devcontainer.json +28 -0
- celery_liveops-0.1.0/.github/workflows/ci.yml +46 -0
- celery_liveops-0.1.0/.github/workflows/demo.yml +73 -0
- celery_liveops-0.1.0/.github/workflows/release.yml +42 -0
- celery_liveops-0.1.0/.gitignore +14 -0
- celery_liveops-0.1.0/CHANGELOG.md +27 -0
- celery_liveops-0.1.0/LICENSE +21 -0
- celery_liveops-0.1.0/PKG-INFO +315 -0
- celery_liveops-0.1.0/README.md +277 -0
- celery_liveops-0.1.0/demo/Dockerfile +14 -0
- celery_liveops-0.1.0/demo/README.md +51 -0
- celery_liveops-0.1.0/demo/app/__init__.py +0 -0
- celery_liveops-0.1.0/demo/app/api.py +88 -0
- celery_liveops-0.1.0/demo/app/tasks.py +161 -0
- celery_liveops-0.1.0/demo/capture.py +168 -0
- celery_liveops-0.1.0/demo/docker-compose.yml +71 -0
- celery_liveops-0.1.0/demo/static/index.html +244 -0
- celery_liveops-0.1.0/docs/design-notes.md +232 -0
- celery_liveops-0.1.0/docs/screenshots/01-live.png +0 -0
- celery_liveops-0.1.0/docs/screenshots/02-no-signal.png +0 -0
- celery_liveops-0.1.0/docs/screenshots/03-orphan-lock.png +0 -0
- celery_liveops-0.1.0/pyproject.toml +65 -0
- celery_liveops-0.1.0/src/celery_liveops/__init__.py +205 -0
- celery_liveops-0.1.0/src/celery_liveops/config.py +166 -0
- celery_liveops-0.1.0/src/celery_liveops/contrib/__init__.py +1 -0
- celery_liveops-0.1.0/src/celery_liveops/contrib/fastapi.py +119 -0
- celery_liveops-0.1.0/src/celery_liveops/locks.py +258 -0
- celery_liveops-0.1.0/src/celery_liveops/logs.py +274 -0
- celery_liveops-0.1.0/src/celery_liveops/presence.py +341 -0
- celery_liveops-0.1.0/src/celery_liveops/py.typed +0 -0
- celery_liveops-0.1.0/src/celery_liveops/queues.py +156 -0
- celery_liveops-0.1.0/src/celery_liveops/scale.py +146 -0
- celery_liveops-0.1.0/src/celery_liveops/snapshots.py +190 -0
- celery_liveops-0.1.0/src/celery_liveops/store.py +86 -0
- celery_liveops-0.1.0/src/celery_liveops/watchdog.py +198 -0
- celery_liveops-0.1.0/tests/conftest.py +44 -0
- celery_liveops-0.1.0/tests/test_demo_smoke.py +98 -0
- celery_liveops-0.1.0/tests/test_locks.py +132 -0
- celery_liveops-0.1.0/tests/test_logs.py +159 -0
- celery_liveops-0.1.0/tests/test_presence.py +116 -0
- celery_liveops-0.1.0/tests/test_queues_and_scale.py +180 -0
- celery_liveops-0.1.0/tests/test_snapshots_and_api.py +219 -0
- celery_liveops-0.1.0/tests/test_watchdog.py +116 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "celery-liveops",
|
|
3
|
+
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
|
4
|
+
|
|
5
|
+
// The demo is a compose stack, so the container needs its own Docker.
|
|
6
|
+
"features": {
|
|
7
|
+
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
"forwardPorts": [8000, 15672],
|
|
11
|
+
"portsAttributes": {
|
|
12
|
+
"8000": { "label": "Demo panel", "onAutoForward": "openPreview" },
|
|
13
|
+
"15672": { "label": "RabbitMQ management" }
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
// Dev dependencies for the test suite; the demo itself runs in compose.
|
|
17
|
+
"postCreateCommand": "pip install -e '.[dev]'",
|
|
18
|
+
|
|
19
|
+
// Building the four images takes a couple of minutes on a cold Codespace;
|
|
20
|
+
// starting it here means the panel is ready by the time the editor is.
|
|
21
|
+
"postAttachCommand": "docker compose -f demo/docker-compose.yml up -d --build",
|
|
22
|
+
|
|
23
|
+
"customizations": {
|
|
24
|
+
"vscode": {
|
|
25
|
+
"extensions": ["ms-python.python", "charliermarsh.ruff"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
# Every commit on every branch: a push is exactly when you want to know.
|
|
5
|
+
push:
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python }}
|
|
23
|
+
|
|
24
|
+
- name: Install
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install -e ".[dev]"
|
|
28
|
+
|
|
29
|
+
- name: Lint
|
|
30
|
+
run: ruff check .
|
|
31
|
+
|
|
32
|
+
- name: Test
|
|
33
|
+
# fakeredis stands in for a server: the contract under test is what
|
|
34
|
+
# happens when Redis misbehaves, and a fake can stage that on demand.
|
|
35
|
+
run: pytest --cov=celery_liveops --cov-report=term-missing
|
|
36
|
+
|
|
37
|
+
build:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
- uses: actions/setup-python@v5
|
|
42
|
+
with:
|
|
43
|
+
python-version: "3.12"
|
|
44
|
+
- run: pip install build twine
|
|
45
|
+
- run: python -m build
|
|
46
|
+
- run: twine check dist/*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Runs the demo stack for real and photographs it.
|
|
2
|
+
#
|
|
3
|
+
# This is a test as much as a screenshot job: it builds the compose stack, starts
|
|
4
|
+
# actual work, stops the worker container mid-run and asserts the panel notices.
|
|
5
|
+
# If presence, the live log buffer or the lock catalogue break, this fails --
|
|
6
|
+
# which is more than the unit suite can claim, since it runs against fakeredis.
|
|
7
|
+
#
|
|
8
|
+
# Screenshots are always uploaded as a build artifact, and committed back to
|
|
9
|
+
# docs/screenshots (which is what the README shows) on main. The paths filter is
|
|
10
|
+
# what keeps that honest: the log timestamps baked into each image differ on
|
|
11
|
+
# every run, so without it every unrelated push would produce a commit.
|
|
12
|
+
name: Demo
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [main]
|
|
17
|
+
paths: ["demo/**", "src/**", ".github/workflows/demo.yml"]
|
|
18
|
+
pull_request:
|
|
19
|
+
paths: ["demo/**", "src/**", ".github/workflows/demo.yml"]
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: write
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: demo-${{ github.ref }}
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
capture:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
timeout-minutes: 25
|
|
33
|
+
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
|
|
37
|
+
- uses: actions/setup-python@v5
|
|
38
|
+
with:
|
|
39
|
+
python-version: "3.12"
|
|
40
|
+
|
|
41
|
+
- name: Install the capture tooling
|
|
42
|
+
run: |
|
|
43
|
+
pip install playwright httpx
|
|
44
|
+
playwright install --with-deps chromium
|
|
45
|
+
|
|
46
|
+
- name: Start the stack
|
|
47
|
+
run: docker compose -f demo/docker-compose.yml up -d --build
|
|
48
|
+
|
|
49
|
+
- name: Drive it and capture
|
|
50
|
+
run: python demo/capture.py --out-dir docs/screenshots
|
|
51
|
+
|
|
52
|
+
- uses: actions/upload-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
name: demo-screenshots
|
|
55
|
+
path: docs/screenshots/*.png
|
|
56
|
+
|
|
57
|
+
- name: Commit the screenshots
|
|
58
|
+
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
|
59
|
+
run: |
|
|
60
|
+
git config user.name "github-actions[bot]"
|
|
61
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
62
|
+
git add docs/screenshots
|
|
63
|
+
git diff --staged --quiet && echo "nothing changed" && exit 0
|
|
64
|
+
git commit -m "Atualiza os prints do demo [skip ci]"
|
|
65
|
+
git push
|
|
66
|
+
|
|
67
|
+
- name: Container logs
|
|
68
|
+
if: failure()
|
|
69
|
+
run: docker compose -f demo/docker-compose.yml logs --tail=200
|
|
70
|
+
|
|
71
|
+
- name: Tear down
|
|
72
|
+
if: always()
|
|
73
|
+
run: docker compose -f demo/docker-compose.yml down -v
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Publishes to PyPI on a v* tag, using Trusted Publishing (OIDC) -- no API token
|
|
2
|
+
# stored in this repository.
|
|
3
|
+
#
|
|
4
|
+
# One-time setup on PyPI: Project -> Publishing -> add a GitHub publisher for
|
|
5
|
+
# this repo, workflow `release.yml`, environment `pypi`.
|
|
6
|
+
name: Release
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
# Manual escape hatch: a tag whose commit the runner skipped (a bot commit
|
|
12
|
+
# carrying the skip marker, say) never fires the push event, and the version
|
|
13
|
+
# number is already spent. This republishes the same ref without a new tag.
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
- run: pip install build
|
|
25
|
+
- run: python -m build
|
|
26
|
+
- uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: pypi
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write # required for Trusted Publishing
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/download-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-01
|
|
8
|
+
|
|
9
|
+
First public release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **logs** - per-run live terminal over a capped Redis list, with the previous
|
|
14
|
+
attempt's buffer archived rather than deleted on retry (`read_orphan_logs`).
|
|
15
|
+
- **presence** - per-run and per-worker liveness written from Celery signals,
|
|
16
|
+
read with `EXISTS`; `alive_among` answers for a whole page in one round trip.
|
|
17
|
+
- **watchdog** - a hard deadline registered per task name, so one pool serves
|
|
18
|
+
both short and overnight jobs; `safe_stop_at` for cooperative stopping.
|
|
19
|
+
- **locks** - a named catalogue of the Redis locks a dead process leaves behind,
|
|
20
|
+
with an allowlist on release and automatic release limited to singleton locks.
|
|
21
|
+
- **queues** - `queue_depth`, `consumers_by_queue`, `has_consumer` and
|
|
22
|
+
`orphan_queues`, each failing in the direction its caller needs.
|
|
23
|
+
- **scale** - runtime pool resize, autoscaler ceiling, and boot-time
|
|
24
|
+
reapplication of a persisted target.
|
|
25
|
+
- **snapshots** - throttled, background-stored last frame of a browser task.
|
|
26
|
+
- `contrib.fastapi.liveops_router`, which refuses to mount without auth.
|
|
27
|
+
- A four-container demo stack under `demo/`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joao Pedro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: celery-liveops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See inside a long-running Celery task while it runs: live logs, presence, per-task deadlines, orphan locks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/joaopalmeidao/celery-liveops
|
|
6
|
+
Project-URL: Issues, https://github.com/joaopalmeidao/celery-liveops/issues
|
|
7
|
+
Author: Joao Pedro
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: celery,logs,observability,redis,selenium,watchdog,worker
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
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 :: System :: Distributed Computing
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: redis>=4.2
|
|
25
|
+
Provides-Extra: celery
|
|
26
|
+
Requires-Dist: celery>=5.2; extra == 'celery'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: celery>=5.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: fakeredis>=2.20; extra == 'dev'
|
|
30
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
31
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
35
|
+
Provides-Extra: fastapi
|
|
36
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# celery-liveops
|
|
40
|
+
|
|
41
|
+
[](https://github.com/joaopalmeidao/celery-liveops/actions/workflows/ci.yml)
|
|
42
|
+
[](https://github.com/joaopalmeidao/celery-liveops/actions/workflows/demo.yml)
|
|
43
|
+
[](https://pypi.org/project/celery-liveops/)
|
|
44
|
+
[](https://pypi.org/project/celery-liveops/)
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
|
|
47
|
+
**See inside a long-running Celery task while it runs.**
|
|
48
|
+
|
|
49
|
+
A task that takes eight hours is a black box. The result backend tells you what
|
|
50
|
+
happened *after* it happened. Flower tells you a task is running — not what it is
|
|
51
|
+
doing, not whether the process behind it is still breathing, and not why the next
|
|
52
|
+
one exits instantly with "already running".
|
|
53
|
+
|
|
54
|
+
`celery-liveops` is the small set of pieces that answer those questions. They
|
|
55
|
+
come from a production system running headless-browser automations: hundreds of
|
|
56
|
+
runs a day, from 15-second jobs to overnight sweeps, on workers that must be
|
|
57
|
+
restarted without losing evidence.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from celery_liveops import install, capture_logs
|
|
61
|
+
|
|
62
|
+
install(watchdog={"crawl": 3600}, watchdog_enabled=True)
|
|
63
|
+
|
|
64
|
+
@app.task(bind=True)
|
|
65
|
+
def crawl(self, url):
|
|
66
|
+
with capture_logs(self.request.id):
|
|
67
|
+
log.info("fetching %s", url) # readable live, from your API process
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from celery_liveops import read_any_logs, is_alive
|
|
72
|
+
|
|
73
|
+
read_any_logs(task_id) # the terminal — live, or the archived previous attempt
|
|
74
|
+
is_alive(task_id) # ...or is this "running" row an orphan?
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## What is in the box
|
|
80
|
+
|
|
81
|
+
| Module | Answers |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **logs** | What is this task printing *right now*? And what did the attempt that died print? |
|
|
84
|
+
| **presence** | Does this "running" row still have a living process behind it? Which workers are up? |
|
|
85
|
+
| **watchdog** | A hard deadline **per task**, not per container — so one pool serves a 15-minute job and an 8-hour one. |
|
|
86
|
+
| **locks** | Which Redis locks did a dead process leave behind, and who can safely release them? |
|
|
87
|
+
| **queues** | Queue depth, who consumes what, and the declared queue **nobody** is consuming. |
|
|
88
|
+
| **scale** | Resize a running worker; make that size survive a restart. |
|
|
89
|
+
| **snapshots** | A live frame of what a browser task is looking at. |
|
|
90
|
+
|
|
91
|
+
One rule runs through all of it: **observability must never break the job it is
|
|
92
|
+
observing.** Every Redis call is wrapped, every failure degrades the panel rather
|
|
93
|
+
than the work. A Redis outage greys out a badge; it does not fail a task.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install celery-liveops # core (redis)
|
|
101
|
+
pip install "celery-liveops[celery]" # + signal wiring
|
|
102
|
+
pip install "celery-liveops[fastapi]" # + the ready-made router
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Python 3.9+. Redis 5+. Celery is optional — importing this in a web-only process
|
|
106
|
+
is harmless.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## The five things it does
|
|
111
|
+
|
|
112
|
+
### 1. Live logs, across process boundaries
|
|
113
|
+
|
|
114
|
+
The worker writes, your API reads, and they are in different containers. The
|
|
115
|
+
transport is a capped Redis list (`RPUSH` + `LTRIM`) — exactly the shape a
|
|
116
|
+
scrolling terminal wants.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
install_logging(logging.getLogger("myapp")) # one handler, once
|
|
120
|
+
|
|
121
|
+
with capture_logs(task_id):
|
|
122
|
+
... # every log line in this block is captured
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The handler is a **cheap no-op outside a run**, so the same logging setup serves
|
|
126
|
+
your API, your beat process and your workers.
|
|
127
|
+
|
|
128
|
+
**The part worth stealing:** Celery keeps the task id across retries. When a
|
|
129
|
+
process dies without running its `finally` — OOM, a watchdog, a deploy mid-run —
|
|
130
|
+
that attempt's log exists nowhere but Redis, and the next attempt used to start
|
|
131
|
+
by deleting it. Here it is *renamed* instead:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
read_orphan_logs(task_id) # what the attempt that died was doing
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Your reaper can archive that against the failed run before closing it. The only
|
|
138
|
+
evidence of the crash survives the retry.
|
|
139
|
+
|
|
140
|
+
### 2. Presence — is anybody home?
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
is_alive(task_id) # one run
|
|
144
|
+
alive_among(page_of_task_ids) # a whole table, one round trip
|
|
145
|
+
workers() # every worker heartbeating right now
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Written from Celery's `task_prerun`/`task_postrun` signals plus a daemon thread
|
|
149
|
+
that refreshes while the task runs. Readers only ever do `EXISTS` — no
|
|
150
|
+
`celery inspect`, which is slow and times out precisely when you need it, in an
|
|
151
|
+
endpoint being polled every three seconds. Every key has a TTL, so a dying
|
|
152
|
+
process cleans up after itself.
|
|
153
|
+
|
|
154
|
+
### 3. A deadline that belongs to the task
|
|
155
|
+
|
|
156
|
+
Celery enforces `task_time_limit` by killing the *child* process of a prefork
|
|
157
|
+
pool. With `--pool=solo` there is no child, so it never fires: a worker stuck on
|
|
158
|
+
one task holds its queue until somebody restarts it by hand.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
install_watchdog(
|
|
162
|
+
deadlines={"quick.petition": 900, "overnight.sweep": 28800},
|
|
163
|
+
enabled=True,
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
When the timeout is an environment variable it is a property of the *container*,
|
|
168
|
+
and every duration profile needs its own service — a pool of identical replicas
|
|
169
|
+
can never serve both, because the long run dies halfway through in the short
|
|
170
|
+
container. Registered per task name, one fungible pool serves every profile.
|
|
171
|
+
|
|
172
|
+
Cooperative stopping is better whenever the task can manage it:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
stop_by = safe_stop_at(task_name="crawl") # 90% of the budget
|
|
176
|
+
for page in pages:
|
|
177
|
+
if datetime.now() >= stop_by:
|
|
178
|
+
save_checkpoint(page) # stop clean, resume later
|
|
179
|
+
break
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The watchdog is off unless you turn it on. A library that kills processes has to
|
|
183
|
+
be opted into, never switched on by the act of installing it.
|
|
184
|
+
|
|
185
|
+
### 4. The locks a dead process left behind
|
|
186
|
+
|
|
187
|
+
A key taken to serialise work that is not reentrant — one login per account, one
|
|
188
|
+
scrape per catalogue — is released in a `finally`. When the process never reaches
|
|
189
|
+
that `finally`, the key stays and keeps blocking new work until its TTL expires.
|
|
190
|
+
|
|
191
|
+
The symptom is miserable: nothing shows an error. The trigger "works", the task
|
|
192
|
+
exits with "already running", the screen says nothing.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
register_lock(
|
|
196
|
+
pattern="lock:login:*",
|
|
197
|
+
label="Login (one session per account)",
|
|
198
|
+
blocks="Other runs on the same account queue behind the login.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
list_locks() # what is held, and for how long
|
|
202
|
+
release_locks(["lock:login:42"]) # allowlist-checked
|
|
203
|
+
lock_state("lock:login:42") # why this click will not run anything
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Two guarantees: **only registered patterns can be released** (the raw key
|
|
207
|
+
arrives from a browser — without the allowlist, an arbitrary `DEL` against
|
|
208
|
+
production Redis is one POST away), and **no guessing** — automatic release is
|
|
209
|
+
limited to singleton locks that unambiguously belong to the run being killed,
|
|
210
|
+
because freeing a *live* run's lock is worse than the problem.
|
|
211
|
+
|
|
212
|
+
### 5. The queue nobody is consuming
|
|
213
|
+
|
|
214
|
+
The quietest failure a Celery deployment has: the message is published, the
|
|
215
|
+
enqueue returns success, the screen says "queued" — and nothing ever runs it,
|
|
216
|
+
forever. It happens the day a service is commented out of the compose file.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
orphan_queues() # declared, but nobody consuming
|
|
220
|
+
queue_depth("fast") # None means "could not ask" — NOT zero
|
|
221
|
+
has_consumer("fast") # True when it cannot tell — fail-fast callers
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Those two failure modes point in opposite directions on purpose. A reaper
|
|
225
|
+
deciding whether a pending item was abandoned must distinguish "the queue is
|
|
226
|
+
empty" from "I could not ask", or it invents failures that never happened. A
|
|
227
|
+
caller checking before it enqueues would rather wait than refuse real work
|
|
228
|
+
because the broker was slow.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## The panel, in one line
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from fastapi import Depends, FastAPI
|
|
236
|
+
from celery_liveops.contrib.fastapi import liveops_router
|
|
237
|
+
|
|
238
|
+
app.include_router(liveops_router(dependencies=[Depends(require_operator)]))
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`GET /liveops/runs/{id}/logs`, `/snapshot`, `POST /runs/alive`, `GET /workers`,
|
|
242
|
+
`/locks`, `POST /locks/release`, `GET /queues`.
|
|
243
|
+
|
|
244
|
+
It exposes task output, screenshots and a lock release, so it **refuses to be
|
|
245
|
+
mounted** without either `dependencies=[...]` or an explicit `public=True`.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Try it
|
|
250
|
+
|
|
251
|
+
A working stack — Redis, RabbitMQ, a worker, a FastAPI panel — is in
|
|
252
|
+
[`demo/`](demo/). In the browser, no install:
|
|
253
|
+
|
|
254
|
+
[](https://codespaces.new/joaopalmeidao/celery-liveops)
|
|
255
|
+
|
|
256
|
+
Or locally:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
docker compose -f demo/docker-compose.yml up --build
|
|
260
|
+
# open http://localhost:8000
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Start a task, watch its terminal stream, kill the worker mid-run and watch the
|
|
264
|
+
row go from *alive* to *no signal* while the orphan log survives.
|
|
265
|
+
|
|
266
|
+
### These screenshots are generated, not staged
|
|
267
|
+
|
|
268
|
+
The [Demo workflow](.github/workflows/demo.yml) builds the stack on every change,
|
|
269
|
+
starts real work, **stops the worker container mid-run** and captures the panel.
|
|
270
|
+
So it is a test as much as a picture: if presence, the live buffer or the lock
|
|
271
|
+
catalogue break, it fails — which the unit suite cannot claim, running as it does
|
|
272
|
+
against `fakeredis`.
|
|
273
|
+
|
|
274
|
+
| A task streaming | Its worker stopped | The lock it left behind |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
|  |  |  |
|
|
277
|
+
|
|
278
|
+
Reproduce it yourself against a running stack:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pip install playwright httpx && playwright install chromium
|
|
282
|
+
python demo/capture.py --out-dir docs/screenshots
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Configuration
|
|
288
|
+
|
|
289
|
+
Everything has a working default; nothing needs configuring to try it.
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
configure(redis_url="redis://cache:6379/2", key_prefix="billing", max_lines=500)
|
|
293
|
+
configure(redis_client=my_existing_client) # bring your own connection
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Or by environment: `LIVEOPS_REDIS_URL`, `LIVEOPS_KEY_PREFIX`, `LIVEOPS_MAX_LINES`,
|
|
297
|
+
`LIVEOPS_LOG_TTL`, `LIVEOPS_PRESENCE_TTL`, `LIVEOPS_WATCHDOG_ENABLED`,
|
|
298
|
+
`LIVEOPS_DEFAULT_DEADLINE`, `LIVEOPS_SNAPSHOT_TTL`, `LIVEOPS_MAX_CONCURRENCY`.
|
|
299
|
+
|
|
300
|
+
See [`docs/design-notes.md`](docs/design-notes.md) for the reasoning behind the
|
|
301
|
+
less obvious choices.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Development
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
uv venv && uv pip install -e ".[dev]"
|
|
309
|
+
uv run pytest
|
|
310
|
+
uv run ruff check .
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT
|