Skip to main content

Custom Connectors

EventFlux connectors are ordinary Rust types implementing the Source and Sink traits, registered through factories. This page covers the contracts a connector must honor and the helpers that make them easy to honor.

The in-tree RabbitMQ connector (src/core/stream/input/source/rabbitmq_source.rs and src/core/stream/output/sink/rabbitmq_sink.rs) is a complete reference implementation of everything below.

Architecture

Source:  external system → bytes → SourceCallback → SourceMapper → Events → pipeline
Sink: pipeline → Events → per event: SinkMapper → bytes → Sink::publish() → external system

Connectors deal only in bytes — parsing and serialization are the mappers' job. This keeps transports and formats independently pluggable.

Writing a Source

A source implements the Source trait:

pub trait Source: Debug + Send + Sync {
fn start(&mut self, callback: Arc<dyn SourceCallback>);
fn stop(&mut self);
fn clone_box(&self) -> Box<dyn Source>;
fn validate_connectivity(&self) -> Result<(), EventFluxError> { Ok(()) }
fn set_error_dlq_junction(&mut self, junction: Arc<Mutex<InputHandler>>) {}
}

The stop() contract — use SourceWorker

stop() must be synchronous: when it returns, the worker thread has terminated and no callback can fire afterwards. Repeated calls must be no-ops.

Don't hand-roll this with an AtomicBool and a discarded thread handle — embed a SourceWorker, which captures the lifecycle once:

use eventflux::core::stream::input::source::{Source, SourceCallback, SourceWorker};

pub struct MySource {
config: MyConfig,
worker: SourceWorker,
}

impl MySource {
pub fn new(config: MyConfig) -> Self {
Self { config, worker: SourceWorker::new("MySource") }
}
}

impl Source for MySource {
fn start(&mut self, callback: Arc<dyn SourceCallback>) {
let config = self.config.clone();
self.worker.start(move |running| {
while running.load(Ordering::SeqCst) {
// read from the external system, then:
// callback.on_data(&bytes)?
// Poll `running` at least every ~100ms so stop() is fast.
}
});
}

fn stop(&mut self) {
self.worker.stop(); // signals the flag + joins the thread (bounded)
}

fn clone_box(&self) -> Box<dyn Source> {
Box::new(Self {
config: self.config.clone(),
worker: self.worker.clone(), // clones as a fresh, unstarted worker
})
}
}

SourceWorker guarantees:

  • stop() joins the worker, bounded by a 5s grace period (SOURCE_STOP_GRACE_PERIOD) — a worker stuck in slow I/O is detached with a warning instead of hanging shutdown
  • Idempotent — safe without start(), safe to call twice
  • Drop-safe (RAII) — a source dropped without being stopped still shuts its worker down
  • Clone-safe — a clone starts unstarted; runtime state never leaks

Your only obligation is inside the loop: poll the running flag at least every ~100ms. Use a poll/read timeout around blocking I/O, and sleep_while_running(&running, interval) instead of thread::sleep for waits between iterations, so long intervals never delay shutdown.

The runtime stops all sources in parallel during application shutdown, so a correct stop() is all you need — total shutdown stays bounded by one grace period regardless of how many sources an app runs.

validate_connectivity()

Called at application startup (fail-fast): verify the external system is reachable and configured resources exist, with a bounded timeout. If it returns an error, the application does not start.

Writing a Sink

pub trait Sink: Debug + Send + Sync {
fn publish(&self, payload: &[u8]) -> Result<(), EventFluxError>;
fn start(&self) {}
fn stop(&self) {}
fn clone_box(&self) -> Box<dyn Sink>;
fn validate_connectivity(&self) -> Result<(), EventFluxError> { Ok(()) }
}

publish() receives one pre-formatted payload per event — the runtime maps each event through the sink mapper and calls publish() once per event (one event = one transport message). A sink that wants transport-level batching (e.g. batched HTTP POSTs) should buffer internally and flush in stop().

Factories and Registration

Factories carry the metadata the SQL layer validates against — supported formats, required and optional parameters — and build fully-initialized instances from the WITH clause properties:

impl SourceFactory for MySourceFactory {
fn name(&self) -> &'static str { "mysource" } // WITH (extension = 'mysource')
fn supported_formats(&self) -> &[&str] { &["json", "csv", "bytes"] }
fn required_parameters(&self) -> &[&str] { &["mysource.host"] }
fn optional_parameters(&self) -> &[&str] { &["mysource.port"] }
fn create_initialized(
&self,
config: &HashMap<String, String>,
) -> Result<Box<dyn Source>, EventFluxError> { /* parse + validate */ }
fn clone_box(&self) -> Box<dyn SourceFactory> { Box::new(self.clone()) }
}

Register with the manager, and the connector becomes usable from SQL:

manager.context().add_source_factory("mysource".to_string(), Box::new(MySourceFactory));
CREATE STREAM Input (value INT) WITH (
type = 'source',
extension = 'mysource',
format = 'json',
"mysource.host" = 'localhost'
);

Error Handling Integration

Sources can integrate with EventFlux's error handling strategies (error.strategy = drop / retry / dlq / fail) via SourceErrorContext. The pattern — retry loop around callback.on_data, acknowledge only after success, DLQ fallback events from raw bytes — is shown end-to-end in the RabbitMQ source.

Contributing a Connector Upstream

In-tree connectors are gated behind a cargo feature named after the extension (the default build is minimal; connectors-all enables everything). The complete per-connector gating checklist — dependency, feature, #[cfg] points, registration, the unavailable-extension error hint, tests, CI, docs — lives in docs/writing_extensions.md in the repository.

Dynamic Extensions

Connectors can also ship as separate crates compiled to dynamic libraries and loaded at runtime via manager.set_extension(...), exporting registration callbacks like register_sources / register_sinks. See docs/writing_extensions.md for the callback list and a minimal crate example.