diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java index d6b9ae1..45df50f 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java @@ -88,7 +88,11 @@ public class SnapshotsService { boolean currentMarked = false; DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY; 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(); boolean isCurrent = !currentMarked && !bucket.isAfter(now); if (isCurrent) currentMarked = true; @@ -103,6 +107,38 @@ public class SnapshotsService { return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots); } + /** + * Convert native-query Object[] timestamp column → OffsetDateTime. + * + *
Postgres JDBC driver returns timestamptz columns в разных форматах + * в зависимости от версии: + *
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 === public record Snapshot( diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java index 3335fdd..95440ca 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java @@ -112,6 +112,63 @@ class SnapshotsServiceTest { 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 на каждом запросе. + * + *
Тест проверяет что 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.