fix(snapshots): handle Instant return from pgJDBC 42.7+ (was 500)

ClassCastException on every GET /api/v1/dictionaries/{name}/snapshots:
  java.lang.ClassCastException: class java.time.Instant cannot be cast
  to class java.sql.Timestamp

Root cause: pgJDBC 42.7+ возвращает timestamptz из native query как
java.time.Instant, а старый код жёстко кастил в java.sql.Timestamp.
42.6 возвращал Timestamp — отсюда и тесты с Timestamp проходили,
а runtime ломался.

Fix: helper `toBucketOffsetDateTime(Object raw, OffsetDateTime now)`
ловит все 3 возможных типа от драйвера:
  - java.sql.Timestamp (pgJDBC 42.6 и ранее)
  - java.time.Instant (pgJDBC 42.7+ — current behavior)
  - java.time.OffsetDateTime (если jdbc.timestamp.return-type=offset-date-time)

При любом другом типе бросаем IllegalStateException — fail-loud вместо
silent CCE на каждом запросе.

Tests:
  + snapshots_acceptsInstantFromPg42_7Driver — regression на конкретный
    bug (Instant from current driver)
  + snapshots_acceptsOffsetDateTime — defensive coverage
  + snapshots_rejectsUnexpectedTimestampType — fail-loud branch

Existing tests (с Timestamp) сохранены для backward-compat.
This commit is contained in:
Zimin A.N.
2026-05-19 23:01:37 +03:00
parent 9f032e9b5d
commit d908febc91
2 changed files with 94 additions and 1 deletions
@@ -88,7 +88,11 @@ public class SnapshotsService {
boolean currentMarked = false; boolean currentMarked = false;
DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY; DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY;
for (Object[] row : rows) { for (Object[] row : rows) {
OffsetDateTime bucket = ((java.sql.Timestamp) row[0]).toInstant().atOffset(now.getOffset()); // pgJDBC 42.7+ возвращает timestamptz как java.time.Instant вместо
// java.sql.Timestamp (как было в 42.6). Native query тип определяется
// в runtime — поддерживаем оба + OffsetDateTime для completeness.
// Без этой branch'и был ClassCastException 500 на каждом запросе.
OffsetDateTime bucket = toBucketOffsetDateTime(row[0], now);
long changedSinceLast = ((Number) row[1]).longValue(); long changedSinceLast = ((Number) row[1]).longValue();
boolean isCurrent = !currentMarked && !bucket.isAfter(now); boolean isCurrent = !currentMarked && !bucket.isAfter(now);
if (isCurrent) currentMarked = true; if (isCurrent) currentMarked = true;
@@ -103,6 +107,38 @@ public class SnapshotsService {
return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots); return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots);
} }
/**
* Convert native-query Object[] timestamp column → OffsetDateTime.
*
* <p>Postgres JDBC driver returns timestamptz columns в разных форматах
* в зависимости от версии:
* <ul>
* <li>42.6 и старше — {@link java.sql.Timestamp}</li>
* <li>42.7+ (current) — {@link java.time.Instant}</li>
* <li>при {@code jdbc.timestamp.return-type=offset-date-time} —
* {@link OffsetDateTime} напрямую</li>
* </ul>
*
* <p>Native query через {@code @Query(nativeQuery = true)} возвращает Object[]
* без compile-time типа — поддерживаем все три варианта. Иначе CCE на runtime.
*/
static OffsetDateTime toBucketOffsetDateTime(Object raw, OffsetDateTime now) {
if (raw == null) {
throw new IllegalStateException("bucket timestamp is null in result row");
}
if (raw instanceof java.sql.Timestamp ts) {
return ts.toInstant().atOffset(now.getOffset());
}
if (raw instanceof java.time.Instant inst) {
return inst.atOffset(now.getOffset());
}
if (raw instanceof OffsetDateTime odt) {
return odt.withOffsetSameInstant(now.getOffset());
}
throw new IllegalStateException(
"Unexpected timestamp type from native query: " + raw.getClass().getName());
}
// === DTOs === // === DTOs ===
public record Snapshot( public record Snapshot(
@@ -112,6 +112,63 @@ class SnapshotsServiceTest {
assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L); assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L);
} }
/**
* Регрессия prod-bug 2026-05-19: pgJDBC 42.7+ возвращает timestamptz как
* {@link java.time.Instant} вместо {@code java.sql.Timestamp}. Старый код
* жёстко кастил в Timestamp → ClassCastException на каждом запросе.
*
* <p>Тест проверяет что service корректно мапит Instant в OffsetDateTime.
*/
@Test
void snapshots_acceptsInstantFromPg42_7Driver() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
java.time.Instant april = OffsetDateTime.parse("2026-04-01T00:00:00Z").toInstant();
java.time.Instant may = OffsetDateTime.parse("2026-05-11T00:00:00Z").toInstant();
when(recRepo.findSnapshotBuckets(eq(d.getId()), anyString(), any(), any(), eq("day")))
.thenReturn(List.<Object[]>of(
new Object[]{may, 5L},
new Object[]{april, 3L}));
var result = service.findSnapshots("dict", null, null, "day");
assertThat(result.snapshots()).hasSize(2);
assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(3L);
assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L);
}
/**
* Defensive coverage: если configurable
* {@code pgjdbc.timestamp.return-type=offset-date-time} в будущем включат,
* helper должен handle и OffsetDateTime. (Сейчас не активно, но проверяем
* чтобы не сломаться при future driver tuning.)
*/
@Test
void snapshots_acceptsOffsetDateTime() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
OffsetDateTime apr = OffsetDateTime.parse("2026-04-01T00:00:00Z");
when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString()))
.thenReturn(List.<Object[]>of(new Object[]{apr, 1L}));
var result = service.findSnapshots("dict", null, null, "day");
assertThat(result.snapshots()).hasSize(1);
assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(1L);
}
@Test
void snapshots_rejectsUnexpectedTimestampType() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString()))
.thenReturn(List.<Object[]>of(new Object[]{"2026-04-01", 1L}));
assertThatThrownBy(() -> service.findSnapshots("dict", null, null, "day"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Unexpected timestamp type");
}
@Test @Test
void snapshots_currentMarkedOnce_evenWithFutureBuckets() { void snapshots_currentMarkedOnce_evenWithFutureBuckets() {
var d = def("dict", DataScope.PUBLIC); var d = def("dict", DataScope.PUBLIC);