diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2ac960..1a3e704 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
+### Added
+
+- `Drasi.create` and `Drasi.from_config` accept
+ `wal_provider={"path": ...}`. The built-in redb provider stores one WAL file
+ per durable transient source in that directory; `kind` defaults to `redb`.
+- `add_python_source(..., durability=True)` persists changes before acknowledging
+ them and replays them after a fresh-process restart. The async and blocking
+ APIs expose the same option.
+
+### Changed
+
+- Volatile Python sources now report that they cannot replay changes. Persistent
+ queries must use a durable Python source with a configured WAL provider.
+
## [0.3.0] - 2026-10-01
Built against drasi-lib 0.9. This is a minor release because two behaviour
diff --git a/Cargo.lock b/Cargo.lock
index bd08b61..dd3e93e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -897,6 +897,7 @@ dependencies = [
"drasi-lib",
"drasi-plugin-sdk",
"drasi-state-store-redb",
+ "drasi-wal-redb",
"futures-util",
"log",
"pyo3",
@@ -956,6 +957,23 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "drasi-wal-redb"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a220606c2dc3f9d6857ed14083a01864bec8c16ad62ddfd46af536ef3f93e2"
+dependencies = [
+ "async-trait",
+ "bincode",
+ "drasi-core",
+ "drasi-lib",
+ "log",
+ "ordered-float",
+ "redb",
+ "serde",
+ "tokio",
+]
+
[[package]]
name = "dunce"
version = "1.0.5"
diff --git a/Cargo.toml b/Cargo.toml
index 70929fe..34a28b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,7 @@ drasi-core = "=0.5.10"
drasi-host-sdk = { version = "=0.11.3", features = ["watcher", "registry"] }
drasi-plugin-sdk = "=0.11.3"
drasi-state-store-redb = "=0.2.8"
+drasi-wal-redb = "=0.2.10"
drasi-index-rocksdb = { version = "=0.6.4", optional = true, features = ["plugin-descriptor"] }
anyhow = "1"
diff --git a/README.md b/README.md
index 9045066..bb6aa18 100644
--- a/README.md
+++ b/README.md
@@ -163,8 +163,12 @@ drasi = await Drasi.create(
"app",
state_store={"kind": "redb", "path": "state.redb"},
index_store={"kind": "rocksdb", "path": "index"},
+ wal_provider={"path": "wal"},
)
+await drasi.start()
+await drasi.add_python_source("orders", durability=True)
+
async def handle(event):
await write_somewhere(event) # if this raises, the event is retried
diff --git a/docs/api-audit.md b/docs/api-audit.md
index 421663e..a06d128 100644
--- a/docs/api-audit.md
+++ b/docs/api-audit.md
@@ -25,7 +25,7 @@ one genuine advantage — streaming as async iterators — has been carried over
| Components | **loaded at runtime** from `ghcr.io/drasi-project` | **linked at compile time**, one PyPI package each |
| PyPI packages | 1 | 23 + workspace root |
| Engine methods (at audit) | 37 | 27 |
-| Errors | 8 classes, 26 codes | 1 class, no codes |
+| Errors | 8 classes, 30 codes | 1 class, no codes |
| Streaming | none | async iterators |
| Python | ≥3.10 (abi3) | ≥3.11 |
@@ -185,6 +185,7 @@ At audit: 30 of 48 methods (62%). **Now 48 of 48 (100%).**
| Secret store | `secrets`, and the resolver plugins call back through | ✅ |
| State store | redb | ✅ |
| Index store | RocksDB, behind a Cargo feature | ✅ |
+| WAL provider | redb, for durable transient Python sources | ✅ |
| Identity | password and token | ✅ |
**21 methods exist here that Node does not have**: the eight streaming
@@ -198,17 +199,17 @@ iterators (`query_results`, `query_events`, `source_events`,
## 4. Gap analysis vs the `drasi-lib` engine
-Comparing against `drasi-lib` 0.8.9 `src/lib_core_ops/`, the engine also offers,
-unexposed by *either* prototype:
+Comparing against `drasi-lib` 0.8.9 `src/lib_core_ops/`, the engine also offers
+the following surface. The WAL-provider row is now closed:
-| Engine capability | Notes |
-| --- | --- |
-| `get_query_config` / `get_query_info` | inspect a running query |
-| `get_source_info` / `get_reaction_info` | |
-| `snapshot_configuration` / `get_current_config` | round-trip config to disk |
-| `subscribe_all_component_events` | |
-| Query tuning: `with_middleware`, `with_recovery_policy`, `with_storage_backend`, `with_outbox_capacity`, `dispatch_mode` | `Query` builder options; only `joins` and `language` are exposed today |
-| WAL providers | |
+| Engine capability | Notes | Status |
+| --- | --- | --- |
+| `get_query_config` / `get_query_info` | inspect a running query | |
+| `get_source_info` / `get_reaction_info` | | |
+| `snapshot_configuration` / `get_current_config` | round-trip config to disk | |
+| `subscribe_all_component_events` | | |
+| Query tuning: `with_middleware`, `with_recovery_policy`, `with_storage_backend`, `with_outbox_capacity`, `dispatch_mode` | `Query` builder options; only `joins` and `language` are exposed today | |
+| WAL providers | redb provider plus per-Python-source durability opt-in | ✅ |
These are lower priority than the Node parity gaps — they are engine surface
neither binding has needed yet — but `snapshot_configuration` pairs naturally
diff --git a/python/drasi/_drasi.pyi b/python/drasi/_drasi.pyi
index ddc2a28..7c02dec 100644
--- a/python/drasi/_drasi.pyi
+++ b/python/drasi/_drasi.pyi
@@ -44,6 +44,7 @@ from .types import (
SourceSchema,
SourceSubscription,
StateStore,
+ WalProvider,
)
# Directory arguments reach Rust as a PathBuf, which accepts anything
@@ -103,6 +104,7 @@ class Drasi:
secrets: Mapping[str, str] | None = None,
state_store: StateStore | None = None,
index_store: IndexStore | None = None,
+ wal_provider: WalProvider | None = None,
identity: Identity | None = None,
plugins_dir: StrPath | None = None,
) -> Awaitable[Drasi]: ...
@@ -164,7 +166,9 @@ class Drasi:
def wait_for_query(self, id: str, *, timeout: float = 30.0) -> Awaitable[None]: ...
# components defined in Python
- def add_python_source(self, id: str, *, auto_start: bool = True) -> Awaitable[None]: ...
+ def add_python_source(
+ self, id: str, *, auto_start: bool = True, durability: bool = False
+ ) -> Awaitable[None]: ...
def push_change(self, source_id: str, change: SourceChange) -> Awaitable[None]: ...
def add_python_reaction(
self,
diff --git a/python/drasi/sync.py b/python/drasi/sync.py
index 0749deb..be3ca8e 100644
--- a/python/drasi/sync.py
+++ b/python/drasi/sync.py
@@ -71,6 +71,7 @@
SourceSchema,
SourceSubscription,
StateStore,
+ WalProvider,
)
StrPath = str | PathLike[str]
@@ -168,6 +169,7 @@ def create(
secrets: Mapping[str, str] | None = None,
state_store: StateStore | None = None,
index_store: IndexStore | None = None,
+ wal_provider: WalProvider | None = None,
identity: Identity | None = None,
plugins_dir: StrPath | None = None,
) -> Drasi:
@@ -178,6 +180,7 @@ def create(
secrets=secrets,
state_store=state_store,
index_store=index_store,
+ wal_provider=wal_provider,
identity=identity,
plugins_dir=plugins_dir,
)
@@ -291,8 +294,12 @@ def list_queries(self) -> list[tuple[str, str]]:
# ------------------------------------------- components defined in Python
- def add_python_source(self, id: str, *, auto_start: bool = True) -> None:
- _Loop.run(lambda: self._inner.add_python_source(id, auto_start=auto_start))
+ def add_python_source(
+ self, id: str, *, auto_start: bool = True, durability: bool = False
+ ) -> None:
+ _Loop.run(
+ lambda: self._inner.add_python_source(id, auto_start=auto_start, durability=durability)
+ )
def push_change(self, source_id: str, change: SourceChange) -> None:
_Loop.run(lambda: self._inner.push_change(source_id, change))
diff --git a/python/drasi/types.py b/python/drasi/types.py
index 802d064..7fc36d6 100644
--- a/python/drasi/types.py
+++ b/python/drasi/types.py
@@ -67,6 +67,7 @@
"SourceSchema",
"SourceSubscription",
"StateStore",
+ "WalProvider",
]
QueryLanguage = Literal["cypher", "gql"]
@@ -103,6 +104,16 @@ class IndexStore(TypedDict, total=False):
direct_io: bool
+class _WalProviderRequired(TypedDict):
+ path: str
+
+
+class WalProvider(_WalProviderRequired, total=False):
+ """Directory containing one redb WAL file per durable transient source."""
+
+ kind: Literal["redb"]
+
+
class Identity(TypedDict, total=False):
"""Credentials handed to plugins that ask for them.
@@ -446,6 +457,7 @@ class DrasiConfig(TypedDict, total=False):
secrets: Mapping[str, str]
state_store: StateStore
index_store: IndexStore
+ wal_provider: WalProvider
identity: Identity
plugins_dir: str
sources: Sequence[SourceConfig]
diff --git a/scripts/generate_api_reference.py b/scripts/generate_api_reference.py
index 73f6abd..66bd1cb 100644
--- a/scripts/generate_api_reference.py
+++ b/scripts/generate_api_reference.py
@@ -62,8 +62,14 @@
itself and a `Stream` becomes an ordinary iterator — see [the blocking
API](../guides/blocking-api/).
-The shapes referenced here (`SourceChange`, `QueryResultEvent`, `StateStore` and the
-rest) are declared in `drasi.types`, described in the [typing guide](../guides/typing/).
+The shapes referenced here (`SourceChange`, `QueryResultEvent`, `StateStore`,
+`WalProvider` and the rest) are declared in `drasi.types`, described in the
+[typing guide](../guides/typing/).
+
+`wal_provider={"path": ...}` configures a directory. The redb provider creates one
+WAL file per durable transient source inside it. This is Drasi's local
+transient-source write-ahead log; it is not a PostgreSQL replication slot and does
+not create, manage or resume one.
{{% alert title="Generated from the shipped stubs" color="info" %}}
The tables below are generated from `python/drasi/_drasi.pyi`, the same file your type
diff --git a/src/components.rs b/src/components.rs
index 69e4809..a989f47 100644
--- a/src/components.rs
+++ b/src/components.rs
@@ -20,14 +20,17 @@
use std::any::Any;
use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
+use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use drasi_core::models::SourceChange;
-use drasi_lib::channels::{QueryResult, SubscriptionResponse};
+use drasi_lib::channels::{QueryResult, SourceEvent, SourceEventWrapper, SubscriptionResponse};
use drasi_lib::config::SourceSubscriptionSettings;
use drasi_lib::context::{ReactionRuntimeContext, SourceRuntimeContext};
+use drasi_lib::wal::{WalProvider, WriteAheadLogConfig};
use drasi_lib::{
BootstrapProvider, ComponentStatus, Reaction, ReactionBase, ReactionBaseParams,
ReactionRecoveryPolicy, Source, SourceBase, SourceBaseParams,
@@ -35,7 +38,7 @@ use drasi_lib::{
use pyo3::prelude::*;
use pyo3_async_runtimes::TaskLocals;
use serde_json::Value;
-use tokio::sync::Mutex as TokioMutex;
+use tokio::sync::{Mutex as TokioMutex, RwLock as TokioRwLock};
use crate::conversions::json_to_py;
use crate::streams::{StreamItem, StreamSender};
@@ -46,32 +49,81 @@ const PYTHON_COMPONENT_TYPE: &str = "python";
/// A source that emits the changes pushed into it from Python.
pub struct PythonSource {
base: SourceBase,
- /// Serialises dispatch so concurrent pushes cannot be reordered.
- ///
- /// `dispatch_source_change` assigns a monotonic sequence with `fetch_add`
- /// and only then awaits its way to the subscribers, so two overlapping
- /// pushes can be delivered in the opposite order to the sequences they
- /// took. The query side treats sequence order as delivery order -
- /// `SequenceDedup::should_skip` drops anything at or below the highest
- /// sequence already seen - so the lower one is silently discarded.
- /// `asyncio.gather` over `push_change` hit this for real, losing roughly
- /// one change in twenty at a few percent of runs.
+ durability: bool,
+ wal: TokioRwLock