keepup-tasks 0.5.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.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-tasks
3
+ Version: 0.5.0
4
+ Summary: The one scheduler, the declared jobs and the distributed locks they run under.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-auth>=0.4.0
10
+ Requires-Dist: apscheduler<4,>=3.10
11
+ Requires-Dist: fastapi<1,>=0.128
12
+
13
+ # keepup-tasks
14
+
15
+ The one scheduler, the declared jobs and the distributed locks they run under.
16
+
17
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
18
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
19
+ this distribution carries one part of it. It is installed on its own
20
+ (`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
21
+ and it declares itself to the kernel through its
22
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ # keepup-tasks
2
+
3
+ The one scheduler, the declared jobs and the distributed locks they run under.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,31 @@
1
+ """Background tasks: the one scheduler, and the locks the work runs under.
2
+
3
+ This distribution provides `scheduler` and `locks` and declares one plugin,
4
+ `tasks`, in the `keepup.plugins` group. It depends on `keepup-admin` for the
5
+ constructor, on `keepup-auth` for the panel guard of its routes -- the temporary
6
+ edge the route bridge carries until `keepup-http` mounts what a plugin declares
7
+ (keepup-121) -- and on `keepup-db`, because the locks it offers write to a table.
8
+ """
9
+
10
+ from keepup_tasks.scheduler import (
11
+ JOB_DEFAULTS,
12
+ get_scheduler,
13
+ init_scheduler,
14
+ new_scheduler,
15
+ )
16
+
17
+ __version__ = "0.5.0"
18
+
19
+ __all__ = ["JOB_DEFAULTS", "get_scheduler", "init_scheduler", "new_scheduler"]
20
+
21
+ #: What the entry point asks for, looked up on access: a deployment that only
22
+ #: wants the scheduler does not pay for the plugin's panel wiring.
23
+ _PLUGIN_NAMES = {"TasksPlugin", "SERVICE_SCHEDULER", "SERVICE_LOCKS"}
24
+
25
+
26
+ def __getattr__(name):
27
+ if name in _PLUGIN_NAMES:
28
+ from keepup_tasks import plugin
29
+
30
+ return getattr(plugin, name)
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,197 @@
1
+ """Background tasks as a capability: the one scheduler and the locks under it.
2
+
3
+ Task keepup-109. The scheduler used to be the kernel's (`keepup/scheduler.py`):
4
+ the application's lifespan created it before the plugins and started it after
5
+ them, so a deployment that wanted no background work paid for a scheduler, the
6
+ panel's "Background tasks" section listed whatever the kernel happened to start
7
+ (nothing), and every loop of the framework was an `asyncio.create_task` in that
8
+ same lifespan instead of a job somebody could see.
9
+
10
+ The capability owns it now:
11
+
12
+ * it starts the scheduler when the application comes up -- with the job defaults
13
+ the deployment named -- and shuts it down with the application;
14
+ * it publishes the `scheduler` service, and consumes the `jobs` contribution
15
+ (`(job_id, callable, trigger)`, the contract in doc/plugin_constructor.md), so
16
+ a capability declares its work instead of starting a loop of its own, and the
17
+ section shows every declared job with its next run;
18
+ * it publishes the `locks` service. The lock machinery is kernel infrastructure
19
+ -- the kernel's own plugin runtime and the capabilities that outlived it need
20
+ it -- and the capability is what offers it to everyone else.
21
+
22
+ The routes are declared in `keepup_tasks.scheduler` and registered by the
23
+ application until the transport that mounts what a plugin declares exists
24
+ (`keepup-http`, keepup-121).
25
+ """
26
+
27
+ import logging
28
+ from typing import Any, List
29
+
30
+ from keepup.kernel.datasource import SERVICE_DATASOURCE
31
+ from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
32
+ from keepup.plugins.base import BasePlugin
33
+ from keepup_tasks.scheduler import (
34
+ close_scheduler,
35
+ init_scheduler,
36
+ jobs_payload,
37
+ restart_scheduler,
38
+ stats_payload,
39
+ trigger_job,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ __all__ = ["TasksPlugin", "SERVICE_LOCKS", "SERVICE_SCHEDULER"]
45
+
46
+ #: What another plugin asks for to have its work scheduled.
47
+ SERVICE_SCHEDULER = "scheduler"
48
+
49
+ #: What another plugin asks for to run its work once across the replicas.
50
+ SERVICE_LOCKS = "locks"
51
+
52
+ #: The metrics capability's service, asked for by name: the locks' own numbers
53
+ #: are published as a sample instead of being written into its table.
54
+ SERVICE_METRICS = "metrics"
55
+
56
+ #: The panel's section: the jobs the scheduler holds and the locks in force.
57
+ SECTION = {
58
+ "id": "background_tasks",
59
+ "name": "Background tasks",
60
+ "description": "Scheduled jobs and the distributed locks they hold",
61
+ "js": "/keepup-static/modules/js/background_tasks.js",
62
+ "css": "/keepup-static/modules/css/background_tasks.css",
63
+ "initFunction": "initBackgroundTasks",
64
+ "icon": "clock",
65
+ "version": "1.0.0",
66
+ }
67
+
68
+
69
+ class TasksPlugin(BasePlugin):
70
+ """The scheduler and the locks, and the section that shows both."""
71
+
72
+ descriptor = PluginDescriptor(
73
+ id="tasks",
74
+ name="Background tasks",
75
+ version="0.5.0",
76
+ distribution="keepup-tasks",
77
+ kind=KIND_OPTIONAL,
78
+ priority=80,
79
+ provides=(f"{SERVICE_SCHEDULER}>=1", f"{SERVICE_LOCKS}>=1"),
80
+ requires=("datasource>=1",),
81
+ contributions=("routes", "sections", "tables", "jobs"),
82
+ )
83
+
84
+ def __init__(self, config=None):
85
+ super().__init__("tasks", "Background tasks", config)
86
+ self.scheduler = None
87
+
88
+ def register(self, services):
89
+ """Offer the locks and the scheduler.
90
+
91
+ The locks are published here because a plugin may ask for them while it
92
+ registers -- the runtime's own post-construct lock does -- and the
93
+ scheduler is published once it exists (``initialize``), since a
94
+ scheduler that is not started yet is not something to hand out.
95
+ """
96
+ if services.has(SERVICE_DATASOURCE):
97
+ from keepup import locks
98
+
99
+ services.provide(SERVICE_LOCKS, locks, version=1, plugin_id="tasks")
100
+ # The locks' own numbers, when something is collecting them: the
101
+ # metrics capability is optional, and the lock machinery does not
102
+ # write into its table (keepup-109).
103
+ if services.has(SERVICE_METRICS):
104
+ services.require(SERVICE_METRICS).collect(locks.samples)
105
+
106
+ async def initialize(self):
107
+ """Create the scheduler and start it; the declared jobs join at the freeze."""
108
+ settings = getattr(self.plugin_manager, "settings", None)
109
+ self.scheduler = init_scheduler(getattr(settings, "scheduler_job_defaults", None))
110
+ self.scheduler.start()
111
+ if self.services is not None:
112
+ self.services.provide(SERVICE_SCHEDULER, self.scheduler, version=1,
113
+ plugin_id="tasks")
114
+ return True
115
+
116
+ async def post_construct(self):
117
+ """Put the declared jobs in, now that every plugin has declared them.
118
+
119
+ Contributions are mounted after the plugins initialise (step 8 of the
120
+ lifecycle), so a job another capability declared is known here and not
121
+ in `initialize` -- and a running scheduler takes a job added to it.
122
+ """
123
+ for job in self._declared_jobs():
124
+ self._put(job)
125
+ logger.info("The tasks capability holds %s job(s)",
126
+ len(self.scheduler.get_jobs()) if self.scheduler else 0)
127
+
128
+ def _declared_jobs(self) -> List[Any]:
129
+ """Every job the other plugins declared, in the order they initialised.
130
+
131
+ Read here rather than collected by the kernel: the contribution is the
132
+ contract, and consuming it is this capability's work (keepup-109).
133
+ """
134
+ contributions = getattr(self.plugin_manager, "contributions", None)
135
+ if contributions is None:
136
+ return []
137
+ return list(contributions.of("jobs"))
138
+
139
+ def _put(self, job) -> None:
140
+ """One declared job: `(job_id, callable, trigger)`, intervals by default."""
141
+ job_id, work, trigger = job
142
+ self.scheduler.add_job(work, id=job_id, trigger="interval", **trigger)
143
+
144
+ async def cleanup(self):
145
+ """Stop the scheduler with the application that started it."""
146
+ if self.scheduler is not None:
147
+ close_scheduler()
148
+ self.scheduler = None
149
+
150
+ def get_declared_tables(self) -> List[Any]:
151
+ """The lock table: the service published here is the one that uses it.
152
+
153
+ Declared since keepup-158, when the 0.3.0 path that created it went
154
+ away. A deployment that assembles its runtime from plugins had no
155
+ `distributed_locks` table and failed on its first background sweep.
156
+ """
157
+ from keepup_tasks.tables import DISTRIBUTED_LOCKS
158
+
159
+ return [DISTRIBUTED_LOCKS]
160
+
161
+ def get_api_routes(self):
162
+ """The section's three doors, declared as data for the transport.
163
+
164
+ The application registers the same three through the bridge until
165
+ `keepup-http` mounts what a plugin declares (keepup-121); both answer
166
+ with the payload functions of `keepup_tasks.scheduler`, so there is one
167
+ implementation of each answer.
168
+ """
169
+ return [
170
+ {"path": "/api/admin/scheduler/jobs", "methods": ["GET"],
171
+ "handler": self.jobs},
172
+ {"path": "/api/admin/scheduler/jobs/{job_id}/trigger", "methods": ["POST"],
173
+ "handler": self.trigger},
174
+ {"path": "/api/admin/scheduler/stats", "methods": ["GET"],
175
+ "handler": self.stats},
176
+ {"path": "/api/admin/scheduler/restart", "methods": ["POST"],
177
+ "handler": self.restart},
178
+ ]
179
+
180
+ async def jobs(self, current_user=None):
181
+ return jobs_payload()
182
+
183
+ async def trigger(self, job_id: str, current_user=None):
184
+ return trigger_job(job_id)
185
+
186
+ async def stats(self, current_user=None):
187
+ return stats_payload()
188
+
189
+ async def restart(self, current_user=None):
190
+ return restart_scheduler()
191
+
192
+ def get_panel_sections(self):
193
+ """The jobs and the locks, as the panel's section."""
194
+ return [dict(SECTION)]
195
+
196
+ def get_handlers(self):
197
+ return {"scheduler": self.scheduler}
@@ -0,0 +1,181 @@
1
+ """One scheduler for the whole process.
2
+
3
+ Plugins are meant to register their jobs against it rather than each starting
4
+ a scheduler of its own, which is what keeps the jobs visible in one place -- and
5
+ what lets an administrator see and trigger them. A plugin that does need its
6
+ own takes it from new_scheduler(), so it gets the same job defaults. Running the
7
+ same job in every replica is prevented one level up, by the distributed lock
8
+ around the job itself, not here.
9
+
10
+ The job defaults are what APScheduler leaves open and a replica set cannot
11
+ leave open (keepup-46):
12
+
13
+ - ``coalesce``: a job that missed several runs -- the process was paused, the
14
+ loop was busy, the machine slept -- runs once on waking, not once per missed
15
+ slot. Without it a sync job that fell an hour behind fires sixty times in a
16
+ row, on every replica that woke up.
17
+ - ``max_instances = 1``: a run that has not finished is not started again
18
+ beside itself. Two passes of the same job in one process would contend for
19
+ the same distributed lock and one of them would only log that it lost.
20
+ - ``misfire_grace_time``: how late a run may still start. APScheduler's default
21
+ is one second, so a loop held for longer than that silently drops the run.
22
+ """
23
+
24
+ import asyncio
25
+ import logging
26
+ from typing import Any, Dict, Optional
27
+
28
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
29
+ from fastapi import Depends, HTTPException
30
+
31
+ from keepup import clock
32
+ from keepup_auth.dependencies import get_current_admin
33
+
34
+ #: What an application may import from this module. Everything else is
35
+ #: internal and may change without notice -- see doc/keepup.md.
36
+ __all__ = [
37
+ "JOB_DEFAULTS",
38
+ "init_scheduler",
39
+ "new_scheduler",
40
+ ]
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: The process-wide scheduler, created by init_scheduler().
45
+ scheduler = None
46
+
47
+ #: The job defaults the application named, kept so a restart starts the same way.
48
+ _job_defaults: Optional[Dict[str, Any]] = None
49
+
50
+
51
+ def get_scheduler():
52
+ """Return the process-wide scheduler, or None before it is created."""
53
+ return scheduler
54
+
55
+
56
+ #: The job defaults every scheduler of the framework starts with; an
57
+ #: application overrides them with ``KeepupSettings.scheduler_job_defaults``.
58
+ JOB_DEFAULTS = {
59
+ "coalesce": True,
60
+ "max_instances": 1,
61
+ "misfire_grace_time": 60,
62
+ }
63
+
64
+
65
+ def new_scheduler(job_defaults: Optional[Dict[str, Any]] = None) -> AsyncIOScheduler:
66
+ """A scheduler with the framework's job defaults, overridden by ``job_defaults``."""
67
+ return AsyncIOScheduler(job_defaults={**JOB_DEFAULTS, **(job_defaults or {})})
68
+
69
+
70
+ def init_scheduler(job_defaults: Optional[Dict[str, Any]] = None):
71
+ """Create and configure the task scheduler."""
72
+ global scheduler, _job_defaults
73
+
74
+ _job_defaults = job_defaults
75
+ scheduler = new_scheduler(job_defaults)
76
+
77
+ logger.info("Global scheduler initialized - tasks will be registered by plugins")
78
+ return scheduler
79
+
80
+
81
+ def restart_scheduler() -> Dict[str, Any]:
82
+ """Stop the scheduler and start a fresh one holding the same jobs.
83
+
84
+ What the panel's button asks for: the jobs are the contract, and the clock
85
+ that runs them is replaceable. A job is re-added by its function, id and
86
+ trigger, so nothing has to be declared twice (keepup-109).
87
+ """
88
+ global scheduler
89
+
90
+ previous = scheduler
91
+ declared = []
92
+ if previous is not None:
93
+ declared = [{"func": job.func, "id": job.id, "name": job.name,
94
+ "trigger": job.trigger}
95
+ for job in previous.get_jobs()]
96
+ previous.shutdown(wait=False)
97
+
98
+ fresh = init_scheduler(_job_defaults)
99
+ for spec in declared:
100
+ fresh.add_job(spec["func"], id=spec["id"], name=spec["name"], trigger=spec["trigger"])
101
+ fresh.start()
102
+ logger.info("Scheduler restarted with %s job(s)", len(fresh.get_jobs()))
103
+ return {"restarted": True, "jobs": [job.id for job in fresh.get_jobs()]}
104
+
105
+
106
+ def close_scheduler() -> None:
107
+ """Stop the process-wide scheduler and forget it.
108
+
109
+ Called by the tasks capability when its application goes down: the name is
110
+ the process's, and a scheduler that stopped is not one to answer a route
111
+ with (keepup-109).
112
+ """
113
+ global scheduler
114
+
115
+ if scheduler is not None:
116
+ scheduler.shutdown()
117
+ scheduler = None
118
+
119
+
120
+ def jobs_payload() -> Dict[str, Any]:
121
+ """Every job the scheduler holds, as the panel's section reads them."""
122
+ if not scheduler:
123
+ return {"error": "Scheduler not initialized"}
124
+ return {"jobs": [{"id": job.id, "name": job.name,
125
+ "next_run_time": job.next_run_time,
126
+ "trigger": str(job.trigger)}
127
+ for job in scheduler.get_jobs()]}
128
+
129
+
130
+ def stats_payload() -> Dict[str, Any]:
131
+ """How the scheduler is doing, as the panel's section reads it."""
132
+ if not scheduler:
133
+ return {"error": "Scheduler not initialized"}
134
+ jobs = scheduler.get_jobs()
135
+ next_run_times = [job.next_run_time for job in jobs if job.next_run_time]
136
+ return {
137
+ "scheduler_running": scheduler.running,
138
+ "total_jobs": len(jobs),
139
+ "next_wakeup": min(next_run_times) if next_run_times else None,
140
+ "pending_tasks": len(asyncio.all_tasks()) if asyncio.get_event_loop() else 0,
141
+ }
142
+
143
+
144
+ def trigger_job(job_id: str) -> Dict[str, Any]:
145
+ """Run one job by hand, now; refuses a job nobody declared."""
146
+ if not scheduler:
147
+ raise HTTPException(status_code=500, detail="Scheduler not initialized")
148
+ job = scheduler.get_job(job_id)
149
+ if not job:
150
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
151
+ job.modify(next_run_time=clock.utcnow())
152
+ return {"success": True, "message": f"Job {job_id} triggered manually"}
153
+
154
+
155
+ def register_scheduler_routes(app):
156
+ """Register the scheduler endpoints on the application.
157
+
158
+ The application's bridge until the transport mounts what a plugin declares
159
+ (keepup-121); the plugin contributes the same three routes as data, and both
160
+ answer with the payload functions above.
161
+ """
162
+
163
+ @app.get("/api/admin/scheduler/jobs")
164
+ async def get_scheduler_jobs(admin: dict = Depends(get_current_admin)):
165
+ """Return the scheduler's jobs (administrators only)."""
166
+ return jobs_payload()
167
+
168
+ @app.post("/api/admin/scheduler/jobs/{job_id}/trigger")
169
+ async def trigger_scheduler_job(job_id: str, admin: dict = Depends(get_current_admin)):
170
+ """Run a scheduler job by hand (administrators only)."""
171
+ return trigger_job(job_id)
172
+
173
+ @app.get("/api/admin/scheduler/stats")
174
+ async def get_scheduler_stats(admin: dict = Depends(get_current_admin)):
175
+ """Return scheduler statistics (administrators only)."""
176
+ return stats_payload()
177
+
178
+ @app.post("/api/admin/scheduler/restart")
179
+ async def restart_the_scheduler(admin: dict = Depends(get_current_admin)):
180
+ """Stop the scheduler and start a fresh one with the same jobs."""
181
+ return restart_scheduler()
@@ -0,0 +1,28 @@
1
+ """The table the locks service keeps its rows in.
2
+
3
+ Task keepup-158. The declaration used to live in `keepup_db.schema`, on the
4
+ 0.3.0 path an application called by hand -- which meant a runtime assembled from
5
+ plugins, the way the kernel does it, created no `distributed_locks` table at all:
6
+ a project built that way served its panel and then reported `no such table:
7
+ distributed_locks` on every background sweep.
8
+
9
+ The table belongs to the capability that provides the service that uses it.
10
+ `keepup.locks` reaches it with plain SQL, so the declaration here is the only
11
+ thing that has to exist.
12
+ """
13
+
14
+ from sqlalchemy import Column, DateTime, Text
15
+
16
+ from keepup_db import tables
17
+
18
+ __all__ = ["DISTRIBUTED_LOCKS"]
19
+
20
+
21
+ DISTRIBUTED_LOCKS = tables.table(
22
+ "distributed_locks",
23
+ # A text key written as ``TEXT PRIMARY KEY``: SQLite leaves such a column
24
+ # nullable, and the declaration keeps it that way.
25
+ Column("lock_name", Text, primary_key=True, nullable=True),
26
+ Column("acquired_at", DateTime, nullable=False),
27
+ Column("instance_id", Text, nullable=False),
28
+ )
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-tasks
3
+ Version: 0.5.0
4
+ Summary: The one scheduler, the declared jobs and the distributed locks they run under.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-auth>=0.4.0
10
+ Requires-Dist: apscheduler<4,>=3.10
11
+ Requires-Dist: fastapi<1,>=0.128
12
+
13
+ # keepup-tasks
14
+
15
+ The one scheduler, the declared jobs and the distributed locks they run under.
16
+
17
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
18
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
19
+ this distribution carries one part of it. It is installed on its own
20
+ (`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
21
+ and it declares itself to the kernel through its
22
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ keepup_tasks/__init__.py
4
+ keepup_tasks/plugin.py
5
+ keepup_tasks/scheduler.py
6
+ keepup_tasks/tables.py
7
+ keepup_tasks.egg-info/PKG-INFO
8
+ keepup_tasks.egg-info/SOURCES.txt
9
+ keepup_tasks.egg-info/dependency_links.txt
10
+ keepup_tasks.egg-info/entry_points.txt
11
+ keepup_tasks.egg-info/requires.txt
12
+ keepup_tasks.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [keepup.plugins]
2
+ tasks = keepup_tasks:TasksPlugin
@@ -0,0 +1,5 @@
1
+ keepup-admin>=0.4.0
2
+ keepup-db>=0.4.0
3
+ keepup-auth>=0.4.0
4
+ apscheduler<4,>=3.10
5
+ fastapi<1,>=0.128
@@ -0,0 +1 @@
1
+ keepup_tasks
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keepup-tasks"
7
+ version = "0.5.0"
8
+ description = "The one scheduler, the declared jobs and the distributed locks they run under."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "keepup-admin>=0.4.0",
13
+ "keepup-db>=0.4.0",
14
+ "keepup-auth>=0.4.0",
15
+ "apscheduler>=3.10,<4",
16
+ "fastapi>=0.128,<1",
17
+ ]
18
+
19
+ [project.entry-points."keepup.plugins"]
20
+ tasks = "keepup_tasks:TasksPlugin"
21
+
22
+ [tool.setuptools]
23
+ packages = ["keepup_tasks"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+