Metadata-Version: 2.5
Name: samco-job-lifecycle
Version: 0.2.0
Summary: One job lifecycle: claim due work, launch a Kubernetes Job, observe it, close it.
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: kubernetes>=29; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: kubernetes
Requires-Dist: kubernetes>=29; extra == 'kubernetes'
Description-Content-Type: text/markdown

# samco-job-lifecycle

One job lifecycle, shared by every use case that runs work as a Kubernetes Job.

```text
claim due work -> build execution -> start Job -> record -> observe -> close
```

Before this package the loop above existed twice — in `screener-schedule-engine`
and in `realtime-signal-engine` — and the two had drifted. This package keeps
the shared lifecycle in one place.

Version 0.2.0 replaces the unrelated 0.1.x starter API. It does not expose the
old `Job` class; use `AbstractLifecycleEngine` and the models below.

## Install

```sh
pip install samco-job-lifecycle==0.2.0
```

To use `KubernetesJobRuntime`, install the optional Kubernetes client:

```sh
pip install 'samco-job-lifecycle[kubernetes]==0.2.0'
```

## What it does and does not know

| Owns | Never owns |
|---|---|
| the lifecycle sequence and legal phase changes | screener cadence, trading or signal rules |
| the concurrency cap and fail-closed counting | MySQL or MongoDB schema |
| idempotent Job creation, 409 handling | a project or source name switch |
| observing Jobs and classifying failures | an untyped dict of "project inputs" |
| cycle reports and metrics | what a run computed |

`SUCCEEDED` here means the process exited zero. What a screen matched or which
signal was emitted is the runner's to record, and stays the runner's.

## Using it

This subclass is a sketch: each method shown with `...` must be implemented
against your application's store before calling `schedule_once()`.

```python
from samco_job_lifecycle import (AbstractLifecycleEngine, ExecutionSpec,
                                 KubernetesJobRuntime, LabelSelector,
                                 LifecycleLimits)


class PaperTradeLifecycle(AbstractLifecycleEngine):
    def claim_due(self, now, limit): ...          # your store, your atomic claim
    def build_execution(self, work): ...          # -> ExecutionSpec
    def mark_launched(self, work, handle): ...
    def mark_launch_failed(self, work, failure): ...
    def active_executions(self): ...
    def apply_observation(self, execution, observation): ...


engine = PaperTradeLifecycle(
    KubernetesJobRuntime("strategies"),
    LifecycleLimits(max_active=40, per_cycle=20),
    LabelSelector({"app": "strategy-runner"}),
)
engine.schedule_once()
```

## Main API

| Component | Public methods or functions | Purpose |
|---|---|---|
| `AbstractLifecycleEngine` | `schedule_once`, `claim_due`, `build_execution`, `mark_launched`, `mark_launch_failed`, `mark_launch_ambiguous`, `active_executions`, `apply_observation` | Run one scheduling cycle; subclasses provide store-specific operations. |
| `KubernetesJobRuntime` / `JobRuntime` | `active_count`, `start`, `observe` | Count, create and observe Kubernetes Jobs. |
| `Reconciler` | `reconcile_once` | Close finished executions and flag missing Jobs. |
| State helpers | `can_transition`, `check_transition`; `states.is_terminal` | Validate lifecycle phase changes. |
| Manifest helpers | `render_manifest`, `observation_from_job` | Convert execution specs and Kubernetes Jobs. |
| `FakeJobRuntime` | `active_count`, `start`, `observe`, `finish`, `forget` | Exercise the lifecycle without a cluster. |
| `LifecycleContract` | `make_engine`, `seed_due`, `seed_malformed` | Hooks for the shared subclass test suite. |
| `CycleReport` / `ReconcileReport` | `summary`, `ok` | Read cycle and reconciliation outcomes. |
| `Metrics` | `count`, `gauge` | Emit lifecycle counters and gauges. |

The package also exports `LifecyclePhase`, `TERMINAL`, `LifecycleLimits`,
`ExecutionSpec`, `EnvVar`, `EnvSource`, `ResourceSpec`, `LabelSelector`,
`WorkRef`, `JobHandle`, `JobObservation`, `TrackedExecution`, and failure types.

Do not override `schedule_once`. It is the template method, and overriding it
opts out of every guarantee the contract suite checks.

## The two cap policies

Both consumers were right and they disagreed, so the difference is
configuration rather than a subclass replacing the cycle:

```python
LifecycleLimits(max_active=20, wait_for_slot=True)    # screener: run everything, in waves
LifecycleLimits(max_active=40, per_cycle=20)          # signals: trim the cycle, retry next minute
```

## The contract suite

Every subclass must pass the same suite, or it is not substitutable:

```python
from samco_job_lifecycle.testing import LifecycleContract

class TestPaperContract(LifecycleContract):
    def make_engine(self, runtime):
        return PaperTradeLifecycle(runtime, LIMITS, SELECTOR, store=FakeStore())

    def seed_due(self, engine, count):
        engine.store.add(count)
```

It checks the properties that were incidents waiting to happen: an atomic
claim, a 409 read as success, one item's failure not stopping the rest, an
ambiguous create keeping its claim, and monotonic idempotent phase writes.

## Consuming a local checkout

For development, a path dependency can override the published package:

```toml
dependencies = ["samco-job-lifecycle"]

[tool.uv.sources]
samco-job-lifecycle = { path = "../samco-job-lifecycle" }
```

It is **not** editable, so an edit here needs
`uv sync --reinstall-package samco-job-lifecycle` in each consumer and a
restart of anything running.

Python floor is **3.10**, not 3.12: a shared package cannot be newer than its
oldest consumer. The Kubernetes client is an extra (`[kubernetes]`) — testing
against `FakeJobRuntime` needs neither a client nor a cluster.

## Layout

```text
src/samco_job_lifecycle/
  engine.py       AbstractLifecycleEngine — the template method
  models.py       frozen value objects crossing the boundary
  states.py       phases and legal transitions
  errors.py       definite vs ambiguous failure
  runtime.py      JobRuntime contract
  kubernetes.py   KubernetesJobRuntime — the only file importing the client
  reconciler.py   closing runs a pod could not close
  report.py       CycleReport / ReconcileReport
  metrics.py      counters, no domain labels
  testing/        FakeJobRuntime and the contract suite
```

Run the tests with `uv run pytest -q` and the linter with `uv run ruff check .`.
