From 4a326d14afc80297dd4ab55ca1987e9e591dbe70 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 6 Jul 2026 14:04:55 -0700 Subject: [PATCH 1/4] 1223: Warn when web and database server time differ --- .../labkey/api/data/dialect/SqlDialect.java | 55 ++++++++++++++++++- core/src/org/labkey/core/admin/admin.jsp | 22 +++----- .../core/dialect/PostgreSql92Dialect.java | 2 + .../experiment/api/ExpMaterialTableImpl.java | 16 ++---- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index 06f31efb50b..4c018ebaf40 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -59,6 +59,7 @@ import org.labkey.api.module.ModuleLoader; import org.labkey.api.query.FieldKey; import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.HtmlString; import org.labkey.api.util.MemTracker; import org.labkey.api.util.StringUtilsLabKey; import org.labkey.api.util.SystemMaintenance; @@ -80,6 +81,8 @@ import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -1980,12 +1983,60 @@ public final Collection getExecutionPlan(DbScope scope, SQLFragment sql, // Add any database configuration warnings (e.g., missing aggregate function or deprecated database server version) // to display in the page header for administrators. This will be called: // - Only on the LabKey DataSource's dialect instance (not external data sources) - // - After the core module has been upgraded and the dialect has been prepared for the last time, meaning the dialect + // - After the core module has been upgraded, and the dialect has been prepared for the last time, meaning the dialect // should reflect the final database configuration public void addAdminWarningMessages(Warnings warnings, boolean showAllWarnings) { } + public static final long TIME_DIFFERENCE_WARNING_SECONDS = 10; + + public static ServerDatabaseTimeDifference getServerDatabaseTimeDifference(DbScope scope) + { + LocalDateTime serverTime = LocalDateTime.now(); + LocalDateTime databaseTime = new SqlSelector(scope, "SELECT CURRENT_TIMESTAMP").getObject(LocalDateTime.class); + + return new ServerDatabaseTimeDifference(serverTime, databaseTime); + } + + public record ServerDatabaseTimeDifference(LocalDateTime serverTime, LocalDateTime databaseTime) + { + public long getSeconds() + { + return Math.abs(Duration.between(serverTime, databaseTime).toSeconds()); + } + + public boolean exceedsWarningThreshold() + { + return getSeconds() > TIME_DIFFERENCE_WARNING_SECONDS; + } + } + + // GH Issue #1223: Add a site configuration warning for administrators if the server and database clocks differ. + protected void addTimeDifferenceWarning(Warnings warnings, boolean showAllWarnings) + { + try + { + ServerDatabaseTimeDifference difference = getServerDatabaseTimeDifference(DbScope.getLabKeyScope()); + + if (difference.exceedsWarningThreshold()) + warnings.add(getTimeDifferenceWarning(difference.getSeconds())); + else if (showAllWarnings) + warnings.add(getTimeDifferenceWarning(TIME_DIFFERENCE_WARNING_SECONDS + 1)); + } + catch (Exception e) + { + LOG.warn("Unable to compare web server and database server times", e); + } + } + + private HtmlString getTimeDifferenceWarning(long seconds) + { + return HtmlString.of("The web server and database server times differ by " + seconds + " seconds. " + + "LabKey Server often relies on comparing timestamps stored in the database with timestamps generated by " + + "the web server, so this difference can lead to data integrity issues. Synchronize the clocks on these servers."); + } + public abstract List getChangeStatements(TableChange change); public abstract void purgeTempSchema(Map createdTableNames); @@ -2007,7 +2058,7 @@ protected void trackTempTables(Map createdTableNames) // Defragment an index, if necessary public void defragmentIndex(DbSchema schema, String tableSelectName, String indexName) { - // By default do nothing + // By default, do nothing } public boolean isTableExists(DbScope scope, String schema, String name) diff --git a/core/src/org/labkey/core/admin/admin.jsp b/core/src/org/labkey/core/admin/admin.jsp index 15b4821c403..4e394cd9d80 100644 --- a/core/src/org/labkey/core/admin/admin.jsp +++ b/core/src/org/labkey/core/admin/admin.jsp @@ -19,7 +19,7 @@ <%@ page import="org.apache.commons.lang3.StringUtils" %> <%@ page import="org.labkey.api.admin.AdminBean" %> <%@ page import="org.labkey.api.data.DbScope" %> -<%@ page import="org.labkey.api.data.SqlSelector" %> +<%@ page import="org.labkey.api.data.dialect.SqlDialect" %> <%@ page import="org.labkey.api.files.FileContentService" %> <%@ page import="org.labkey.api.module.DefaultModule" %> <%@ page import="org.labkey.api.module.Module" %> @@ -33,13 +33,12 @@ <%@ page import="org.labkey.api.view.NavTree" %> <%@ page import="org.labkey.core.admin.AdminController" %> <%@ page import="java.text.DecimalFormat" %> -<%@ page import="java.time.Duration" %> -<%@ page import="java.time.LocalDateTime" %> <%@ page import="java.time.format.DateTimeFormatter" %> <%@ page import="java.util.Collection" %> <%@ page import="java.util.Map" %> <%@ page import="java.util.TreeMap" %> <%@ page import="org.apache.commons.lang3.Strings" %> +<%@ page import="org.labkey.api.util.DateUtil" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% @@ -89,15 +88,12 @@ <% row = 0; - LocalDateTime serverTime = LocalDateTime.now(); - LocalDateTime databaseTime = new SqlSelector(DbScope.getLabKeyScope(), "SELECT CURRENT_TIMESTAMP").getObject(LocalDateTime.class); - long duration = Math.abs(Duration.between(serverTime, databaseTime).toSeconds()); + SqlDialect.ServerDatabaseTimeDifference timeDifference = SqlDialect.getServerDatabaseTimeDifference(DbScope.getLabKeyScope()); + boolean exceedsThreshold = timeDifference.exceedsWarningThreshold(); - // Warn if greater than this many seconds - long warningSeconds = 10; - - HtmlString style = unsafe(duration > warningSeconds ? " style=\"color:red;\"" : ""); - HtmlString warning = unsafe(duration > warningSeconds ? " - Warning: Web and database server times differ by " + duration + " seconds!" : ""); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateUtil.getJsonDateTimeFormatString()); + HtmlString style = unsafe(exceedsThreshold ? " style=\"color:red;\"" : ""); + HtmlString warning = unsafe(exceedsThreshold ? " - Warning: Web and database server times differ by " + timeDifference.getSeconds() + " seconds!" : ""); %>

Runtime Information

@@ -123,8 +119,8 @@ ><%=h(AdminBean.serverStartupTime)%> - ><%=h(serverTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")))%><%=warning%> - ><%=h(databaseTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")))%><%=warning%> + ><%=h(timeDifference.serverTime().format(dateTimeFormatter))%><%=warning%> + ><%=h(timeDifference.databaseTime().format(dateTimeFormatter))%><%=warning%>
Server GUID<%=h(AdminBean.serverGuid)%>
Server Session GUID<%=h(AdminBean.serverSessionGuid)%>
Server Startup Time
Web Server Time
Database Server Time
Web Server Time
Database Server Time
diff --git a/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java b/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java index 55fa2623127..6c09ab7fbc0 100644 --- a/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java +++ b/core/src/org/labkey/core/dialect/PostgreSql92Dialect.java @@ -371,6 +371,8 @@ public void addAdminWarningMessages(Warnings warnings, boolean showAllWarnings) super.addAdminWarningMessages(warnings, showAllWarnings); if (showAllWarnings) warnings.add(HtmlString.of(PostgreSqlDialectFactory.getStandardWarningMessage("has not been tested against", getMajorVersion() + ".x"))); + + addTimeDifferenceWarning(warnings, showAllWarnings); } private int getIdentifierMaxByteLength() diff --git a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java index 63bb3b8e304..38f71e66222 100644 --- a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java @@ -139,8 +139,6 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.sql.Timestamp; -import java.time.Duration; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -1406,19 +1404,15 @@ private _MaterializedQueryHelper getOrCreateMQH() */ private static boolean isIncrementalUpdateDisabled() { + // Disable if web server and database time differ if (_incrementalUpdateDisabled == null) { - // borrowed from core/admin.jsp - LocalDateTime databaseTime = new SqlSelector(DbScope.getLabKeyScope(), "SELECT CURRENT_TIMESTAMP").getObject(LocalDateTime.class); - LocalDateTime serverTime = LocalDateTime.now(); - - // Disable if greater than this many seconds - long thresholdSeconds = 10; - long deltaSeconds = Math.abs(Duration.between(serverTime, databaseTime).toSeconds()); - _incrementalUpdateDisabled = deltaSeconds > thresholdSeconds; + DbScope scope = DbScope.getLabKeyScope(); + SqlDialect.ServerDatabaseTimeDifference difference = SqlDialect.getServerDatabaseTimeDifference(scope); + _incrementalUpdateDisabled = difference.exceedsWarningThreshold(); if (_incrementalUpdateDisabled) - _log.warn("Incremental update disabled for samples. Web and database server time differ by {} seconds which exceeds the threshold of {} seconds. You may experience degraded sample query performance.", deltaSeconds, thresholdSeconds); + _log.warn("Incremental update disabled for samples. Web and database server time differ by {} seconds which exceeds the threshold of {} seconds. You may experience degraded sample query performance.", difference.getSeconds(), SqlDialect.TIME_DIFFERENCE_WARNING_SECONDS); } return _incrementalUpdateDisabled; From 07c632a8902b56d2ad8db3123f0bdb5b05b2affd Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 6 Jul 2026 14:15:59 -0700 Subject: [PATCH 2/4] unsafe --- core/src/org/labkey/core/admin/admin.jsp | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/core/src/org/labkey/core/admin/admin.jsp b/core/src/org/labkey/core/admin/admin.jsp index 4e394cd9d80..2d7ed565776 100644 --- a/core/src/org/labkey/core/admin/admin.jsp +++ b/core/src/org/labkey/core/admin/admin.jsp @@ -30,6 +30,7 @@ <%@ page import="org.labkey.api.settings.AppProps" %> <%@ page import="org.labkey.api.util.Formats" %> <%@ page import="org.labkey.api.util.HtmlString"%> +<%@ page import="org.labkey.api.util.HtmlStringBuilder" %> <%@ page import="org.labkey.api.view.NavTree" %> <%@ page import="org.labkey.core.admin.AdminController" %> <%@ page import="java.text.DecimalFormat" %> @@ -51,6 +52,7 @@ .lk-admin-section { display: none; } .header-title { margin-bottom: 5px; } .header-link { margin: 0 5px 20px 0; } + .lk-server-time-warning { color: red; }
@@ -87,13 +89,19 @@
<% row = 0; + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateUtil.getJsonDateTimeFormatString()); + String timeCellCls = ""; + HtmlString warning = HtmlString.EMPTY_STRING; SqlDialect.ServerDatabaseTimeDifference timeDifference = SqlDialect.getServerDatabaseTimeDifference(DbScope.getLabKeyScope()); - boolean exceedsThreshold = timeDifference.exceedsWarningThreshold(); - - DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateUtil.getJsonDateTimeFormatString()); - HtmlString style = unsafe(exceedsThreshold ? " style=\"color:red;\"" : ""); - HtmlString warning = unsafe(exceedsThreshold ? " - Warning: Web and database server times differ by " + timeDifference.getSeconds() + " seconds!" : ""); + if (timeDifference.exceedsWarningThreshold()) + { + timeCellCls = "lk-server-time-warning"; + warning = HtmlStringBuilder.of(" - Warning: Web and database server times differ by ") + .append(timeDifference.getSeconds()) + .append(" seconds!") + .getHtmlString(); + } %>

