Creating Custom Sources and Reactions
Implement the Source and Reaction traits to connect drasi-lib to a system that doesn’t have a built-in plugin.
drasi-lib ships with plugins for common systems — PostgreSQL, HTTP, gRPC, MSSQL, and more (see Available Sources and Available Reactions). When your application needs to watch or react to a system that isn’t on that list, you implement two small async traits — Source and Reaction — and drasi-lib handles query evaluation, dispatch, and recovery for you.
This guide walks through both traits with illustrative code that does not compile as shown. If you’d rather have an AI agent generate a production-ready plugin — including tests and docs — see Source and Reaction Creation Agents instead. That workflow uses the same traits described here.
Before you start
Check whether an existing plugin already covers your use case:
Available Sources
postgres, http, grpc, mock, mssql, platform, application
Available Reactions
http, grpc, sse, log, platform, profiler, stored-proc (postgres/mysql/mssql), application
Both Source and Reaction are plugins in the same sense as these built-ins — your implementation is registered with DrasiLib::builder() exactly the way a built-in plugin would be.
Creating a custom Source
A Source connects to a system, models its data as a property graph, and dispatches change events. It implements the Source trait.
The following skeleton is non-compilable illustrative code. It omits required imports, an error type alias, and application-specific ingestion logic.
use drasi_lib::{Source, SourceBase, SourceBaseParams, ComponentStatus};
use drasi_lib::context::SourceRuntimeContext;
use drasi_lib::channels::SubscriptionResponse;
use async_trait::async_trait;
pub struct MySource {
base: SourceBase,
// your config fields
}
#[async_trait]
impl Source for MySource {
fn id(&self) -> &str { &self.base.get_id() }
fn type_name(&self) -> &str { "my-source" }
fn properties(&self) -> HashMap<String, serde_json::Value> { HashMap::new() }
fn auto_start(&self) -> bool { self.base.get_auto_start() }
async fn initialize(&self, context: SourceRuntimeContext) {
self.base.initialize(context).await;
}
async fn start(&self) -> Result<()> {
self.base.set_status(ComponentStatus::Running, None).await;
// spawn your data ingestion task
Ok(())
}
async fn stop(&self) -> Result<()> {
self.base.stop_common().await;
Ok(())
}
async fn status(&self) -> ComponentStatus {
self.base.get_status().await
}
async fn subscribe(&self, settings: SourceSubscriptionSettings) -> Result<SubscriptionResponse> {
self.base.subscribe_with_bootstrap(&settings, "MySource").await
}
fn as_any(&self) -> &dyn std::any::Any { self }
}
SourceBase does the bookkeeping — status tracking, checkpoint sequencing, bootstrap coordination — so your implementation only needs to fill in how you connect and how you detect changes.
A few details matter once you go beyond this skeleton:
- Dispatch events through the base, not directly. Call
self.base.dispatch_event(event)(ordispatch_events_batch) rather than writing to a channel yourself.SourceBasestamps monotonic sequence numbers and maintains thesequence → positionmapping that recovery depends on. - Call
apply_subscription_settingsat the start ofsubscribe(). Either callself.base.apply_subscription_settings(&settings)directly, or usesubscribe_with_bootstrap()as shown above, which calls it for you. Skipping this breaks sequence continuity across restarts. - Decide whether your source supports replay.
supports_replay()defaults totrue. If the system you’re wrapping has a replayable log (a WAL, a CDC stream, a durable event store), leave it and encode each event’s native position — an LSN, an offset — asOption<Bytes>onsource_position. If your source is push-only and volatile (a metrics collector, a plain webhook receiver), overridesupports_replay()to returnfalseand you can skip position handling entirely.
Creating a custom Reaction
A Reaction receives query results and takes action — call a webhook, write to a database, push to a dashboard. It implements the Reaction trait.
The following skeleton is non-compilable illustrative code. It omits required imports, an error type alias, and application-specific result processing logic.
use drasi_lib::{Reaction, ReactionBase, ReactionBaseParams, ComponentStatus};
use drasi_lib::context::ReactionRuntimeContext;
use async_trait::async_trait;
pub struct MyReaction {
base: ReactionBase,
}
#[async_trait]
impl Reaction for MyReaction {
fn id(&self) -> &str { self.base.get_id() }
fn type_name(&self) -> &str { "my-reaction" }
fn properties(&self) -> HashMap<String, serde_json::Value> { HashMap::new() }
fn query_ids(&self) -> Vec<String> { self.base.get_queries().clone() }
fn auto_start(&self) -> bool { self.base.get_auto_start() }
async fn initialize(&self, context: ReactionRuntimeContext) {
self.base.initialize(context).await;
}
async fn start(&self) -> Result<()> {
self.base.set_status(ComponentStatus::Running, None).await;
// spawn your result processing task — use base.enqueue_query_result()
Ok(())
}
async fn stop(&self) -> Result<()> {
self.base.stop_common().await;
Ok(())
}
async fn status(&self) -> ComponentStatus {
self.base.get_status().await
}
fn as_any(&self) -> &dyn std::any::Any { self }
}
Results arrive as QueryResult values containing ResultDiff items:
pub enum ResultDiff {
Add { data: serde_json::Value },
Delete { data: serde_json::Value },
Update {
data: serde_json::Value, // current row
before: serde_json::Value, // previous values
after: serde_json::Value, // new values
grouping_keys: Option<Vec<String>>,
},
}
Reaction recovery
Reactions can stop and restart without losing results. Each query keeps a bounded outbox of recent results (configurable with .with_outbox_capacity(n) on the query builder, default 1000); reactions persist a checkpoint after each delivered result; on restart, the runtime replays anything missed. If the checkpoint has fallen further behind than the outbox retains, that’s a gap, and the recovery policy decides what happens next:
| Policy | Behavior on gap | Use case |
|---|---|---|
Strict (default) |
Fail with error — reaction stops | Correctness-critical (financial, audit) |
AutoReset |
Wipe checkpoint, re-bootstrap from full snapshot | Materialized views, caches |
AutoSkipGap |
Skip missing entries, resume from latest | Best-effort delivery (alerts, logs) |
Set a policy per-instance via ReactionBaseParams::new(...).with_recovery_policy(...), or as your plugin’s default by implementing a few methods on Reaction:
The following recovery snippet is non-compilable illustrative code. It omits imports for ReactionRecoveryPolicy and BootstrapContext, an error type alias for Result, and other required implementation details. These methods belong in your existing Reaction implementation, not a second trait implementation.
impl Reaction for MyReaction {
// ...
fn is_durable(&self) -> bool {
true // requires a durable StateStoreProvider
}
fn needs_snapshot_on_fresh_start(&self) -> bool {
true // triggers bootstrap() on first start with no checkpoint
}
fn default_recovery_policy(&self) -> ReactionRecoveryPolicy {
ReactionRecoveryPolicy::AutoReset
}
async fn bootstrap(&self, ctx: BootstrapContext) -> Result<()> {
// Called on fresh start (if needs_snapshot_on_fresh_start=true)
// and on AutoReset recovery after a gap.
let snapshot = ctx.fetch_snapshot().await?;
while let Some(row) = snapshot.next().await {
// Process each row...
}
Ok(())
}
}
The runtime validates a few combinations at startup — is_durable=true without a durable state store, or AutoReset without needs_snapshot_on_fresh_start, both fail fast rather than silently misbehaving. If your target system is idempotent (safe to receive the same result twice), you can lean toward AutoSkipGap or AutoReset; if it isn’t (e.g. it increments a counter on every delivery), stick with Strict or add deduplication in your reaction.
Wire it into a running instance
Custom sources and reactions register with the builder exactly like built-in ones:
let core = DrasiLib::builder()
.with_source(MySource::new(/* ... */))
.with_reaction(MyReaction::new(/* ... */))
.with_query(
Query::cypher("my-query")
.query("MATCH (n:Thing) WHERE n.value > 100 RETURN n")
.from_source("my-source-id")
.build()
)
.build()
.await?;
core.start().await?;
Sources and reactions are owned by DrasiLib once passed to with_source() / with_reaction() — you configure them, hand them over, and the runtime manages their lifecycle from there.
Going further
- Dynamic loading: the trait implementations above are enough for drasi-lib, where your plugin compiles directly into your binary. Drasi Server additionally supports loading plugins as
.so/.dylib/.dllshared libraries at runtime — that requires an extra descriptor and anexport_plugin!registration and is documented separately in the drasi-core repository’s plugin architecture notes. - Let an agent do the implementation, testing, and docs: the same two traits are what the
source-planner/source-plan-executorandreaction-planner/reaction-plan-executoragents produce when you describe a target system in plain language. See Source and Reaction Creation Agents. - Study a real example: the
postgressource (full replay support) andapplicationreaction are the most complete references incomponents/sourcesandcomponents/reactionsof the drasi-core repository.
Feedback
Was this page helpful?
Glad to hear it! Please tell us what you found helpful.
Sorry to hear that. Please tell us how we can improve.