Skip to content

Commit 80a5014

Browse files
authored
fix: improve startup diagnostics and Redis resilience (#55)
Improve daemon startup diagnostics while keeping emitted startup configuration limited to an explicit safe allowlist. Fatal errors now follow etl-replicator's typed `main -> try_main -> render_report` flow, including categorized source-chain reports and optional backtraces. **Safe Startup Visibility** Startup emits one INFO event containing only the stream ID, sink type, batch settings, and TLS state. Sink identity uses the same config-level `kind()` pattern as etl-replicator destinations, covering every feature-gated sink without logging sink configuration. **Redis Resilience** Redis Strings and Redis Streams now support connection/response timeouts and reconnect controls. Redis failures preserve their source and distinguish transient connectivity/capacity errors, authentication failures, and permanent client/data errors so ETL can apply the appropriate retry policy.
1 parent a8ecdcd commit 80a5014

13 files changed

Lines changed: 616 additions & 154 deletions

docs/sinks/redis-streams.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ sink:
1515
type: redis-streams
1616
url: redis://localhost:6379
1717
stream_name: events
18+
connection_timeout_ms: 1000
19+
response_timeout_ms: 5000
20+
connection_retries: 2
21+
connection_max_delay_ms: 1000
1822
```
1923
2024
### With Length Limit
@@ -34,6 +38,10 @@ sink:
3438
| `url` | string | Yes | - | No | Redis connection URL |
3539
| `stream_name` | string | No | - | Yes | Default stream (can be overridden per-event) |
3640
| `max_len` | integer | No | - | No | Maximum stream length (uses MAXLEN ~) |
41+
| `connection_timeout_ms` | integer | No | Redis default | No | Timeout for each connection attempt |
42+
| `response_timeout_ms` | integer | No | Redis default | No | Timeout for command responses |
43+
| `connection_retries` | integer | No | Redis default | No | Number of reconnection attempts |
44+
| `connection_max_delay_ms` | integer | No | Redis default | No | Maximum delay between reconnect attempts |
3745

3846
## Dynamic Routing
3947

docs/sinks/redis-strings.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ docker pull ghcr.io/psteinroe/postgres-stream:redis-strings-latest
1414
sink:
1515
type: redis-strings
1616
url: redis://localhost:6379
17+
connection_timeout_ms: 1000
18+
response_timeout_ms: 5000
19+
connection_retries: 2
20+
connection_max_delay_ms: 1000
1721
```
1822
1923
### With Key Prefix
@@ -31,6 +35,10 @@ sink:
3135
|--------|------|----------|---------|-------------------|-------------|
3236
| `url` | string | Yes | - | No | Redis connection URL |
3337
| `key_prefix` | string | No | - | No | Prefix for all keys |
38+
| `connection_timeout_ms` | integer | No | Redis default | No | Timeout for each connection attempt |
39+
| `response_timeout_ms` | integer | No | Redis default | No | Timeout for command responses |
40+
| `connection_retries` | integer | No | Redis default | No | Number of reconnection attempts |
41+
| `connection_max_delay_ms` | integer | No | Redis default | No | Maximum delay between reconnect attempts |
3442
| `key` | - | - | - | Yes | Full key (via metadata only) |
3543

3644
## Key Resolution

src/config/load.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ impl TryFrom<String> for Environment {
7474
"prod" => Ok(Self::Prod),
7575
"staging" => Ok(Self::Staging),
7676
"dev" => Ok(Self::Dev),
77-
other => Err(io::Error::other(format!(
78-
"{other} is not a supported environment. Use either `prod`/`staging`/`dev`.",
79-
))),
77+
_ => Err(io::Error::other(
78+
"unsupported APP_ENVIRONMENT value; use `prod`, `staging`, or `dev`",
79+
)),
8080
}
8181
}
8282
}

src/config/sink.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,48 @@ pub enum SinkConfig {
105105
#[serde(rename = "gcp-pubsub")]
106106
GcpPubsub(GcpPubsubSinkConfig),
107107
}
108+
109+
impl SinkConfig {
110+
/// Returns the stable, non-sensitive name of the configured sink.
111+
pub(crate) const fn kind(&self) -> &'static str {
112+
match self {
113+
Self::Memory => "memory",
114+
115+
#[cfg(feature = "sink-elasticsearch")]
116+
Self::Elasticsearch(_) => "elasticsearch",
117+
118+
#[cfg(feature = "sink-redis-strings")]
119+
Self::RedisStrings(_) => "redis-strings",
120+
121+
#[cfg(feature = "sink-redis-streams")]
122+
Self::RedisStreams(_) => "redis-streams",
123+
124+
#[cfg(feature = "sink-nats")]
125+
Self::Nats(_) => "nats",
126+
127+
#[cfg(feature = "sink-rabbitmq")]
128+
Self::Rabbitmq(_) => "rabbitmq",
129+
130+
#[cfg(feature = "sink-webhook")]
131+
Self::Webhook(_) => "webhook",
132+
133+
#[cfg(feature = "sink-kafka")]
134+
Self::Kafka(_) => "kafka",
135+
136+
#[cfg(feature = "sink-sqs")]
137+
Self::Sqs(_) => "sqs",
138+
139+
#[cfg(feature = "sink-sns")]
140+
Self::Sns(_) => "sns",
141+
142+
#[cfg(feature = "sink-kinesis")]
143+
Self::Kinesis(_) => "kinesis",
144+
145+
#[cfg(feature = "sink-meilisearch")]
146+
Self::Meilisearch(_) => "meilisearch",
147+
148+
#[cfg(feature = "sink-gcp-pubsub")]
149+
Self::GcpPubsub(_) => "gcp-pubsub",
150+
}
151+
}
152+
}

src/core.rs

Lines changed: 10 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use etl::pipeline::Pipeline;
1717
use etl::store::both::postgres::PostgresStore;
1818
use sqlx::postgres::PgPoolOptions;
1919
use tokio::signal::unix::{SignalKind, signal};
20-
use tracing::{debug, error, info, warn};
20+
use tracing::{error, info, warn};
2121

