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:
+37
-1
@@ -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(
|
||||||
|
|||||||
+57
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user