percival 0.1.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.
percival/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """Client side of Percival. Installed in the Celery project, not in the panel.
2
+
3
+ import percival
4
+ percival.init(app, redis_url="redis://...", code_version=os.environ["GIT_SHA"])
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ from percival.config import UNKNOWN_VERSION, Settings
15
+ from percival.dedupe import once_per
16
+ from percival.runtime import ClientFactory, Runtime
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ __all__ = ["Settings", "__version__", "init", "once_per", "shutdown"]
21
+
22
+
23
+ def init(
24
+ app: Any,
25
+ redis_url: str | None = None,
26
+ code_version: str | None = None,
27
+ *,
28
+ client_factory: ClientFactory | None = None,
29
+ **options: Any,
30
+ ) -> Runtime:
31
+ """Wire percival into a celery app. Safe to call more than once.
32
+
33
+ Any field of :class:`percival.Settings` can be passed as a keyword, e.g.
34
+ ``redact_kwargs=[...]``, ``stale_action="drop"``, ``capture_args=False``.
35
+ ``client_factory`` exists for tests, you shouldn't need it.
36
+ """
37
+ # imported here so `import percival` stays cheap for things like the CLI
38
+ from percival import gate, heartbeat, metadata, runtime, signals
39
+
40
+ settings = Settings.build(redis_url=redis_url, code_version=code_version, **options)
41
+ if settings.code_version == UNKNOWN_VERSION:
42
+ log.warning(
43
+ "percival: no code_version set. pass code_version= or set PERCIVAL_CODE_VERSION, "
44
+ "otherwise schedules that require a version will be blocked on this worker"
45
+ )
46
+
47
+ rt = runtime.configure(settings, client_factory=client_factory)
48
+ signals.connect()
49
+ heartbeat.connect()
50
+ metadata.connect()
51
+ gate.connect()
52
+
53
+ # if the worker is already up (init called late), gate the tasks now
54
+ if getattr(app, "finalized", False):
55
+ gate.install(app)
56
+ return rt
57
+
58
+
59
+ def shutdown() -> None:
60
+ """Undo init(). Mostly useful in tests."""
61
+ from percival import gate, heartbeat, metadata, runtime, signals
62
+
63
+ signals.disconnect()
64
+ heartbeat.disconnect()
65
+ metadata.disconnect()
66
+ gate.disconnect()
67
+ runtime.reset()
percival/beat.py ADDED
@@ -0,0 +1,91 @@
1
+ """RedBeat scheduler with per-schedule timezones and a heartbeat.
2
+
3
+ celery -A proj beat -S percival.beat.Scheduler
4
+
5
+ It's stock RedBeat underneath. Entries stay in RedBeat's format, so plain
6
+ ``redbeat.RedBeatScheduler`` still reads them (crontabs just fall back to the
7
+ app timezone). What this adds:
8
+
9
+ - crontabs with a ``percival_timezone`` header are evaluated in that zone
10
+ - a heartbeat key so the panel knows beat is alive, when it last ticked and
11
+ who holds the lock
12
+ - a shorter default loop interval, so a newly created schedule is picked up
13
+ in seconds instead of up to 5 minutes
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import socket
22
+ import time
23
+ from typing import Any
24
+
25
+ from celery.schedules import crontab
26
+ from redbeat.schedulers import RedBeatScheduler, RedBeatSchedulerEntry
27
+
28
+ from percival import __version__, runtime
29
+ from percival.naming import Headers
30
+ from percival.schedules import zoned_crontab
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ DEFAULT_MAX_INTERVAL = 15
35
+
36
+
37
+ def apply_zone(sched: Any, options: dict[str, Any] | None, app: Any = None) -> Any:
38
+ headers = (options or {}).get("headers") or {}
39
+ zone = headers.get(Headers.TIMEZONE)
40
+ if not zone or not isinstance(sched, crontab) or isinstance(sched, zoned_crontab):
41
+ return sched
42
+ try:
43
+ return zoned_crontab.from_crontab(sched, zone, app=app)
44
+ except Exception:
45
+ log.warning("percival: unknown timezone %r, using the app timezone", zone)
46
+ return sched
47
+
48
+
49
+ class Entry(RedBeatSchedulerEntry): # type: ignore[misc]
50
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
51
+ super().__init__(*args, **kwargs)
52
+ self.schedule = apply_zone(self.schedule, self.options, self.app) # type: ignore[has-type]
53
+
54
+
55
+ class Scheduler(RedBeatScheduler): # type: ignore[misc]
56
+ Entry = Entry
57
+
58
+ def __init__(self, app: Any, *args: Any, **kwargs: Any) -> None:
59
+ if kwargs.get("max_interval") is None and not app.conf.beat_max_loop_interval:
60
+ kwargs["max_interval"] = DEFAULT_MAX_INTERVAL
61
+ super().__init__(app, *args, **kwargs)
62
+ self._started_at = time.time()
63
+
64
+ def tick(self, *args: Any, **kwargs: Any) -> Any:
65
+ result = super().tick(*args, **kwargs)
66
+ self._heartbeat()
67
+ return result
68
+
69
+ def _heartbeat(self) -> None:
70
+ rt = runtime.get_optional()
71
+ if rt is None:
72
+ return
73
+ payload = {
74
+ "hostname": socket.gethostname(),
75
+ "pid": os.getpid(),
76
+ "scheduler": f"{type(self).__module__}.{type(self).__name__}",
77
+ "started_at": self._started_at,
78
+ "last_tick": time.time(),
79
+ "max_interval": self.max_interval,
80
+ "lock_key": self.lock_key,
81
+ "code_version": rt.settings.code_version,
82
+ "client_version": __version__,
83
+ }
84
+ try:
85
+ rt.redis.get().set(
86
+ rt.keys.beat,
87
+ json.dumps(payload),
88
+ ex=int(max(self.max_interval * 3, 60)),
89
+ )
90
+ except Exception:
91
+ log.debug("percival: beat heartbeat failed", exc_info=True)
percival/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """``percival`` command line. Only ``import`` for now."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ from percival.naming import NAME
12
+
13
+
14
+ def load_app(spec: str) -> Any:
15
+ """``proj.celery:app``, or just ``proj.celery`` if the app is called app/celery."""
16
+ # same as celery -A: make the current directory importable
17
+ if os.getcwd() not in sys.path:
18
+ sys.path.insert(0, os.getcwd())
19
+
20
+ module_name, _, attr = spec.partition(":")
21
+ module = importlib.import_module(module_name)
22
+ if attr:
23
+ return getattr(module, attr)
24
+ for candidate in ("app", "celery"):
25
+ if hasattr(module, candidate):
26
+ return getattr(module, candidate)
27
+ raise SystemExit(f"couldn't find a celery app in {module_name}, try {module_name}:app")
28
+
29
+
30
+ def cmd_import(args: argparse.Namespace) -> int:
31
+ from percival import importer
32
+
33
+ app = load_app(args.app)
34
+ importer.configure_redbeat(app, args.redis_url, args.key_prefix)
35
+ planned = importer.plan(app, require_version=args.require_version, only=args.only or None)
36
+ print(importer.render(planned))
37
+
38
+ if not args.apply:
39
+ if any(p.action in (importer.CREATE, importer.UPDATE) for p in planned):
40
+ print("\ndry run, nothing written. run again with --apply to write these.")
41
+ return 0
42
+
43
+ written = importer.apply(app, planned)
44
+ print(f"\nwrote {written} entries to redbeat.")
45
+ if written:
46
+ print(
47
+ "note: beat re-syncs beat_schedule from config on every start, so remove the\n"
48
+ "imported entries from your config or they will overwrite changes made in the panel."
49
+ )
50
+ return 0
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(prog=NAME)
55
+ sub = parser.add_subparsers(dest="command", required=True)
56
+
57
+ imp = sub.add_parser("import", help="copy beat_schedule from your app into redbeat")
58
+ imp.add_argument("--app", required=True, help="celery app, e.g. proj.celery:app")
59
+ imp.add_argument("--redis-url", help="redis used by redbeat (default: redbeat_redis_url)")
60
+ imp.add_argument("--key-prefix", help="redbeat_key_prefix if you changed it")
61
+ imp.add_argument(
62
+ "--require-version",
63
+ help="set the required code version on every imported entry",
64
+ )
65
+ imp.add_argument("--only", nargs="+", metavar="NAME", help="import just these entries")
66
+ imp.add_argument("--apply", action="store_true", help="actually write (default is dry run)")
67
+ imp.set_defaults(func=cmd_import)
68
+ return parser
69
+
70
+
71
+ def main(argv: list[str] | None = None) -> int:
72
+ args = build_parser().parse_args(argv)
73
+ result: int = args.func(args)
74
+ return result
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())
percival/config.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass, replace
6
+ from typing import Any, Literal
7
+
8
+ from percival.naming import ENV_PREFIX, NAME
9
+
10
+ DEFAULT_REDACT = (
11
+ "password",
12
+ "passwd",
13
+ "secret",
14
+ "token",
15
+ "api_key",
16
+ "apikey",
17
+ "authorization",
18
+ "cookie",
19
+ )
20
+
21
+ UNKNOWN_VERSION = "unknown"
22
+
23
+ StaleAction = Literal["requeue", "drop"]
24
+
25
+
26
+ def _env(name: str) -> str | None:
27
+ value = os.environ.get(ENV_PREFIX + name)
28
+ return value if value else None
29
+
30
+
31
+ def _env_bool(name: str) -> bool | None:
32
+ value = _env(name)
33
+ if value is None:
34
+ return None
35
+ return value.strip().lower() in {"1", "true", "yes", "on"}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Settings:
40
+ redis_url: str = "redis://localhost:6379/0"
41
+ code_version: str = UNKNOWN_VERSION
42
+ key_prefix: str = NAME
43
+
44
+ # what goes into run events
45
+ capture_args: bool = True
46
+ redact_kwargs: tuple[str, ...] = DEFAULT_REDACT
47
+ max_value_chars: int = 2_000
48
+ max_traceback_chars: int = 20_000
49
+
50
+ # what the stale-worker gate does with a blocked message
51
+ stale_action: StaleAction = "requeue"
52
+ requeue_delay: float = 5.0
53
+ max_requeues: int = 50
54
+
55
+ heartbeat_interval: float = 30.0
56
+ heartbeat_ttl: int = 90
57
+
58
+ events_maxlen: int = 100_000
59
+ publish_source: bool = True
60
+ source_max_lines: int = 300
61
+
62
+ # once_per behaviour when redis is down; False means skip the run
63
+ dedupe_fail_open: bool = False
64
+
65
+ @classmethod
66
+ def build(cls, **overrides: Any) -> Settings:
67
+ """Explicit arguments win, then PERCIVAL_* env vars, then defaults."""
68
+ values: dict[str, Any] = {}
69
+
70
+ env_map: dict[str, Any] = {
71
+ "redis_url": _env("REDIS_URL"),
72
+ "code_version": _env("CODE_VERSION"),
73
+ "key_prefix": _env("KEY_PREFIX"),
74
+ "capture_args": _env_bool("CAPTURE_ARGS"),
75
+ "dedupe_fail_open": _env_bool("DEDUPE_FAIL_OPEN"),
76
+ "stale_action": _env("STALE_ACTION"),
77
+ }
78
+ values.update({k: v for k, v in env_map.items() if v is not None})
79
+ values.update({k: v for k, v in overrides.items() if v is not None})
80
+
81
+ redact = values.get("redact_kwargs")
82
+ if redact is not None and not isinstance(redact, tuple):
83
+ values["redact_kwargs"] = _as_tuple(redact)
84
+
85
+ settings = cls(**values)
86
+ settings.validate()
87
+ return settings
88
+
89
+ def validate(self) -> None:
90
+ if self.stale_action not in ("requeue", "drop"):
91
+ raise ValueError(f"stale_action must be 'requeue' or 'drop', got {self.stale_action!r}")
92
+ if self.heartbeat_ttl <= self.heartbeat_interval:
93
+ raise ValueError("heartbeat_ttl has to be larger than heartbeat_interval")
94
+
95
+ def with_(self, **changes: Any) -> Settings:
96
+ return replace(self, **changes)
97
+
98
+
99
+ def _as_tuple(items: Iterable[str] | str) -> tuple[str, ...]:
100
+ if isinstance(items, str):
101
+ return tuple(s.strip() for s in items.split(",") if s.strip())
102
+ return tuple(items)
percival/dedupe.py ADDED
@@ -0,0 +1,183 @@
1
+ """``@once_per``: run a task at most once per key per time window.
2
+
3
+ @app.task
4
+ @once_per(key=lambda user_id, **kw: user_id, window="1d")
5
+ def send_digest(user_id): ...
6
+
7
+ Backed by ``SET NX EX`` in redis. Fixed windows ("5m", "1h", "1d") are
8
+ aligned to the unix epoch, so "1d" means one UTC calendar day. For anything
9
+ else, like the recipient's local date, pass a callable as ``window`` and it
10
+ becomes the bucket.
11
+
12
+ If the task raises (including celery's Retry) the key is released again,
13
+ otherwise a failed attempt would block its own retry for the whole window.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import logging
20
+ import re
21
+ import time
22
+ from collections.abc import Callable
23
+ from types import SimpleNamespace
24
+ from typing import Any, TypeVar
25
+
26
+ from celery import Task, current_task
27
+ from celery.exceptions import Ignore
28
+
29
+ from percival import runtime
30
+ from percival.events import Status, build_event, emit
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ F = TypeVar("F", bound=Callable[..., Any])
35
+
36
+ _UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
37
+ _WINDOW_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
38
+
39
+ # ttl for callable windows when none is given, generous on purpose
40
+ DEFAULT_CALLABLE_TTL = 2 * 86400
41
+
42
+
43
+ def parse_window(window: str) -> int:
44
+ match = _WINDOW_RE.match(window)
45
+ if not match:
46
+ raise ValueError(f"can't parse window {window!r}, use something like '5m', '1h', '1d'")
47
+ seconds = int(match.group(1)) * _UNITS[match.group(2)]
48
+ if seconds <= 0:
49
+ raise ValueError("window has to be longer than zero")
50
+ return seconds
51
+
52
+
53
+ def once_per(
54
+ key: Callable[..., Any] | None = None,
55
+ window: str | Callable[..., Any] = "1d",
56
+ *,
57
+ name: str | None = None,
58
+ ttl: int | None = None,
59
+ fail_open: bool | None = None,
60
+ release_on_error: bool = True,
61
+ ) -> Callable[[F], F]:
62
+ # validate up front so a typo fails at import, not at 3am
63
+ window_seconds = parse_window(window) if isinstance(window, str) else None
64
+ if window_seconds is None and not callable(window):
65
+ raise TypeError("window must be a string like '1d' or a callable")
66
+
67
+ def decorator(fn: F) -> F:
68
+ fallback_name = f"{fn.__module__}.{fn.__qualname__}"
69
+
70
+ @functools.wraps(fn)
71
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
72
+ rt = runtime.current()
73
+ task = _running_task()
74
+ task_name = name or (task.name if task is not None else fallback_name)
75
+
76
+ # bind=True tasks get self first, the key function shouldn't see it
77
+ user_args = args[1:] if args and isinstance(args[0], Task) else args
78
+ key_value = str(key(*user_args, **kwargs)) if key is not None else "_"
79
+
80
+ if window_seconds is not None:
81
+ bucket = str(int(time.time() // window_seconds))
82
+ expire = ttl or window_seconds
83
+ else:
84
+ bucket = str(window(*user_args, **kwargs)) # type: ignore[operator]
85
+ expire = ttl or DEFAULT_CALLABLE_TTL
86
+
87
+ redis_key = rt.keys.once(task_name, key_value, bucket)
88
+ holder = getattr(getattr(task, "request", None), "id", None) or "direct"
89
+ should_fail_open = rt.settings.dedupe_fail_open if fail_open is None else fail_open
90
+
91
+ try:
92
+ acquired = bool(rt.redis.get().set(redis_key, holder, nx=True, ex=expire))
93
+ except Exception as exc:
94
+ if should_fail_open:
95
+ log.warning(
96
+ "percival: once_per can't reach redis, running %s anyway",
97
+ task_name,
98
+ exc_info=True,
99
+ )
100
+ return fn(*args, **kwargs)
101
+ log.error(
102
+ "percival: once_per can't reach redis, skipping %s", task_name, exc_info=True
103
+ )
104
+ _report(
105
+ rt,
106
+ Status.DEDUPE_UNAVAILABLE,
107
+ task,
108
+ task_name,
109
+ fn,
110
+ user_args,
111
+ kwargs,
112
+ redis_key,
113
+ exc_type=type(exc).__name__,
114
+ exc_message=str(exc),
115
+ )
116
+ return _skip(task)
117
+
118
+ if not acquired:
119
+ _report(
120
+ rt, Status.SKIPPED_DUPLICATE, task, task_name, fn, user_args, kwargs, redis_key
121
+ )
122
+ return _skip(task)
123
+
124
+ try:
125
+ return fn(*args, **kwargs)
126
+ except BaseException:
127
+ if release_on_error:
128
+ _release(rt, redis_key, holder)
129
+ raise
130
+
131
+ return wrapper # type: ignore[return-value]
132
+
133
+ return decorator
134
+
135
+
136
+ def _running_task() -> Any:
137
+ task = current_task
138
+ if not task:
139
+ return None
140
+ request = getattr(task, "request", None)
141
+ if request is None or getattr(request, "called_directly", True):
142
+ return None
143
+ return task
144
+
145
+
146
+ def _skip(task: Any) -> None:
147
+ # inside a worker, Ignore keeps celery from also recording a "success"
148
+ if task is not None:
149
+ raise Ignore()
150
+ return None
151
+
152
+
153
+ def _release(rt: runtime.Runtime, redis_key: str, holder: str) -> None:
154
+ try:
155
+ client = rt.redis.get()
156
+ # only delete it if it's still ours
157
+ current = client.get(redis_key)
158
+ if isinstance(current, bytes):
159
+ current = current.decode()
160
+ if current == holder:
161
+ client.delete(redis_key)
162
+ except Exception:
163
+ log.warning("percival: could not release once_per key %s", redis_key, exc_info=True)
164
+
165
+
166
+ def _report(
167
+ rt: runtime.Runtime,
168
+ status: str,
169
+ task: Any,
170
+ task_name: str,
171
+ fn: Callable[..., Any],
172
+ args: Any,
173
+ kwargs: Any,
174
+ redis_key: str,
175
+ **extra: Any,
176
+ ) -> None:
177
+ subject = task if task is not None else SimpleNamespace(name=task_name, request=None, run=fn)
178
+ emit(
179
+ rt,
180
+ build_event(
181
+ rt, status, task=subject, args=args, kwargs=kwargs, dedupe_key=redis_key, **extra
182
+ ),
183
+ )
percival/events.py ADDED
@@ -0,0 +1,117 @@
1
+ """Run events, pushed to a Redis Stream that the panel consumes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import socket
8
+ import time
9
+ from typing import Any
10
+
11
+ from percival.naming import Headers
12
+ from percival.redaction import capture_args
13
+ from percival.runtime import Runtime
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class Status:
19
+ STARTED = "started"
20
+ SUCCESS = "success"
21
+ FAILURE = "failure"
22
+ RETRY = "retry"
23
+ REVOKED = "revoked"
24
+ SKIPPED_DUPLICATE = "skipped_duplicate"
25
+ BLOCKED_STALE_WORKER = "blocked_stale_worker"
26
+ # once_per could not reach redis and fail_open is off, so the run was skipped
27
+ DEDUPE_UNAVAILABLE = "dedupe_unavailable"
28
+
29
+
30
+ def emit(rt: Runtime, event: dict[str, Any]) -> None:
31
+ """Best effort. A broken redis must never take a task down with it."""
32
+ try:
33
+ payload = json.dumps(event, default=repr, separators=(",", ":"))
34
+ rt.redis.get().xadd(
35
+ rt.keys.events,
36
+ {"data": payload},
37
+ maxlen=rt.settings.events_maxlen,
38
+ approximate=True,
39
+ )
40
+ except Exception:
41
+ log.warning(
42
+ "percival: could not record %s event for %s",
43
+ event.get("status"),
44
+ event.get("task_id"),
45
+ exc_info=True,
46
+ )
47
+
48
+
49
+ def get_header(request: Any, name: str) -> Any:
50
+ """Custom headers end up in different places depending on the message
51
+ protocol and celery version, so look in both."""
52
+ if request is None:
53
+ return None
54
+ headers = getattr(request, "headers", None)
55
+ if isinstance(headers, dict) and name in headers:
56
+ return headers[name]
57
+ return getattr(request, name, None)
58
+
59
+
60
+ def build_event(
61
+ rt: Runtime,
62
+ status: str,
63
+ *,
64
+ task: Any,
65
+ request: Any = None,
66
+ args: Any = None,
67
+ kwargs: Any = None,
68
+ started_at: float | None = None,
69
+ **extra: Any,
70
+ ) -> dict[str, Any]:
71
+ settings = rt.settings
72
+ now = time.time()
73
+ if request is None:
74
+ request = getattr(task, "request", None)
75
+
76
+ event: dict[str, Any] = {
77
+ "status": status,
78
+ "task_id": getattr(request, "id", None),
79
+ "task": getattr(task, "name", None),
80
+ "schedule": get_header(request, Headers.SCHEDULE),
81
+ "required_version": get_header(request, Headers.REQUIRED_VERSION),
82
+ "worker": getattr(request, "hostname", None) or rt.hostname or socket.gethostname(),
83
+ "code_version": settings.code_version,
84
+ "retries": getattr(request, "retries", None),
85
+ "queue": _queue_of(request),
86
+ "ts": now,
87
+ }
88
+ if started_at is not None:
89
+ event["started_at"] = started_at
90
+ if status != Status.STARTED:
91
+ event["finished_at"] = now
92
+ event["duration"] = round(now - started_at, 6)
93
+
94
+ if settings.capture_args:
95
+ if args is None and kwargs is None and request is not None:
96
+ args = getattr(request, "args", None)
97
+ kwargs = getattr(request, "kwargs", None)
98
+ event.update(
99
+ capture_args(
100
+ getattr(task, "run", None),
101
+ args,
102
+ kwargs,
103
+ settings.redact_kwargs,
104
+ settings.max_value_chars,
105
+ )
106
+ )
107
+
108
+ event.update({k: v for k, v in extra.items() if v is not None})
109
+ return event
110
+
111
+
112
+ def _queue_of(request: Any) -> str | None:
113
+ info = getattr(request, "delivery_info", None)
114
+ if isinstance(info, dict):
115
+ queue = info.get("routing_key") or info.get("queue")
116
+ return str(queue) if queue else None
117
+ return None