Source code for genro_asgi.tasks.store

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""TaskStore — the persistent half of the task backbone (NO SQLite).

The routing tree declares tasks (the live registry); the store remembers the
SCHEDULES: when each one runs, whether it is enabled, what happened last time.
Filesystem on the storage layer behind a small contract — one JSON per schedule
record at ``tasks/<code>.json``, one capped JSONL log per task at
``tasks/logs/<task_name>.jsonl``. The mount is chosen exactly as the other
server stores' data of record: the encrypted ``secure`` mount when key material
is installed, else the plain ``site`` mount with a warning (distinct from the
task SPOOL, which is always ``site`` — spool data are transient service state,
task RECORDS may carry schedule kwargs worth encrypting). A future Db-backed
store swaps behind the same contract.

The record::

    {
      "code": "shop_cleanup",          # PK; the default row's code IS the task name
      "task_name": "shop_cleanup",     # joins the live registry (the tree)
      "target_kind": "task",           # "path" reserved for a future privileged mode
      "kwargs": {},                    # passed to the callable
      "kind": "every",                 # every | cron | at
      "spec": "15m",                   # interval | cron string | ISO list
      "enabled": true,
      "next_run_ts": 1783900000.0,     # epoch; null = nothing due (exhausted "at")
      "last_run_ts": null, "last_outcome": null,
      "last_error": null, "last_duration": null
    }

