Merge branch 'fix/snapshots-timestamp-cast' into 'main'

fix(snapshots): handle Instant return from pgJDBC 42.7+ (был 500)

See merge request 2-6/2-6-4/terravault/ordinis!242
This commit is contained in:
Александр Зимин
2026-05-19 20:06:22 +00:00
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);