srathbun/promiseflow

PromiseFlow is a Python implementation of the promise-based parallel processing model described in ACM Queue. It provides composable promises for coordinating asynchronous and parallel computation across threads and processes, allowing complex workflows to be expressed as chains of dependent operations.

★ 0Forks 0PythonGitHub ↗Compare

README

promiseflow

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

Install

pip install promiseflow

Quickstart

Single unit of work

Use 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).

Composable chains

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:

  • scan runs once. Both users share the result.
  • sort runs once. Both users share the sorted output.
  • group and limit each 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.

Writing your own step functions

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_result

Steps 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.

Retention

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 everything

The 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.
  • StaleWhileRevalidate serves the last value while a single background rebuild refreshes it (builds stay single-flight, so a stampede triggers one rebuild).

Chain segment keys

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.

Advanced chain semantics

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 under AdvancedChain.cache_point_retention (a RetentionPolicy, default Ttl(3600)) and reused across flights (later run() 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 forced Ephemeral: 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.

Distributed coordination (0.2)

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 / FAILED messages are the wake signal. Payloads are generation-scoped and evicted by a bounded TTL (RedisCoordinator's payload_ttl).
  • Every call returns the owner's result by value, so results must be serializable by the backend codec (pickle by default; pluggable).
  • Chain works unchanged with RedisCoordinator.
  • LockBackend / PayloadStore / MessageBus protocols make the backend replaceable; RedisBackend is the shipped implementation.

Status

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.

Contributors

srathbun

Issues