Distributed & durable background events in Python
This project has been archived.
The maintainers of this project have marked this project as archived. No new releases are expected.
Project description
rappel
rappel is a library to let you build durable background tasks that withstand device restarts, task crashes, and long-running jobs. It's built for Python and Postgres without any additional deploy time requirements.
Usage
An example is worth a thousand words. Here's how you define your workflow:
from rappel import Workflow, action, workflow
from myapp.models import User, GreetingSummary
from myapp.db import my_db
@workflow
class GreetingWorkflow(Workflow):
async def run(self, user_id: str):
user = await fetch_user(user_id) # first action
summary = await build_greetings(user) # second action, chained
return summary
And here's how you describe your distributed actions:
@action
async def fetch_user(user_id: str) -> User:
return await my_db.get(User, user_id)
@action
async def build_greetings(user: User) -> GreetingSummary:
messages: list[str] = []
for topic in user.interests:
messages.append(f"Hi {user.name}, let's talk about {topic}!")
return GreetingSummary(user=user, messages=messages)
Your webserver wants to greet some user but do it (1) asynchronously and (2) guarantee this happens even if your webapp crashes. When you call await workflow.run() from within your code we'll queue up this work in Postgres; none of the workflow logic is actually executed inline within your webserver. We start by parsing the AST definition to determine your control flow and identify that fetch_user and build_greetings are decorated with @action and depend on the outputs of the another. We will call them in sequence, passing the data as necessary, on whatever background machines are able to handle more work. When the summary is returned to your original webapp caller it looks like everything just happened right in the same process. Whereas the actual code was orchestrated across multiple different machines.
Actions are the distributed work that your system does: these are the parallelism primitives that can be retired, throw errors independently, etc.
Workflows are your control flow - also written in Python - that orchestrate the actions. They are intended to be fast business logic: list iterations. Not long-running or blocking network jobs, for instance.
Complex Workflows
Workflows can get much more complex than the example above:
-
Customizable retry policy
By default your Python code will execute like native logic would: any exceptions will throw and immediately fail. Actions are set to timeout after ~5min to keep the queues from backing up - although we will continuously retry timed out actions in case they were caused by a failed node in your cluster. If you want to control this logic to be more robust, you can set retry policies and backoff intervals so you can attempt the action multiple times until it succeeds.
from rappel import RetryPolicy, BackoffPolicy from datetime import timedelta async def run(self): await self.run_action( inconsistent_action(0.5), # control handling of failures retry=RetryPolicy(attempts=50), backoff=BackoffPolicy(base_delay=5), timeout=timedelta(minutes=10) )
-
Branching control flows
Use if statements, for loops, or any other Python primitives within the control logic. We will automatically detect these branches and compile them into a DAG node that gets executed just like your other actions.
async def run(self, user_id: str) -> Summary: # loop + non-action helper call top_spenders: list[float] = [] for record in summary.transactions.records: if _is_high_value(record): top_spenders.append(record.amount)
-
asyncio primitives
Use asyncio.gather to parallelize tasks. Use asyncio.sleep to sleep for a longer period of time.
import asyncio async def run(self, user_id: str) -> Summary: # parallelize independent actions with gather profile, settings, history = await asyncio.gather( fetch_profile(user_id=user_id), fetch_settings(user_id=user_id), fetch_purchase_history(user_id=user_id) ) # wait before sending email await asyncio.sleep(24*60*60) recommendations = await email_ping(history) return Summary(profile=profile, settings=settings, recommendations=recommendations)
-
Helper functions
You can declare helper functions in your file, in your class, or import helper functions from elsewhere in your project.
from myapp.helpers import _format_currency async def run(self, user_id: str) -> Summary: # actions related to one another profile = await fetch_profile(user_id=user_id) txns = await load_transactions(user_id=user_id) summary = await compute_summary(profile=profile, txns=txns) # helper functions pretty = _format_currency(summary.transactions.total)
Error handling
To build truly robust background tasks, you need to consider how things can go wrong. Actions can 'fail' in a few ways. This is supported by our .run_action syntax that allows users to provide additional parameters to modify the execution bounds on each action.
- Action explicitly throws an error and we want to retry it. Caused by intermittent database connectivity / overloaded webservers / or simply buggy code will throw an error.
- Actions raise an error that is a really a RappelTimeout. This indicates that we dequeued the task but weren't able to complete it in the time allocated. This could be because we dequeued the task, started work on it, then the server crashed. Or it could still be running in the background but simply took too much time. Either way we will raise a synthetic error that is representative of this execution.
By default we will only try explicit actions one time if there is an explicit exception raised. We will try them infinite times in the case of a timeout since this is usually caused by cross device coordination issues.
Project Status
Rappel is in an early alpha. Particular areas of focus include:
- Extending AST parsing logic to handle most core control flows
- Performance tuning
- Unit and integration tests
If you have a particular workflow that you think should be working but isn't yet producing the correct DAG (you can visualize it via CLI by .visualize()) please file an issue.
Configuration
The main rappel configuration is done through env vars, which is what you'll typically use in production when using a docker deployment pipeline. If we can't find an environment parameter we will fallback to looking for an .env that specifies it within your local filesystem.
| Environment Variable | Description | Default | Example |
|---|---|---|---|
DATABASE_URL |
PostgreSQL connection string for the rappel server | (required) | postgresql://user:pass@localhost:5433/rappel |
CARABINER_HTTP_ADDR |
HTTP bind address for rappel-server |
127.0.0.1:24117 |
0.0.0.0:24117 |
CARABINER_GRPC_ADDR |
gRPC bind address for rappel-server |
HTTP port + 1 | 0.0.0.0:24118 |
CARABINER_WORKER_COUNT |
Number of Python worker processes | num_cpus |
8 |
CARABINER_MAX_CONCURRENT |
Max concurrent actions across all workers | 32 |
64 |
CARABINER_USER_MODULE |
Python module preloaded into each worker | none | my_app.actions |
CARABINER_POLL_INTERVAL_MS |
Poll interval for the dispatch loop (ms) | 100 |
50 |
CARABINER_BATCH_SIZE |
Max actions fetched per poll | 100 |
200 |
Philosophy
Background jobs in webapps are so frequently used that they should really be a primitive of your fullstack library: database, backend, frontend, and background jobs. Otherwise you're stuck in a situation where users either have to always make blocking requests to an API or you spin up ephemeral tasks that will be killed during re-deployments or an accidental docker crash.
After trying most of the ecosystem in the last 3 years, I believe background jobs should provide a few key features:
- Easy to write control flow in normal Python
- Should be both very simple to test locally and very simple to deploy remotely
- Reasonable default configurations to scale to a reasonable request volume without performance tuning
On the point of control flow, we shouldn't be forced into a DAG definition (decorators, custom syntax). It should be regular control flow just distinguished because the flows are durable and because some portions of the parallelism can be run across machines.
Nothing on the market provides this balance - rappel aims to try. We don't expect ourselves to reach best in class functionality for load performance. Instead we intend for this to scale most applications well past product market fit.
Other options
When should you use Rappel?
- You're already using Python & Postgres for the core of your stack, either with Mountaineer or FastAPI
- You have a lot of async heavy logic that needs to be durable and can be retried if it fails (common with 3rd party API calls, db jobs, etc)
- You want something that works the same locally as when deployed remotely
- You want background job code to plug and play with your existing unit test & static analysis stack
- You are focused on getting to product market fit versus scale
Performance is a top priority of rappel. That's why it's written with a Rust core, is lightweight on your database connection by minimizing connections to ~1 per machine host, and runs continuous benchmarks on CI. But it's not the only priority. After all there's only so much we can do with Postgres as an ACID backing store. Once you start to tax Postgres' capabilities you're probably at the scale where you should switch to a more complicated architecture.
When shouldn't you?
- You have particularly latency sensitive background jobs, where you need <100ms acknowledgement and handling of each task.
- You have a huge scale of concurrent background jobs, order of magnitude >10k actions being coordinated concurrently.
- You have tried some existing task coordinators and need to scale your solution to the next 10x worth of traffic.
There is no shortage of robust background queues in Python, including ones that scale to millions of requests a second:
- Temporal.io
- Celery/RabbitMQ
- Redis
Almost all of these require a dedicated task broker that you host alongside your app. This usually isn't a huge deal during POCs but can get complex as you need to performance tune it for production. Cloud hosting of most of these are billed per-event and can get very expensive depending on how you orchestrate your jobs. They also typically force you to migrate your logic to fit the conventions of the framework.
Open source solutions like RabbitMQ have been battle tested over decades & large companies like Temporal are able to throw a lot of resources towards optimization. Both of these solutions are great choices - just intended to solve for different scopes. Expect an associated higher amount of setup and management complexity.
Worker Pool
start_workers is the main invocation point to boot your worker cluster on a new node. It launches the gRPC bridge plus a polling dispatcher that streams
queued actions from Postgres into the Python workers. You should use this as your docker entrypoint:
$ cargo run --bin start_workers
Development
Packaging
Use the helper script to produce distributable wheels that bundle the Rust executables with the Python package:
$ uv run scripts/build_wheel.py --out-dir target/wheels
The script compiles every Rust binary (release profile), stages the required entrypoints
(rappel-server, boot-rappel-singleton) inside the Python package, and invokes
uv build --wheel to produce an artifact suitable for publishing to PyPI.
Local Server Runtime
The Rust runtime exposes both HTTP and gRPC APIs via the rappel-server binary:
$ cargo run --bin rappel-server
Developers can either launch it directly or rely on the boot-rappel-singleton helper which finds (or starts) a single shared instance on
127.0.0.1:24117. The helper prints the active HTTP port to stdout so Python clients can connect without additional
configuration:
$ cargo run --bin boot-rappel-singleton
24117
The Python bridge automatically shells out to the helper unless you provide CARABINER_SERVER_URL
(CARABINER_GRPC_ADDR for direct sockets) overrides. Once the ports are known it opens a gRPC channel to the
WorkflowService.
Benchmarking
Stream benchmark output directly into our parser to summarize throughput and latency samples:
$ cargo run --bin bench -- \
--messages 100000 \
--payload 1024 \
--concurrency 64 \
--workers 4 \
--log-interval 15 \
uv run python/tools/parse_bench_logs.py
The `bench` binary seeds raw actions to measure dequeue/execute/ack throughput. Use `bench_instances` for an end-to-end workflow run (queueing and executing full workflow instances via the scheduler) without installing a separate `rappel-worker` binary—the harness shells out to `uv run python -m rappel.worker` automatically:
```bash
$ cargo run --bin bench_instances -- \
--instances 200 \
--batch-size 4 \
--payload-size 1024 \
--concurrency 64 \
--workers 4
Add `--json` to the parser if you prefer JSON output.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rappel-0.2.0.tar.gz.
File metadata
- Download URL: rappel-0.2.0.tar.gz
- Upload date:
- Size: 64.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c7cbedd339204cba1d680b85422f59abe8b74d27f4a8f40ceb3441bf605f64c
|
|
| MD5 |
cfddf4ea2f4bd29a2c0fa1f0edfd46c3
|
|
| BLAKE2b-256 |
a2b7773f1c76c88fd61a080f57e01406a3c63c1eb3df247c9f9750fa79a473c2
|
Provenance
The following attestation bundles were made for rappel-0.2.0.tar.gz:
Publisher:
ci.yml on piercefreeman/rappel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rappel-0.2.0.tar.gz -
Subject digest:
6c7cbedd339204cba1d680b85422f59abe8b74d27f4a8f40ceb3441bf605f64c - Sigstore transparency entry: 732314561
- Sigstore integration time:
-
Permalink:
piercefreeman/rappel@f35ff2ec7cec41503b713da30f097629228465da -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/piercefreeman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f35ff2ec7cec41503b713da30f097629228465da -
Trigger Event:
push
-
Statement type:
File details
Details for the file rappel-0.2.0-py3-none-win_amd64.whl.
File metadata
- Download URL: rappel-0.2.0-py3-none-win_amd64.whl
- Upload date:
- Size: 49.9 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a6a79d3aba7f392c470852c16b62d917dac59a8a5cc9ad4caaba502078996818
|
|
| MD5 |
ce9216fa42c669012f783aa4834579d7
|
|
| BLAKE2b-256 |
4cbfee0831c51fc22b0b6f29ed5da541c7b74bc122f87dce44dca8f02e5737b0
|
Provenance
The following attestation bundles were made for rappel-0.2.0-py3-none-win_amd64.whl:
Publisher:
ci.yml on piercefreeman/rappel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rappel-0.2.0-py3-none-win_amd64.whl -
Subject digest:
a6a79d3aba7f392c470852c16b62d917dac59a8a5cc9ad4caaba502078996818 - Sigstore transparency entry: 732314631
- Sigstore integration time:
-
Permalink:
piercefreeman/rappel@f35ff2ec7cec41503b713da30f097629228465da -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/piercefreeman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f35ff2ec7cec41503b713da30f097629228465da -
Trigger Event:
push
-
Statement type:
File details
Details for the file rappel-0.2.0-py3-none-manylinux_2_39_x86_64.whl.
File metadata
- Download URL: rappel-0.2.0-py3-none-manylinux_2_39_x86_64.whl
- Upload date:
- Size: 65.7 MB
- Tags: Python 3, manylinux: glibc 2.39+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
319d1307778435360d2e5694e0822bc6a9a11559183c0b7b0f699ab5f4a70f8f
|
|
| MD5 |
5f0e4ac76e184d6838aad094721cd98e
|
|
| BLAKE2b-256 |
df1848db0c99d81565424142233a8a7530a666ff713288d0a2c2436f3eea6077
|
Provenance
The following attestation bundles were made for rappel-0.2.0-py3-none-manylinux_2_39_x86_64.whl:
Publisher:
ci.yml on piercefreeman/rappel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rappel-0.2.0-py3-none-manylinux_2_39_x86_64.whl -
Subject digest:
319d1307778435360d2e5694e0822bc6a9a11559183c0b7b0f699ab5f4a70f8f - Sigstore transparency entry: 732314600
- Sigstore integration time:
-
Permalink:
piercefreeman/rappel@f35ff2ec7cec41503b713da30f097629228465da -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/piercefreeman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f35ff2ec7cec41503b713da30f097629228465da -
Trigger Event:
push
-
Statement type:
File details
Details for the file rappel-0.2.0-py3-none-macosx_15_0_arm64.whl.
File metadata
- Download URL: rappel-0.2.0-py3-none-macosx_15_0_arm64.whl
- Upload date:
- Size: 52.4 MB
- Tags: Python 3, macOS 15.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
038605097454f7cbb2cbcd09f512a7ccc6cc632f46829adffd60d2a42cc3fe10
|
|
| MD5 |
5d6a6bade40a8e3f0d14d3d89cf3ca5e
|
|
| BLAKE2b-256 |
6f7e0fae5602accac1ab338269bf83c2260f701ede086da380c157f118105a84
|
Provenance
The following attestation bundles were made for rappel-0.2.0-py3-none-macosx_15_0_arm64.whl:
Publisher:
ci.yml on piercefreeman/rappel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rappel-0.2.0-py3-none-macosx_15_0_arm64.whl -
Subject digest:
038605097454f7cbb2cbcd09f512a7ccc6cc632f46829adffd60d2a42cc3fe10 - Sigstore transparency entry: 732314659
- Sigstore integration time:
-
Permalink:
piercefreeman/rappel@f35ff2ec7cec41503b713da30f097629228465da -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/piercefreeman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@f35ff2ec7cec41503b713da30f097629228465da -
Trigger Event:
push
-
Statement type: