Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
* Reads PostgreSQL logical replication slot state for operator status (no secrets).
*
* <p>Uses the primary datasource ({@link JdbcTemplate}). Fail-open: DB errors become
* {@code available=false} rather than failing the status API.</p>
* {@code available=false} rather than failing the status API. Database-driver exception
* details are intentionally excluded from both the returned status and ordinary logs.</p>
*/
@Service
public class ReplicationSlotProbe {

private static final Logger log = LoggerFactory.getLogger(ReplicationSlotProbe.class);
private static final String QUERY_FAILED_MESSAGE = "Replication slot state unavailable";

// restart_lsn / confirmed_flush_lsn are pg_lsn; lag bytes via pg_wal_lsn_diff against current insert LSN.
private static final String SLOT_SQL = """
Expand All @@ -45,15 +47,31 @@ ELSE pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)

private final JdbcTemplate jdbcTemplate;

/**
* Creates a replication-slot probe backed by the service's primary PostgreSQL datasource.
*
* @param jdbcTemplate JDBC access used for the read-only replication-slot query
*/
public ReplicationSlotProbe(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

/**
* Probes the slot named by {@code CDC_SLOT_NAME}, or the legacy default when it is absent.
*
* @return a finite operator status map that never includes database-driver diagnostics
*/
public Map<String, Object> probeConfiguredSlot() {
String slotName = EnvUtils.getEnv("CDC_SLOT_NAME", "xtrmetl_slot");
return probeSlot(slotName);
}

/**
* Probes one PostgreSQL logical replication slot without exposing connection diagnostics.
*
* @param slotName replication slot to query
* @return slot state when available, or a stable {@code query_failed} status when the query fails
*/
public Map<String, Object> probeSlot(String slotName) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("slotName", slotName);
Expand All @@ -78,11 +96,11 @@ public Map<String, Object> probeSlot(String slotName) {
result.put("flushLagBytes", toLong(row.get("flush_lag_bytes")));
return result;
} catch (DataAccessException e) {
log.debug("Replication slot probe failed for {}: {}", slotName, e.toString());
log.debug("Replication slot probe unavailable: query_failed");
result.put("available", false);
result.put("found", false);
result.put("error", "query_failed");
result.put("message", safeMessage(e));
result.put("message", QUERY_FAILED_MESSAGE);
return result;
}
}
Expand All @@ -101,15 +119,4 @@ private static Long toLong(@Nullable Object value) {
return null;
}
}

private static String safeMessage(DataAccessException e) {
String msg = e.getMostSpecificCause() != null
? e.getMostSpecificCause().getMessage()
: e.getMessage();
if (msg == null || msg.isBlank()) {
return e.getClass().getSimpleName();
}
// Avoid leaking connection strings if drivers embed them.
return msg.length() > 200 ? msg.substring(0, 200) + "…" : msg;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,19 @@ void reportsNotFoundWhenEmpty() {
}

@Test
void failOpenOnDataAccessError() {
void failOpenOnDataAccessErrorWithoutExposingDriverDiagnostics() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
String sensitiveDriverMessage =
"jdbc:postgresql://db.internal:5432/prod?user=admin&password=super-secret";
when(jdbc.queryForList(anyString(), eq("xtrmetl_slot")))
.thenThrow(new DataAccessResourceFailureException("down"));
.thenThrow(new DataAccessResourceFailureException(sensitiveDriverMessage));

Map<String, Object> result = new ReplicationSlotProbe(jdbc).probeSlot("xtrmetl_slot");

assertFalse((Boolean) result.get("available"));
assertEquals("query_failed", result.get("error"));
assertEquals("Replication slot state unavailable", result.get("message"));
assertFalse(result.toString().contains("super-secret"));
assertFalse(result.toString().contains("jdbc:postgresql://"));
}
}
Loading