Spedo
SPEDO ENGINE
v0.70.0 PREVIEW
← Documentation

Reactive Live Data, Distributed State & CDC

Spedo couples in-process reactive state with one central RESP store. It provides

Live Variables, optional Change Data Capture (CDC), and a Write-Behind helper

for external SQL databases. These are preview features, not a replacement for

replication, durable messaging, or cross-node high availability.


1. Distributed Live Variables (SpedoLiveVar)

A SpedoLiveVar behaves like a regular Python variable, but its value can be

coordinated between application instances that connect to the same Spedo node.

It does not make the underlying node highly available.

Reading .value returns the local in-process copy. On an invalidation, the client reloads the key from Spedo before notifying callbacks. The first load and each invalidated reload are network operations; an unchanged local read is not.

The data path is push invalidation + pull refresh:

1. assigning .value updates the writer's local copy and sends SET;

2. Spedo publishes the invalidated key name through SPEDO.WATCH;

3. other bound clients issue one GET, replace their local copy, then notify

callbacks and async waiters;

4. subsequent .value reads stay inside the Python process.

default is a local fallback when the server key does not exist; binding does

not create that key. Assign .value explicitly to publish an initial value.

For dictionaries and lists, assign a new value instead of mutating

bound.value[...] in place, because only assignment invokes the setter and

sends SET.

Usage Example

from spedo_client import SpedoClient

client = SpedoClient(host="127.0.0.1", port=6380)

# Bind a reactive variable to a key. `bind_var` is the current SDK API.
feature_flag = client.bind_var("flags:new_checkout_flow", default=False)

# Local memory read when the cached value is current
if feature_flag.value:
    print("New checkout enabled!")

# When any pod modifies the value:
feature_flag.value = True
# Other connected pods receive an invalidation and reload the value.

Real-world example: a live payment kill switch

from spedo_client import SpedoClient

api = SpedoClient(host="spedo", port=6380)
control = SpedoClient(host="spedo", port=6380)

payment_policy = api.bind_var(
    "runtime:payments",
    default={"enabled": True, "max_amount": 2500},
)
control_policy = control.bind_var(
    "runtime:payments",
    default={"enabled": True, "max_amount": 2500},
)

@payment_policy.on_change
def policy_changed(policy):
    print("Payment policy reloaded:", policy)

# One control-plane SET. Connected API processes invalidate and reload once.
control_policy.value = {
    "enabled": False,
    "max_amount": 0,
    "reason": "provider incident",
}

def authorize_payment(amount):
    policy = payment_policy.value  # local in-process read on the hot path
    if not policy["enabled"]:
        return {"status": "temporarily_unavailable"}
    verdict = "accepted" if amount <= policy["max_amount"] else "review"
    return {"status": verdict}

The v0.41 feature harness measured 1,579,176 local .value reads/s versus

1,497 Redis TCP GET calls/s in its Python loops. This is an application-path

comparison: the gain comes from removing TCP from repeated reads, while each

change still pays one server write and one reload per connected binding.

Async Waiting for State Changes

import asyncio

async def monitor_task():
    # Wait until status becomes "ready"
    await task_status.wait_until("ready", timeout=10.0)
    print("Task is now ready!")

    # Or wait for any change
    new_val = await task_status.wait_change(timeout=5.0)
    print(f"Status changed to: {new_val}")

Python class instances (serializer="pickle")

json is the default serializer. Use pickle when the distributed value is a

real Python instance rather than a JSON-compatible value:

from dataclasses import dataclass

@dataclass
class Account:
    id: int
    roles: list[str]

account = client.bind_var(
    "account:42",
    default=Account(42, ["read"]),
    serializer="pickle",
    trusted=True,
)

trusted=True is required to restore application classes. Enable it only for

keys written exclusively by trusted code: a hostile pickle can execute code on

load. Built-in values (dict, list, strings, numbers, bytes…) can use

serializer="pickle" without trusted=True.

bind_live is not a current SDK method; older website examples used that name.

Use bind_var in new code.


2. Zero-Boilerplate Auto-Sync Globals (from spedo import auto_sync)

auto_sync is a convenience layer for small, low-contention module globals.

It wraps functions already defined in the module and performs a **pull before

each wrapped call, then a push after a detected change** to one Spedo node. It

is not a subscription protocol, a distributed transaction, or a cross-instance

consistency guarantee.

How it works

bytecode (func.__code__.co_names) and wraps the functions it finds. A

function defined later is not wrapped automatically. co_names is a

bytecode name list, not a semantic declaration of writable globals, so keep

wrapped functions narrow and avoid assuming every discovered name is a

business-state variable.

discovered name and replaces the local global only when a remote value

exists.