2222
/// Starts the pipeline daemon with the provided configuration.
2323
///
@@ -28,8 +28,6 @@ use tracing::{debug, error, info, warn};
2828
/// If a replication slot is invalidated, this function will automatically
2929
/// recover by setting a failover checkpoint and restarting the pipeline.
3030
pub async fn start_pipeline_with_config(config: PipelineConfig) -> EtlResult<()> {
31-
info!("starting pgstream daemon");
32-
3331
log_config(&config);
3432

3533
// Run etl migrations before starting the pipeline
@@ -98,27 +96,15 @@ async fn run_pipeline(config: &PipelineConfig) -> EtlResult<()> {
9896
#[cfg(feature = "sink-redis-strings")]
9997
SinkConfig::RedisStrings(cfg) => {
10098
use crate::sink::redis_strings::RedisStringsSink;
101-
let s = RedisStringsSink::new(cfg.clone()).await.map_err(|e| {
102-
etl::etl_error!(
103-
etl::error::ErrorKind::InvalidData,
104-
"Failed to create Redis Strings sink",
105-
e.to_string()
106-
)
107-
})?;
108-
AnySink::RedisStrings(s)
99+
let sink = RedisStringsSink::new(cfg.clone()).await?;
100+
AnySink::RedisStrings(sink)
109101
}
110102

111103
#[cfg(feature = "sink-redis-streams")]
112104
SinkConfig::RedisStreams(cfg) => {
113105
use crate::sink::redis_streams::RedisStreamsSink;
114-
let s = RedisStreamsSink::new(cfg.clone()).await.map_err(|e| {
115-
etl::etl_error!(
116-
etl::error::ErrorKind::InvalidData,
117-
"Failed to create Redis Streams sink",
118-
e.to_string()
119-
)
120-
})?;
121-
AnySink::RedisStreams(s)
106+
let sink = RedisStreamsSink::new(cfg.clone()).await?;
107+
AnySink::RedisStreams(sink)
122108
}
123109

124110
#[cfg(feature = "sink-nats")]
@@ -256,95 +242,19 @@ async fn run_pipeline(config: &PipelineConfig) -> EtlResult<()> {
256242
start_pipeline_with_shutdown(pipeline).await
257243
}
258244

259-
/// Logs the daemon configuration (without secrets).
245+
/// Logs an allowlist of safe operational configuration fields.
260246
fn log_config(config: &PipelineConfig) {
261-
log_stream_config(config);
262-
log_sink_config(&config.sink);
263-
}
264-
265-
fn log_stream_config(config: &PipelineConfig) {
266247
let stream = &config.stream;
267-
debug!(
248+
info!(
268249
stream_id = stream.id,
269-
host = stream.pg_connection.host,
270-
port = stream.pg_connection.port,
271-
dbname = stream.pg_connection.name,
272-
username = stream.pg_connection.username,
273-
tls_enabled = stream.pg_connection.tls.enabled,
250+
sink_type = config.sink.kind(),
274251
max_batch_size = stream.batch.max_size,
275252
max_batch_fill_ms = stream.batch.max_fill_ms,
276-
"stream configuration"
253+
tls_enabled = stream.pg_connection.tls.enabled,
254+
"pgstream daemon starting"
277255
);
278256
}
279257

280-
fn log_sink_config(config: &SinkConfig) {
281-
match config {
282-
SinkConfig::Memory => {
283-
debug!("using memory sink");
284-
}
285-
286-
#[cfg(feature = "sink-elasticsearch")]
287-
SinkConfig::Elasticsearch(_cfg) => {
288-
debug!("using elasticsearch sink");
289-
}
290-
291-
#[cfg(feature = "sink-redis-strings")]
292-
SinkConfig::RedisStrings(_cfg) => {
293-
debug!("using redis-strings sink");
294-
}
295-
296-
#[cfg(feature = "sink-redis-streams")]
297-
SinkConfig::RedisStreams(_cfg) => {
298-
debug!("using redis-streams sink");
299-
}
300-
301-
#[cfg(feature = "sink-nats")]
302-
SinkConfig::Nats(_cfg) => {
303-
debug!("using nats sink");
304-
}
305-
306-
#[cfg(feature = "sink-rabbitmq")]
307-
SinkConfig::Rabbitmq(_cfg) => {
308-
debug!("using rabbitmq sink");
309-
}
310-
311-
#[cfg(feature = "sink-webhook")]
312-
SinkConfig::Webhook(_cfg) => {
313-
debug!("using webhook sink");
314-
}
315-
316-
#[cfg(feature = "sink-kafka")]
317-
SinkConfig::Kafka(_cfg) => {
318-
debug!("using kafka sink");
319-
}
320-
321-
#[cfg(feature = "sink-sqs")]
322-
SinkConfig::Sqs(_cfg) => {
323-
debug!("using sqs sink");
324-
}
325-
326-
#[cfg(feature = "sink-sns")]
327-
SinkConfig::Sns(_cfg) => {
328-
debug!("using sns sink");
329-
}
330-
331-
#[cfg(feature = "sink-kinesis")]
332-
SinkConfig::Kinesis(_cfg) => {
333-
debug!("using kinesis sink");
334-
}
335-
336-
#[cfg(feature = "sink-meilisearch")]
337-
SinkConfig::Meilisearch(_cfg) => {
338-
debug!("using meilisearch sink");
339-
}
340-
341-
#[cfg(feature = "sink-gcp-pubsub")]
342-
SinkConfig::GcpPubsub(_cfg) => {
343-
debug!("using gcp-pubsub sink");
344-
}
345-
}
346-
}
347-
348258
/// Starts a pipeline and handles graceful shutdown signals.
349259
///
350260
/// Launches the pipeline, sets up signal handlers for SIGTERM and SIGINT,

src/error.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use etl::error::EtlError;
2+
use std::{backtrace::Backtrace, error::Error, fmt};
3+
4+
pub type PgStreamResult<T> = Result<T, PgStreamError>;
5+
6+
/// Captured backtrace wrapper matching etl-replicator's stable error-reporting pattern.
7+
pub struct CapturedBacktrace(Backtrace);
8+
9+
impl CapturedBacktrace {
10+
fn capture() -> Self {
11+
Self(Backtrace::capture())
12+
}
13+
}
14+
15+
impl fmt::Debug for CapturedBacktrace {
16+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17+
write!(formatter, "{}", self.0)
18+
}
19+
}
20+
21+
/// Top-level daemon error used to render one consistent failure report.
22+
#[derive(Debug)]
23+
pub enum PgStreamError {
24+
Etl(EtlError),
25+
Config(Box<dyn Error + Send + Sync>, CapturedBacktrace),
26+
Io(std::io::Error, CapturedBacktrace),
27+
}
28+
29+
impl PgStreamError {
30+
pub fn config<E: Error + Send + Sync + 'static>(error: E) -> Self {
31+
Self::Config(Box::new(error), CapturedBacktrace::capture())
32+
}
33+
34+
fn category(&self) -> &'static str {
35+
match self {
36+
Self::Etl(_) => "daemon error",
37+
Self::Config(_, _) => "configuration error",
38+
Self::Io(_, _) => "i/o error",
39+
}
40+
}
41+
42+
fn backtrace(&self) -> Option<&Backtrace> {
43+
match self {
44+
Self::Etl(error) => error.backtrace(),
45+
Self::Config(_, backtrace) | Self::Io(_, backtrace) => Some(&backtrace.0),
46+
}
47+
}
48+
49+
pub fn render_report(&self) -> String {
50+
let mut report = String::new();
51+
report.push_str("postgres-stream failed\n");
52+
report.push_str(&format!("category: {}\n", self.category()));
53+
report.push_str(&format!("error: {self}\n"));
54+
55+
if !matches!(self, Self::Etl(error) if error.errors().is_some()) {
56+
let mut source = Error::source(self);
57+
let mut index = 1usize;
58+
while let Some(error) = source {
59+
report.push_str(&format!("cause {index}: {error}\n"));
60+
source = error.source();
61+
index += 1;
62+
}
63+
}
64+
65+
if should_render_backtrace()
66+
&& let Some(backtrace) = self.backtrace()
67+
{
68+
report.push_str("backtrace:\n");
69+
report.push_str(&backtrace.to_string());
70+
if !report.ends_with('\n') {
71+
report.push('\n');
72+
}
73+
}
74+
75+
report
76+
}
77+
}
78+
79+
impl fmt::Display for PgStreamError {
80+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81+
match self {
82+
Self::Etl(error) => write!(formatter, "{error}"),
83+
Self::Config(source, _) => write!(formatter, "configuration error: {source}"),
84+
Self::Io(source, _) => write!(formatter, "i/o error: {source}"),
85+
}
86+
}
87+
}
88+
89+
impl Error for PgStreamError {
90+
fn source(&self) -> Option<&(dyn Error + 'static)> {
91+
match self {
92+
Self::Etl(error) => error.source(),
93+
Self::Config(source, _) => Some(source.as_ref()),
94+
Self::Io(source, _) => Some(source),
95+
}
96+
}
97+
}
98+
99+
impl From<EtlError> for PgStreamError {
100+
fn from(error: EtlError) -> Self {
101+
Self::Etl(error)
102+
}
103+
}
104+
105+
impl From<std::io::Error> for PgStreamError {
106+
fn from(error: std::io::Error) -> Self {
107+
Self::Io(error, CapturedBacktrace::capture())
108+
}
109+
}
110+
111+
fn should_render_backtrace() -> bool {
112+
matches!(
113+
std::env::var("RUST_BACKTRACE").as_deref(),
114+
Ok("1") | Ok("full")
115+
)
116+
}

0 commit comments

Comments
 (0)