Runtime Information

@@ -118,9 +126,9 @@ - ><%=h(AdminBean.serverStartupTime)%> - ><%=h(timeDifference.serverTime().format(dateTimeFormatter))%><%=warning%> - ><%=h(timeDifference.databaseTime().format(dateTimeFormatter))%><%=warning%> + + +
Working Dir<%=h(AdminBean.workingDir)%>
Server GUID<%=h(AdminBean.serverGuid)%>
Server Session GUID<%=h(AdminBean.serverSessionGuid)%>
Server Startup Time
Web Server Time
Database Server Time
Server Startup Time<%=h(AdminBean.serverStartupTime)%>
Web Server Time<%=h(timeDifference.serverTime().format(dateTimeFormatter))%><%=warning%>
Database Server Time<%=h(timeDifference.databaseTime().format(dateTimeFormatter))%><%=warning%>
From 7ebd26aef02b4c11cf32684fec22156b4ed48cea Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 6 Jul 2026 14:42:58 -0700 Subject: [PATCH 3/4] test case --- .../labkey/api/data/dialect/SqlDialect.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index 4c018ebaf40..d7bca3e80cc 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -98,6 +98,7 @@ import java.util.regex.Pattern; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -2460,5 +2461,37 @@ public void testProcedureIdentifierQuoting() // A dotted name is rejected: schema and procedure are separate parameters, so a '.' in a single component is an ambiguous schema-qualified reference assertThrows(IllegalArgumentException.class, () -> dialect.buildProcedureCall(core.getName(), "a.b", 0, false, false, scope)); } + + // GH Issue #1223: Verify the server/database clock-skew arithmetic and warning threshold + @Test + public void testServerDatabaseTimeDifference() + { + LocalDateTime base = LocalDateTime.of(2026, 7, 6, 12, 0, 0); + + // Identical times: zero difference, no warning + ServerDatabaseTimeDifference equal = new ServerDatabaseTimeDifference(base, base); + assertEquals(0, equal.getSeconds()); + assertFalse(equal.exceedsWarningThreshold()); + + // Comfortably below the threshold: no warning + ServerDatabaseTimeDifference below = new ServerDatabaseTimeDifference(base, base.plusSeconds(5)); + assertEquals(5, below.getSeconds()); + assertFalse(below.exceedsWarningThreshold()); + + // Exactly at the threshold: no warning (comparison is strictly greater-than) + ServerDatabaseTimeDifference atThreshold = new ServerDatabaseTimeDifference(base, base.plusSeconds(TIME_DIFFERENCE_WARNING_SECONDS)); + assertEquals(TIME_DIFFERENCE_WARNING_SECONDS, atThreshold.getSeconds()); + assertFalse(atThreshold.exceedsWarningThreshold()); + + // Just past the threshold: warning + ServerDatabaseTimeDifference over = new ServerDatabaseTimeDifference(base, base.plusSeconds(TIME_DIFFERENCE_WARNING_SECONDS + 1)); + assertEquals(TIME_DIFFERENCE_WARNING_SECONDS + 1, over.getSeconds()); + assertTrue(over.exceedsWarningThreshold()); + + // Difference is absolute: database clock behind the web server is treated the same as ahead + ServerDatabaseTimeDifference behind = new ServerDatabaseTimeDifference(base, base.minusSeconds(TIME_DIFFERENCE_WARNING_SECONDS + 1)); + assertEquals(TIME_DIFFERENCE_WARNING_SECONDS + 1, behind.getSeconds()); + assertTrue(behind.exceedsWarningThreshold()); + } } } From d2fcdb2992f99aa30de6daf404f11962c1eff6c9 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 8 Jul 2026 08:13:47 -0700 Subject: [PATCH 4/4] Use Instant --- .../labkey/api/data/dialect/SqlDialect.java | 50 ++++++++++--------- core/src/org/labkey/core/admin/admin.jsp | 9 ++-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index cf6f63bc5a0..90eb7cc62c0 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -80,9 +80,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Timestamp; import java.sql.Types; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -867,25 +868,25 @@ public SQLFragment getNumericCast(SQLFragment expression) * @param arguments Arguments passed from the LK SQL * @return the dialect equivalent SQLFragrment */ - public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) - { - throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); - } - - public boolean supportsIsNumeric() - { - return false; - } - - public SQLFragment isNumericExpr(SQLFragment expression) - { - throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); - } - - public void handleCreateDatabaseException(SQLException e) throws ServletException - { - throw(new ServletException("Can't create database", e)); - } + public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public boolean supportsIsNumeric() + { + return false; + } + + public SQLFragment isNumericExpr(SQLFragment expression) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public void handleCreateDatabaseException(SQLException e) throws ServletException + { + throw(new ServletException("Can't create database", e)); + } /** * Wrap one or more INSERT statements to allow explicit specification @@ -2004,13 +2005,14 @@ public void addAdminWarningMessages(Warnings warnings, boolean showAllWarnings) public static ServerDatabaseTimeDifference getServerDatabaseTimeDifference(DbScope scope) { - LocalDateTime serverTime = LocalDateTime.now(); - LocalDateTime databaseTime = new SqlSelector(scope, "SELECT CURRENT_TIMESTAMP").getObject(LocalDateTime.class); + // Compare Instants, not wall-clock values, so the skew is measured correctly even when the servers are in different time zones. + Instant serverTime = Instant.now(); + Instant databaseTime = new SqlSelector(scope, "SELECT CURRENT_TIMESTAMP").getObject(Timestamp.class).toInstant(); return new ServerDatabaseTimeDifference(serverTime, databaseTime); } - public record ServerDatabaseTimeDifference(LocalDateTime serverTime, LocalDateTime databaseTime) + public record ServerDatabaseTimeDifference(Instant serverTime, Instant databaseTime) { public long getSeconds() { @@ -2476,7 +2478,7 @@ public void testProcedureIdentifierQuoting() @Test public void testServerDatabaseTimeDifference() { - LocalDateTime base = LocalDateTime.of(2026, 7, 6, 12, 0, 0); + Instant base = Instant.parse("2026-07-06T12:00:00Z"); // Identical times: zero difference, no warning ServerDatabaseTimeDifference equal = new ServerDatabaseTimeDifference(base, base); diff --git a/core/src/org/labkey/core/admin/admin.jsp b/core/src/org/labkey/core/admin/admin.jsp index 3c9a79260ab..0bd0d93f3a1 100644 --- a/core/src/org/labkey/core/admin/admin.jsp +++ b/core/src/org/labkey/core/admin/admin.jsp @@ -35,6 +35,7 @@ <%@ page import="org.labkey.api.view.NavTree" %> <%@ page import="org.labkey.core.admin.AdminController" %> <%@ page import="java.text.DecimalFormat" %> +<%@ page import="java.time.ZoneId" %> <%@ page import="java.time.format.DateTimeFormatter" %> <%@ page import="java.util.ArrayList" %> <%@ page import="java.util.Collection" %> @@ -92,7 +93,7 @@
<% row = 0; - DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateUtil.getJsonDateTimeFormatString()); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateUtil.getJsonDateTimeFormatString()).withZone(ZoneId.systemDefault()); String timeCellCls = ""; HtmlString warning = HtmlString.EMPTY_STRING; @@ -129,9 +130,9 @@ Working Dir<%=h(AdminBean.workingDir)%> Server GUID<%=h(AdminBean.serverGuid)%> Server Session GUID<%=h(AdminBean.serverSessionGuid)%> - Server Startup Time<%=h(AdminBean.serverStartupTime)%> - Web Server Time<%=h(timeDifference.serverTime().format(dateTimeFormatter))%><%=warning%> - Database Server Time<%=h(timeDifference.databaseTime().format(dateTimeFormatter))%><%=warning%> + Server Startup Time<%=h(AdminBean.serverStartupTime)%> + Web Server Time<%=h(dateTimeFormatter.format(timeDifference.serverTime()))%><%=warning%> + Database Server Time<%=h(dateTimeFormatter.format(timeDifference.databaseTime()))%><%=warning%>