# Serverless

> Why AWS Lambda and Cloud Functions cannot host a Pyrula worker, where a worker can run, and how a function can still start runs.

Source: https://docs.pyrula.io/getting-started/recipes/serverless/

A worker is a long-lived process. It claims runs from the store, executes them, renews its
leases and fires timers, and it drains on SIGTERM
([Deploy a worker](https://docs.pyrula.io/getting-started/deploy-a-worker.md)). Sleeps, signal waits, child runs and
schedules only make progress while a worker is running. A function platform that starts a
process per request and freezes or stops it between requests cannot do that, so **AWS Lambda and
Google Cloud Functions are not a worker host.**

## Where a worker can run

| Shape | Status |
|---|---|
| Kubernetes Deployment | A recipe: [`examples/deploy/kubernetes/worker.yaml`](https://docs.pyrula.io/getting-started/deploy-a-worker.md#kubernetes) |
| Docker Compose on one host | A recipe, with Valkey or a SQLite store ([Compose](https://docs.pyrula.io/getting-started/deploy-a-worker.md#docker-compose)) |
| Render background worker | A recipe: [Render](https://docs.pyrula.io/getting-started/deploy-a-worker.md#render) |
| ECS or Fargate service, Cloud Run service, Railway service | No recipe and not tested. The worker image from [Deploy a worker](https://docs.pyrula.io/getting-started/deploy-a-worker.md) is an ordinary container; what it needs from the platform is below |

What any platform has to give the worker:

- **Always running.** At least one instance up, with CPU available when no request is arriving.
  A service that scales to zero, or throttles CPU outside requests, stops claiming runs and
  stops firing timers. On Cloud Run that means a minimum instance count of one or more and CPU
  that is not limited to request time; check your platform's settings for the equivalents.
- **Reach the store.** Valkey for more than one worker host, SQLite only for a single host
  ([Choosing a store](https://docs.pyrula.io/getting-started/choosing-a-store.md)).
- **Time to drain.** The kill timer must exceed the worker's shutdown grace: 50 seconds with the
  defaults ([Shutdown](https://docs.pyrula.io/getting-started/deploy-a-worker.md#shutdown)).
- **A volume for the Cloud spool, if you report to Cloud.** Without a persistent volume at
  `/var/lib/pyrula`, unsent Cloud events are lost with the container
  ([details](https://docs.pyrula.io/getting-started/cloud.md#not-losing-events)). An ephemeral container filesystem loses the spool.
- **Probes.** `/livez` and `/readyz` on `PYRULA_WORKER_HEALTH_PORT` for a platform health check.

## Starting runs from a function

A function can submit a run and return. The run executes on a worker somewhere else, and the
function does not wait for it. There are two ways, and the function needs one of them:

- **The client.** `WorkflowClient` from `pyrula.workflows.sync` needs only the workers' store,
  so the function must reach the store (a Valkey the function's network can connect to).
- **The run endpoints.** If a service mounts `RunRouter`, a function can `POST /{name}/runs`
  to it over HTTP, with whatever authentication you configured
  ([HTTP](https://docs.pyrula.io/workflows/guides/http.md)).

A handler using the client. Creating the client outside the handler lets a warm environment reuse
it, but that has not been run on Lambda, and nothing here was run against Valkey: the sync client
owns a loop thread and, on Valkey, sockets that a freeze and thaw can drop. This block runs the handler against a SQLite file on this machine; a real function points
`PYRULA_STORE_URL` at Valkey, because SQLite serves one host:

```python
import tempfile

from pyrula.workflows import open_store
from pyrula.workflows.sync import WorkflowClient

# Module level: runs once per execution environment, not once per invocation.
store = open_store(default=f"sqlite:///{tempfile.mkdtemp()}/lambda.db")
client = WorkflowClient(store)


def handler(event: dict, context: object) -> dict:
    run = client.submit(
        "process_document",
        {"document_id": event["document_id"]},
        # A redelivered event gets the first run back. The key lives 24 hours.
        idempotency_key=f"document:{event['document_id']}",
    )
    return {"run_id": run.run_id}


first = handler({"document_id": "doc_9"}, None)
assert handler({"document_id": "doc_9"}, None) == first
client.close()
```

`process_document` is the name a worker serves; a function that does not import the workflow
module submits by name and gets no version pin, with a warning
([Starting and talking to runs](https://docs.pyrula.io/workflows/guides/client.md)). To wait for the result, poll
`client.handle(name, run_id).status()` from another invocation rather than holding the function
open, since the run can outlive any function timeout.

A function is also a fine place for the event-triggered side of the work. See
[Runs started by events](https://docs.pyrula.io/getting-started/recipes/event-triggered-starts.md).