The store never computes schedules (that is ``tasks.schedule``) and never
resolves callables (that is the scheduler): it persists, lists and filters.
``upsert_default`` is the code-default rule: created when absent, an existing
record ALWAYS wins (the config/preferences pattern). Storage is synchronous by
construction (core 1b): async callers dispatch store calls via ``server.run_sync``.
"""

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from ..storage import LocalStorage, LocalStorageNode

__all__ = ["TaskStore", "FileTaskStore"]

SECURE_MOUNT = "secure"
PLAIN_MOUNT = "site"
TASKS_DIR = "tasks"
LOG_CAP = 200  # JSONL lines kept per task


[docs] class TaskStore: """Contract for the schedule store (see module docstring).""" __slots__ = ()
[docs] def load_all(self) -> list[dict[str, Any]]: """Return every schedule record.""" raise NotImplementedError
[docs] def get(self, code: str) -> dict[str, Any] | None: """Return the record for ``code`` or None.""" raise NotImplementedError
[docs] def save(self, record: dict[str, Any]) -> None: """Persist ``record`` (create or update), atomically.""" raise NotImplementedError
[docs] def delete(self, code: str) -> bool: """Remove ``code``. True if a record was removed, False if absent.""" raise NotImplementedError
[docs] def append_log(self, task_name: str, entry: dict[str, Any]) -> None: """Append one JSONL line to the task's log, keeping the last LOG_CAP.""" raise NotImplementedError
[docs] def read_log(self, task_name: str, limit: int = LOG_CAP) -> list[dict[str, Any]]: """The task's most recent log entries, oldest first.""" raise NotImplementedError
[docs] def due_rows(self, now: float) -> list[dict[str, Any]]: """The enabled records whose ``next_run_ts`` has expired.""" return [ record for record in self.load_all() if record.get("enabled") and record.get("next_run_ts") is not None and record["next_run_ts"] <= now ]
[docs] def upsert_default(self, record: dict[str, Any]) -> None: """Create the code-default record when absent; an existing one wins.""" if self.get(record["code"]) is None: self.save(record)
[docs] def update_run(self, code: str, **fields: Any) -> None: """Merge run-outcome fields (``last_*``, ``next_run_ts``) into a record.""" record = self.get(code) if record is None: return record.update(fields) self.save(record)
[docs] def set_enabled(self, code: str, value: bool) -> dict[str, Any] | None: """Flip the enabled flag; the updated record, or None if absent.""" record = self.get(code) if record is None: return None record["enabled"] = value self.save(record) return record
[docs] class FileTaskStore(TaskStore): """One JSON per schedule + one JSONL log per task, on the storage layer. Note: The store holds the shared ``LocalStorage`` instance (dual relationship: ``self.storage``), never raw paths — the mount decides encryption at rest. """ __slots__ = ("storage", "_mount")
[docs] def __init__(self, storage: LocalStorage, mount: str | None = None) -> None: """Bind the store to the server's storage service. Args: storage: The server's LocalStorage; task files live on its ``secure`` mount when keys are installed, else ``site``. mount: Explicit mount override (the ``tasks(mount=...)`` config element); ``None`` keeps the automatic by-keys choice. """ self.storage = storage self._mount = mount
@property def mount(self) -> str: """The task-files mount: the explicit override, else by installed keys.""" if self._mount is not None: return self._mount return SECURE_MOUNT if self.storage.encryption_active else PLAIN_MOUNT def _tasks_node(self) -> LocalStorageNode: """The ``<mount>:tasks`` directory node.""" return self.storage.node(f"{self.mount}:{TASKS_DIR}") def _record_node(self, code: str) -> LocalStorageNode: """The node for one schedule's JSON file.""" return self.storage.node(f"{self.mount}:{TASKS_DIR}/{code}.json") def _log_node(self, task_name: str) -> LocalStorageNode: """The node for one task's JSONL log.""" return self.storage.node(f"{self.mount}:{TASKS_DIR}/logs/{task_name}.jsonl")
[docs] def load_all(self) -> list[dict[str, Any]]: """Read every ``*.json`` record under ``<mount>:tasks/``.""" directory = self._tasks_node() if not directory.isdir: return [] records: list[dict[str, Any]] = [] for child in directory.children(): if child.ext == "json": records.append(json.loads(child.read_text())) return records
[docs] def get(self, code: str) -> dict[str, Any] | None: """Read one schedule's record, or None if the file does not exist.""" node = self._record_node(code) if not node.exists: return None result: dict[str, Any] = json.loads(node.read_text()) return result
[docs] def save(self, record: dict[str, Any]) -> None: """Persist ``record`` through the storage node, warning if unencrypted. ``code`` is the file identity. ``created_at`` is kept from the existing record on update; ``updated_at`` is refreshed on every write. """ code = record["code"] node = self._record_node(code) now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") existing = self.get(code) record["created_at"] = existing["created_at"] if existing else record.get( "created_at", now ) record["updated_at"] = now if not self.storage.encryption_active: logging.getLogger(__name__).warning( "storage_key not configured: task record %r persisted unencrypted", code ) node.write_text(json.dumps(record, indent=2))
[docs] def delete(self, code: str) -> bool: """Remove one schedule's file. True if it existed, False otherwise.""" return self._record_node(code).delete()
[docs] def append_log(self, task_name: str, entry: dict[str, Any]) -> None: """Append one JSONL line, rewriting with only the last LOG_CAP lines.""" node = self._log_node(task_name) lines = node.read_text().splitlines() if node.exists else [] lines.append(json.dumps(entry)) node.write_text("\n".join(lines[-LOG_CAP:]) + "\n")
[docs] def read_log(self, task_name: str, limit: int = LOG_CAP) -> list[dict[str, Any]]: """The task's most recent ``limit`` entries, oldest first.""" node = self._log_node(task_name) if not node.exists: return [] lines = node.read_text().splitlines() return [json.loads(line) for line in lines[-limit:] if line.strip()]
if __name__ == "__main__": import tempfile from ..storage import LocalStorage with tempfile.TemporaryDirectory() as tmp: store = FileTaskStore(LocalStorage(tmp)) store.save( { "code": "demo", "task_name": "demo", "target_kind": "task", "kwargs": {}, "kind": "every", "spec": "15m", "enabled": True, "next_run_ts": 0.0, } ) print("due:", [r["code"] for r in store.due_rows(1.0)]) store.append_log("demo", {"outcome": "ok"}) print("log:", store.read_log("demo"))