Two requests hit your agent at the same time. Both want a 30-second answer. Without a scheduler, the second caller waits 30s for the first to finish, then 30s more for their own. A single-threaded agent is a queue of one — and queues of one are how production starts melting. The scheduler sits between theDocumentation Index
Fetch the complete documentation index at: https://docs.getbindu.com/llms.txt
Use this file to discover all available pages before exploring further.
TaskManager (your HTTP layer) and the ManifestWorker (your handler runner). Submission is non-blocking: the caller gets a task_id immediately and goes away. The worker pulls operations off the queue and executes them. Storage holds the heavy state; the queue stays lean.
In-memory for local dev. Redis for production. Same bindufy(config, handler) either way — you flip one or two environment variables.
Single process and don’t care about restart survival?
SCHEDULER_TYPE=memory is
the default, no setup needed. Multiple processes, horizontal scaling, or
durable queueing required? SCHEDULER_TYPE=redis + REDIS_URL and you’re done.What the scheduler actually schedules
Not raw functions. Not HTTP requests. The scheduler queues task operations — small typed envelopes that tell the worker what to do with atask_id whose data already lives in storage.
Non-blocking submit
run_task() returns as soon as the operation lands on the queue. The HTTP
response carries the task_id, not the answer.Concurrent dispatch
Multiple workers (or multiple agent processes, with Redis) pop from the same
queue. Redis guarantees each operation is delivered to exactly one worker.
Backpressure
The in-memory queue is bounded (100 ops). If producers outrun the worker, the
sender awaits — you get backpressure instead of unbounded memory growth.
Backends at a glance
memory | redis | |
|---|---|---|
| Selected by | SCHEDULER_TYPE=memory (default) | SCHEDULER_TYPE=redis |
| Transport | anyio memory object stream (buffer = 100) | Redis LIST (RPUSH / BLPOP) |
| Cross-process | No — single Python process only | Yes — many processes, many hosts |
| Restart survival | Lost on process exit | Pending ops survive; in-flight do not (see note) |
| Trace context | Live OpenTelemetry Span reference | Serialized trace_id / span_id |
| Extra deps | None | redis[hiredis] and a running Redis |
| Best for | Local dev, single-replica deploys, tests | Production, horizontal scaling, restart-safe queueing |
Delivery semantics are at-least-once-on-enqueue, not exactly-once-on-execute.
BLPOP atomically removes the operation from the queue before the worker
starts executing. If the worker crashes mid-handler, the operation is gone — the
task body is still in storage (state = working), but no one will retry it
unless you restart it explicitly. There is no visibility-timeout or
redelivery layer. For execution-level resilience, see Retry.The dispatch flow
Submission and execution never touch each other on the call stack. The HTTP request ends the moment the operation is on the queue.Backends
In-memory scheduler
InMemoryScheduler. anyio memory object stream, bounded buffer of 100,
zero external dependencies. The default.Redis scheduler
RedisScheduler. Redis list with RPUSH + BLPOP, connection pool of 10,
default queue key bindu:tasks.In-memory backend
TaskManager) and a consumer (ManifestWorker) share an anyio memory object stream. The stream is bounded at 100 operations — once full, run_task() awaits until the worker drains it. That’s intentional: an unbounded queue would let a runaway producer eat the process’s RAM.
Trace context is preserved by stashing a live OpenTelemetry Span reference directly in the operation envelope (_current_span). Cheap and exact, but only works inside one process — which is fine, since this backend is one process by definition.
Redis backend
LIST (not a Stream, not a Sorted Set) keyed at bindu:tasks by default. Producers RPUSH JSON-serialized operations to the tail; consumers BLPOP from the head, blocking up to poll_timeout seconds (default 1s) before retrying. Atomic on the Redis side — one operation, one worker.
Because operations cross process boundaries, the live Span from the in-memory case won’t survive serialization. The Redis scheduler instead writes trace_id / span_id into the envelope so the worker can reconstruct a child span on the other side.
The connection pool is sized at max_connections=10, with retry_on_timeout=True. If Redis goes briefly unreachable mid-BLPOP, the receive loop logs and backs off 1 second before retrying — no tight CPU loop. Producer-side, every run_task / cancel_task / pause_task / resume_task call is wrapped in @retry_scheduler_operation (Tenacity), so a transient blip won’t drop your enqueue.
Configuration
The scheduler reads fromSchedulerSettings (Pydantic BaseSettings) in bindu/settings.py. Environment variables override defaults. The factory builds a SchedulerConfig from settings if you don’t pass one.
Memory backend env vars
Memory backend env vars
Nothing is required. No URL, no pool, no queue name. The buffer (100) is a code-level constant — change it by subclassing
SCHEDULER_TYPE=memory is the default.InMemoryScheduler if you really need to.Redis backend env vars
Redis backend env vars
SchedulerSettings:| Field | Env var | Default |
|---|---|---|
backend | SCHEDULER_TYPE | memory |
redis_url | REDIS_URL | None |
poll_timeout | REDIS_POLL_TIMEOUT | 1 |
queue_name | — | bindu:tasks |
max_connections | — | 10 |
retry_on_timeout | — | true |
redis_host, redis_port, redis_password, redis_db are also accepted on
the dataclass — the factory will assemble a URL from them if REDIS_URL is
not provided. Prefer the URL form; it’s less error-prone.Retry tuning (scheduler operations)
Retry tuning (scheduler operations)
Scheduler enqueue/cancel calls are wrapped with These cover transient producer-side failures (e.g. brief Redis disconnect during
@retry_scheduler_operation.
Defaults from RetrySettings:RPUSH). They do not retry the agent handler — that’s the worker’s job, see
Retry.Setup
Default: nothing to do
SCHEDULER_TYPE is unset, so the factory returns InMemoryScheduler. Run
uv run python main.py and you’re scheduled.Switch to Redis
Start Redis locally (Docker is fine):Set two env vars and restart:On boot you’ll see
Redis scheduler connected to redis://localhost:6379/0.
The factory does a PING first — if Redis is unreachable, startup fails
loudly instead of silently degrading.Scale horizontally
Point a second process at the same Redis:Both processes call
BLPOP against bindu:tasks. Redis hands each operation
to exactly one waiter. No coordination code from you.Choosing a backend
Pick memory when…
Pick memory when…
- You’re developing locally and want zero setup.
- You’re running a single process and don’t need restart survival.
- You’re writing tests — the in-memory backend is deterministic and fast.
- You don’t care if a process crash drops a few pending submits.
Pick Redis when…
Pick Redis when…
- You run more than one agent process (multiple replicas, blue/green, autoscale).
- You need pending submissions to survive a restart or redeploy.
- You want to observe queue depth from outside the agent (
LLEN bindu:tasks). - You’re already running Redis for storage or rate-limiting and want to consolidate.
What survives a restart, exactly
What survives a restart, exactly
Memory backend: nothing. The stream lives in the process; both the queue
and any in-flight handler die together. Storage state is still consistent
(the task row will show
submitted or working forever until you clean it).Redis backend: pending ops in bindu:tasks survive — when a new worker
starts, it BLPOPs them. In-flight ops (already popped, handler running)
are not recovered. The task body remains in storage; you can re-enqueue it
yourself, but the scheduler will not.Programmatic factory
Most agents don’t need this — env vars are enough. But the factory is callable directly if you’re embedding Bindu or writing tests:RedisScheduler lazily inside a try/except ImportError. If you don’t have redis installed and you ask for type="redis", you get a clear ValueError: Redis scheduler requires redis package. Install with: pip install redis[hiredis] — not an obscure import crash on startup.
Observability and debug helpers
The Redis backend exposes a few helpers that are handy in ops:trace_id / span_id (Redis) or a live Span (memory), so worker spans link back to the HTTP submit span in your OTLP backend. See Observability.
Operational notes
Secrets in env, not config
REDIS_URL belongs in .env (dev) or your orchestrator’s secret manager
(prod). Don’t bake connection strings into the config dict you pass to
bindufy() — that ends up in git.Use TLS across networks
If your Redis isn’t on
localhost or a private network, use rediss://
(TLS) and a password. Upstash, ElastiCache-in-transit-encryption, and
Redis Cloud all support it.Tune `poll_timeout` deliberately
The default
1s is a good tradeoff. Lower values reduce task-start latency
but increase Redis command rate. Higher values are cheaper on managed Redis
with command-based pricing.No visibility timeout
A popped operation is gone from the queue. If you need redelivery on crash,
wrap your handler in retry logic and persist intent before doing
irrecoverable side effects.
Related
- Retry — execution-level retries inside the handler
- Storage — where the task body actually lives
- Observability — tracing the dispatch flow end-to-end
- Architecture — how scheduler, storage, and worker fit together