changed values are written with SET global:<name>.

dict/list value when it changes; an in-place mutation can compare equal

to that same reference and therefore may not be pushed.

or transaction. Other instances see an update only when one of their own

wrapped calls starts and pulls from Spedo.

Concurrent read-modify-write operations are therefore non-atomic. If two

instances pull the same value and both write a replacement, the last SET

accepted by the server wins and an increment can be lost. Use an explicit

atomic server command or a workload-specific coordination design for counters,

money, reservations, or any correctness-sensitive shared state.

dict and list values are JSON-encoded; other values are converted to text.

Do not use auto_sync for arbitrary Python objects or values that need exact

type preservation.

Usage Example:

from spedo import auto_sync

# 1. Declare standard Python variables (NO wrappers, NO special objects)
payment_total = 0
active_orders = 0
promo_banner  = "WELCOME_2026"

# 2. Write regular Python functions.  They must exist before auto_sync() runs.
def process_payment(amount: int):
    global payment_total, active_orders
    payment_total = int(payment_total) + amount
    active_orders = int(active_orders) + 1

def update_promo(new_banner: str):
    global promo_banner
    promo_banner = new_banner

# 3. One call wraps the functions currently defined in this module.
# Each wrapped call pulls first and pushes detected changes afterward.
auto_sync(globals())

# This is a non-atomic read-modify-write operation: do not use it for a counter
# whose updates must never be lost under concurrent writers.
process_payment(50)

3. Distributed Live Dictionaries (SpedoLiveDict)

Synchronize complex structured dictionaries with fine-grained field updates across pods:

cluster_config = client.bind_dict("config:cluster_nodes", default={"master": "node-1"})

# Read keys locally in 0 ms
print(cluster_config["master"])

# Update dictionary keys
cluster_config["worker_count"] = 8
cluster_config.update({"region": "eu-west-1", "active": True})

# Mutate nested dictionary
with cluster_config.mutate() as d:
    d["last_heartbeat"] = 1787255000

3. Native Change Data Capture (CDC)

CDC (Change Data Capture) is an ordered in-memory event stream of mutations

(SET, DEL, etc.) with timestamps and sequence IDs. It is **disabled by

default because recording each write has a cost. Enable cdc in Admin →

Observability & Debug**, or start the server with SPEDO_CDC_ENABLED=true.

The log is bounded and non-persistent: it is not a backup and consumers must

keep their checkpoint / handle missed history. CDC is bounded and in-memory:

it is not a durable event log or recovery mechanism.

Querying the CDC Log

# Get the latest sequence ID
latest_seq = client.cdc_latest()

# Read the last 50 mutation events starting from a sequence ID
events, new_latest = client.cdc_read(last_id=0, count=50)

for event in events:
    print(
        f"[SEQ #{event['id']}] "
        f"OP={event['op']} | "
        f"KEY={event['key']} | "
        f"VAL={event['value']} | "
        f"TS={event['timestamp_ms']}"
    )

4. Asynchronous Write-Behind to SQL Databases

With Write-Behind synchronization, application writes are sent to Spedo while a

background worker consumes the CDC stream in micro-batches and persists records

to PostgreSQL, SQLite, MySQL, or ClickHouse. It requires CDC to be enabled.

Write-behind enqueue throughput and the server ACK throughput are different

measurements; use an explicit flush/error policy when durability matters.

import sqlite3
import json

# Setup external SQL database
db_conn = sqlite3.connect("app.db", check_same_thread=False)

def sync_mutations_to_sql(batch_events):
    """Callback invoked by the background worker for each batch of mutations."""
    cursor = db_conn.cursor()
    for ev in batch_events:
        key = ev["key"]
        if key.startswith("user:"):
            user_id = key.split(":")[-1]
            if ev["op"] == "SET" and ev["value"]:
                val_str = ev["value"].decode() if isinstance(ev["value"], bytes) else str(ev["value"])
                cursor.execute(
                    "INSERT INTO users (id, payload, updated_at) VALUES (?, ?, ?) "
                    "ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at",
                    (user_id, val_str, ev["timestamp_ms"])
                )
            elif ev["op"] == "DEL":
                cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
    db_conn.commit()

# Start the background sync worker
worker = client.db_sync_worker(
    on_mutation=sync_mutations_to_sql,
    poll_interval=0.05, # Check every 50ms
    batch_size=100
).start()

# Application writes only to Spedo (ultra-fast, zero SQL bottleneck)
client.set("user:101", json.dumps({"name": "Alice", "status": "active"}))
client.set("user:102", json.dumps({"name": "Bob", "status": "pro"}))

# The worker automatically writes records into the SQL database in the background!

# Graceful shutdown when application stops
worker.stop()