PromiseFlow is a Python implementation of the promise-based parallel processing model described in ACM Queue Parallel Processing with Promises. It provides composable promises for coordinating asynchronous and parallel computation, allowing complex workflows to be expressed as chains of dependent operations where duplicate work is automatically eliminated.
- one owner performs a keyed unit of work
- followers join the same future and receive the result without repeating work
- stale workers are timed out by heartbeat sweeps
- retries happen through a simple policy
- composable chains let multiple callers share intermediate results
pip install promiseflowUse Coordinator.get_or_run directly when you have a single keyed operation
that many callers might request concurrently:
import asyncio
from promiseflow import Coordinator, RetryPolicy
async def main() -> None:
async with Coordinator(stale_after=3.0, sweep_interval=0.5) as coordinator:
async def expensive() -> str:
await asyncio.sleep(0.2)
return "done"
result = await coordinator.get_or_run(
"job:42",
expensive,
timeout=5.0,
retry=RetryPolicy(max_attempts=3),
heartbeat_interval=0.25,
)
print(result)
asyncio.run(main())If another caller requests "job:42" while the first is still running, it
hooks onto the same future and waits — no duplicate work. After that run
finishes, a later request for the same key starts fresh work (ephemeral
retention; see Semantics).
The real power shows up when work can be broken into named segments that are
shared across callers. Chain hashes the initial input together with the
ordered sequence of step names and callable identities to build a deduplication
key for each segment. Two concurrent chains that share a prefix automatically
share intermediate results. Reuse a name only when the work is meant to be
shared.
Consider a data pipeline where several users issue queries against a database. Each query is a pipeline of operations — scan, sort, group, limit — and many queries share a common prefix:
import asyncio
from promiseflow import Coordinator, Chain
async def scan(params):
"""Simulate an expensive database scan."""
await asyncio.sleep(1.0)
return [{"x": 1}, {"x": 2}, {"x": 3}]
async def sort_rows(rows):
"""Sort results by x."""
return sorted(rows, key=lambda r: r["x"])
async def group_rows(rows):
"""Group/aggregate the sorted results."""
return {"count": len(rows), "sum": sum(r["x"] for r in rows)}
async def limit_rows(rows):
"""Return only the first two rows."""
return rows[:2]
async def main() -> None:
async with Coordinator(stale_after=10.0, sweep_interval=1.0) as coordinator:
# User A: scan → sort → group
async def user_a():
return await (
Chain(coordinator)
.add("scan", scan)
.add("sort", sort_rows)
.add("group", group_rows)
.run()
)
# User B: scan → sort → limit
async def user_b():
return await (
Chain(coordinator)
.add("scan", scan)
.add("sort", sort_rows)
.add("limit", limit_rows)
.run()
)
# Both users run concurrently. The scan and sort steps execute
# exactly once even though two users requested them.
result_a, result_b = await asyncio.gather(user_a(), user_b())
print("User A (group):", result_a)
# -> {'count': 3, 'sum': 6}
print("User B (limit):", result_b)
# -> [{'x': 1}, {'x': 2}]
asyncio.run(main())In this example:
scanruns once. Both users share the result.sortruns once. Both users share the sorted output.groupandlimiteach run once — they diverge at this point, so each gets its own deduplication key.
This is exactly how the original implementation worked for MongoDB aggregation pipelines: each operation in the aggregation pipeline was a named step, and the coordinator ensured that concurrent queries with shared prefixes didn't re-run the same expensive database scans.
A step function is any async callable that takes one argument (the output
of the previous step) and returns a value:
async def my_step(previous_result):
# Do work with previous_result ...
return transformed_resultSteps are composed by name so the coordinator can tell them apart. Identical names with the same initial input share work under concurrency, even if the callables differ — choose unique names when steps are different:
chain = (
Chain(coordinator)
.add("fetch", fetch_from_db)
.add("transform", apply_business_rules)
.add("enrich", call_external_api)
)
result = await chain.run(initial={"query": "..."})If you need retries within a chain (for example, a step that calls a flaky
external service), pass a RetryPolicy:
from promiseflow import RetryPolicy
result = await chain.run(
initial={"query": "..."},
retry=RetryPolicy(max_attempts=3, base_delay=0.1),
)By default, chain steps use max_attempts=1 so errors surface immediately
rather than being silently retried.
Completed results are governed by a pluggable RetentionPolicy, shared by both
Coordinator (in-process) and RedisCoordinator (distributed):
| Policy | Behavior |
|---|---|
Ephemeral (default) |
Drop the result once the current waiters finish; a later caller recomputes. Pure single-flight. |
Ttl(lifetime) |
Reuse the result for lifetime seconds, then rebuild. |
Manual() |
Reuse until you call invalidate(key) / clear(). |
StaleWhileRevalidate(lifetime) |
Serve an expired result immediately and refresh it in the background. |
from promiseflow import Coordinator, Ttl, Manual, StaleWhileRevalidate
async with Coordinator(retention=Ttl(lifetime=30.0)) as c:
v = await c.get_or_run("key", expensive) # computed once
v = await c.get_or_run("key", expensive) # reused (cached)
async with Coordinator(retention=Manual()) as c:
v = await c.get_or_run("key", expensive) # computed once
c.invalidate("key") # force recompute next time
c.clear() # drop everythingThe same policy objects work with RedisCoordinator; its invalidate / clear
are awaited (they hit Redis):
async with RedisCoordinator(backend, retention=Ttl(30.0)) as c:
...
await c.invalidate("key") # or: await c.clear()invalidate(key)— drop one key's retained result; the next call recomputes.clear()— drop all retained results.StaleWhileRevalidateserves the last value while a single background rebuild refreshes it (builds stay single-flight, so a stampede triggers one rebuild).
Segment keys are hash(initial, ordered step names, callable identities).
Two distinct functions sharing a name do not alias. Reuse a name only when the
work is meant to be shared.
AdvancedChain extends Chain with three paper-level behaviors for controlling
how long segment results live — cache points, cascade rebuild, and uncached
tails. It is built directly on the same Coordinator single-flight machinery.
from promiseflow import AdvancedChain, CachePointMode
chain = AdvancedChain(coordinator, cache_point_mode=CachePointMode.CACHE_POINTS_ONLY)
chain.add("fetch", fetch_from_db)
chain.add("analyze", run_model, is_cache_point=True) # retained for reuse
chain.add("transform", apply_rules)
chain.add("report", build_report, uncached_tail=True) # fresh every flight
result = await chain.run(path)The three semantics:
- Cache points (
is_cache_point=True) — the step's result is retained underAdvancedChain.cache_point_retention(aRetentionPolicy, defaultTtl(3600)) and reused across flights (laterrun()calls with the same upstream value). - Cascade rebuild — each segment key folds in the forwarded value of the previous segment, not merely the step path. When an upstream retained result is invalidated and recomputes to a different value, every downstream segment key changes and recomputes; when the value is unchanged, downstream cache entries are correctly reused.
- Uncached tail (
uncached_tail=True) — the step is forcedEphemeral: it still single-flights within a flight (concurrent callers share one run — they would produce identical output, so running it repeatedly adds no value), but recomputes on every future flight (e.g. a user re-running a report to see fresh results).
Two modes select the default retention for steps not explicitly marked:
| Mode | Behavior |
|---|---|
CachePointMode.CACHE_POINTS_ONLY (default) |
Only is_cache_point steps retain; the rest are ephemeral. |
CachePointMode.FULL_CHAIN |
Every step retains, except uncached_tail steps. |
create_cache_points_only_chain(coordinator) and create_full_chain(coordinator)
are convenience factories. AdvancedChain accepts a Coordinator or a
RedisCoordinator, so these semantics work in-process and distributed.
RedisCoordinator gives the same single-flight semantics across processes and
servers, backed by Redis:
import asyncio
import redis.asyncio as redis
from promiseflow import RedisCoordinator, RedisBackend, RetryPolicy
async def main() -> None:
client = redis.from_url("redis://localhost:6379")
backend = RedisBackend(client, namespace="my-app")
async with RedisCoordinator(backend) as coordinator:
async def expensive() -> dict:
await asyncio.sleep(0.5)
return {"computed": True}
result = await coordinator.get_or_run(
"job:42",
expensive,
retry=RetryPolicy(max_attempts=3),
)
print(result)
asyncio.run(main())Cliff notes:
- One owner per key acquires a Redis lock (
SET NX); followers in other processes join the same generation. - Lock TTL + heartbeat reclaim dead owners (no polling; a stale lock simply expires).
- Temporary payload key carries the result to followers; pub/sub
BUILT/FAILEDmessages are the wake signal. Payloads are generation-scoped and evicted by a bounded TTL (RedisCoordinator'spayload_ttl). - Every call returns the owner's result by value, so results must be serializable by the backend codec (pickle by default; pluggable).
Chainworks unchanged withRedisCoordinator.LockBackend/PayloadStore/MessageBusprotocols make the backend replaceable;RedisBackendis the shipped implementation.
Version 1.0.0 is the complete library. On top of the 0.3.0 feature set
(full retention policies across Coordinator and RedisCoordinator, hardened
segment keys, observability hooks), it adds advanced chain semantics —
AdvancedChain with cache points, cascade rebuild, and uncached tails — plus a
per-call retention override on Coordinator.get_or_run. See
docs/plan.md / docs/roadmap.md for the
closed gap checklist.