Skip to content
Merged
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 @@ -223,21 +223,31 @@ public TableStats getTableStats(Connection connection, String database, String t
TableStats stats = new TableStats();
stats.setTableName(tableName);

// One placeholder, resolved once in a CTE, instead of repeating `?::regclass`
// in every expression. The previous form had NINE placeholders — the two
// subtractions use two each — while the binding loop ran `i <= 7`, so
// parameters 8 and 9 were never set and every call threw
// `No value specified for parameter 8`. That silently broke table-growth
// snapshots for every table on every Postgres connection
// (TableGrowthMonitoringService logs it per table and carries on).
//
// Counting placeholders by hand is exactly what failed here, so the count is
// now impossible to get wrong: bind one value and reference it by name.
String query = """
WITH t AS (SELECT ?::regclass AS rel)
SELECT
pg_size_pretty(pg_total_relation_size(?::regclass)) as total_size,
pg_total_relation_size(?::regclass) as total_bytes,
pg_size_pretty(pg_relation_size(?::regclass)) as data_size,
pg_relation_size(?::regclass) as data_bytes,
pg_size_pretty(pg_total_relation_size(?::regclass) - pg_relation_size(?::regclass)) as index_size,
(pg_total_relation_size(?::regclass) - pg_relation_size(?::regclass)) as index_bytes,
obj_description(?::regclass, 'pg_class') as comment
pg_size_pretty(pg_total_relation_size(rel)) as total_size,
pg_total_relation_size(rel) as total_bytes,
pg_size_pretty(pg_relation_size(rel)) as data_size,
pg_relation_size(rel) as data_bytes,
pg_size_pretty(pg_total_relation_size(rel) - pg_relation_size(rel)) as index_size,
(pg_total_relation_size(rel) - pg_relation_size(rel)) as index_bytes,
obj_description(rel, 'pg_class') as comment
FROM t
""";

try (PreparedStatement stmt = connection.prepareStatement(query)) {
for (int i = 1; i <= 7; i++) {
stmt.setString(i, tableName);
}
stmt.setString(1, tableName);

try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Expand Down
14 changes: 13 additions & 1 deletion backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,19 @@ spring.batch.job.enabled=false

# db-scheduler Configuration (distributed task scheduling)
db-scheduler.enabled=true
db-scheduler.heartbeat-interval=5m
# The revival window for an execution whose owner died is
# heartbeat-interval × missed-heartbeats-limit. At the old 5m × 6 that was THIRTY
# MINUTES, during which a dead brain-init stage sits at a frozen percentage with no
# error: the status endpoint keeps reporting the last stage it reached, so it is
# indistinguishable from slow progress. smoke-test.sh gives up after 1200s (20m),
# so any run where a stage died failed the smoke test even though init would have
# completed fine once revived — observed exactly that, init finishing ~37m after
# revival.
#
# 1m × 6 keeps the same six-missed-heartbeat tolerance — a live-but-slow execution
# heartbeats from its own thread, so a long LLM call is never mistaken for death —
# while bringing revival to ~6m, comfortably inside the smoke-test window.
db-scheduler.heartbeat-interval=1m
db-scheduler.missed-heartbeats-limit=6
db-scheduler.polling-interval=10s
db-scheduler.polling-strategy=fetch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
import java.sql.*;
import java.util.List;

import org.mockito.ArgumentCaptor;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
Expand Down Expand Up @@ -171,6 +175,38 @@ void getTableStats_returnsStats() throws SQLException {
assertEquals(122880L, stats.getSizeBytes());
}

@Test
void getTableStats_bindsEveryPlaceholderInTheStatsQuery() throws SQLException {
// The stats query carried NINE `?` placeholders (the two size subtractions use
// two each) while the binding loop ran `i <= 7`, so parameters 8 and 9 were
// never set and Postgres rejected every call with
// No value specified for parameter 8
// silently killing table-growth snapshots for every table.
//
// getTableStats_returnsStats above passed throughout, because a mocked
// PreparedStatement does not enforce that placeholders are bound. This test
// compares the two directly, so the count can never drift again.
PreparedStatement rowCountStatement = mock(PreparedStatement.class);
ResultSet rowCountResultSet = mock(ResultSet.class);
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);

when(connection.prepareStatement(anyString())).thenReturn(preparedStatement, rowCountStatement);
when(preparedStatement.executeQuery()).thenReturn(resultSet);
when(rowCountStatement.executeQuery()).thenReturn(rowCountResultSet);
when(resultSet.next()).thenReturn(true);
when(rowCountResultSet.next()).thenReturn(true);
when(rowCountResultSet.getObject("row_count")).thenReturn(1000L);

provider.getTableStats(connection, "public", "users");

verify(connection, atLeastOnce()).prepareStatement(sqlCaptor.capture());
String statsQuery = sqlCaptor.getAllValues().get(0);
int placeholders = (int) statsQuery.chars().filter(c -> c == '?').count();

assertTrue(placeholders > 0, "stats query should still be parameterised");
verify(preparedStatement, times(placeholders)).setString(anyInt(), eq("users"));
}

@Test
void scanSchema_returnsSchemaMetadata() throws SQLException {
when(connection.createStatement()).thenReturn(statement);
Expand Down
Loading