This is an automated email from the ASF dual-hosted git repository.

cyyang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 488df340a1 [Fix]: Use a connection pool to avoid concurrent access to 
duckdb files. (#3898)
488df340a1 is described below

commit 488df340a14ca8d139278a99a4158eea22c6b76f
Author: Logic <[email protected]>
AuthorDate: Wed Dec 10 15:38:30 2025 +0800

    [Fix]: Use a connection pool to avoid concurrent access to duckdb files. 
(#3898)
    
    Signed-off-by: Logic <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 .../src/main/resources/application-test.yml        |   3 +-
 .../src/main/resources/application.yml             |   1 +
 .../tsdb/duckdb/DuckdbDatabaseDataStorage.java     | 104 ++++++++++++++-------
 .../history/tsdb/duckdb/DuckdbProperties.java      |  17 ++--
 script/application.yml                             |   1 +
 5 files changed, 82 insertions(+), 44 deletions(-)

diff --git a/hertzbeat-startup/src/main/resources/application-test.yml 
b/hertzbeat-startup/src/main/resources/application-test.yml
index 029530d621..2df2509604 100644
--- a/hertzbeat-startup/src/main/resources/application-test.yml
+++ b/hertzbeat-startup/src/main/resources/application-test.yml
@@ -52,12 +52,13 @@ spring:
 common:
   queue:
     type: memory
-    
+
 warehouse:
   store:
     duckdb:
       enabled: true
       expire-time: 90d
+      store-path: data/history.duckdb
     victoria-metrics:
       enabled: false
       url: http://localhost:8428
diff --git a/hertzbeat-startup/src/main/resources/application.yml 
b/hertzbeat-startup/src/main/resources/application.yml
index c5d00079ed..85ff831d59 100644
--- a/hertzbeat-startup/src/main/resources/application.yml
+++ b/hertzbeat-startup/src/main/resources/application.yml
@@ -166,6 +166,7 @@ warehouse:
       enabled: true
       # The maximum retention time for history records, after which records 
will be deleted
       expire-time: 90d
+      store-path: data/history.duckdb
     victoria-metrics:
       # Standalone mode toggle — must be set to false when using cluster mode
       enabled: false
diff --git 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbDatabaseDataStorage.java
 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbDatabaseDataStorage.java
index f8c274fb4b..ff414bfe11 100644
--- 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbDatabaseDataStorage.java
+++ 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbDatabaseDataStorage.java
@@ -17,6 +17,8 @@
 
 package org.apache.hertzbeat.warehouse.store.history.tsdb.duckdb;
 
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.math.NumberUtils;
 import org.apache.hertzbeat.common.constants.CommonConstants;
@@ -33,7 +35,6 @@ import org.springframework.stereotype.Component;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.sql.Connection;
-import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
@@ -59,18 +60,17 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
 
     private static final String DRIVER_NAME = "org.duckdb.DuckDBDriver";
     private static final String URL_PREFIX = "jdbc:duckdb:";
-    private static final String DEFAULT_DB_FILE = "data/history.duckdb";
 
     // Ideal number of data points for charting (avoids frontend lag)
     private static final int TARGET_CHART_POINTS = 800;
 
-    
@org.springframework.beans.factory.annotation.Value("${warehouse.store.duckdb.expire-time:30d}")
-    private String expireTimeStr;
-
+    private final String expireTimeStr;
     private final String dbPath;
+    private HikariDataSource dataSource;
 
-    public DuckdbDatabaseDataStorage() {
-        this.dbPath = DEFAULT_DB_FILE;
+    public DuckdbDatabaseDataStorage(DuckdbProperties duckdbProperties) {
+        this.dbPath = duckdbProperties.storePath();
+        this.expireTimeStr = duckdbProperties.expireTime();
         this.serverAvailable = initDuckDb();
         if (this.serverAvailable) {
             startExpiredDataCleaner();
@@ -80,9 +80,24 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
     private boolean initDuckDb() {
         try {
             Class.forName(DRIVER_NAME);
-            try (Connection connection = 
DriverManager.getConnection(URL_PREFIX + dbPath);
+
+            // Initialize HikariCP
+            HikariConfig config = new HikariConfig();
+            config.setJdbcUrl(URL_PREFIX + dbPath);
+            config.setDriverClassName(DRIVER_NAME);
+            config.setPoolName("DuckDB-Pool");
+            // Important: Maintain at least one connection to keep the DB file 
lock held
+            // and avoid frequent open/close of the embedded DB file which 
causes lock errors.
+            config.setMinimumIdle(1);
+            config.setMaximumPoolSize(10);
+            config.setConnectionTimeout(30000);
+            config.setConnectionTestQuery("SELECT 1");
+
+            this.dataSource = new HikariDataSource(config);
+
+            try (Connection connection = this.dataSource.getConnection();
                  Statement statement = connection.createStatement()) {
-                // instance app metrics metric metric_type int32_value 
double_value str_value record_time labels
+
                 String createTableSql = """
                         CREATE TABLE IF NOT EXISTS hzb_history (
                         instance VARCHAR,
@@ -96,16 +111,16 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
                         record_time BIGINT,
                         labels VARCHAR)""";
                 statement.execute(createTableSql);
-                // Add composite index for query performance
-                String createCompositeIndexSql = "CREATE INDEX IF NOT EXISTS 
idx_hzb_history_composite ON hzb_history(instance, app, metrics, metric, 
record_time)";
-                statement.execute(createCompositeIndexSql);
-                // Add index for cleanup performance
-                String createRecordTimeIndexSql = "CREATE INDEX IF NOT EXISTS 
idx_hzb_history_record_time ON hzb_history(record_time)";
-                statement.execute(createRecordTimeIndexSql);
+                // Re-add indexes for performance on queries and cleanup
+                statement.execute("CREATE INDEX IF NOT EXISTS 
idx_hzb_history_composite ON hzb_history (instance, app, metrics, metric, 
record_time)");
+                statement.execute("CREATE INDEX IF NOT EXISTS 
idx_hzb_history_record_time ON hzb_history (record_time)");
                 return true;
             }
         } catch (Exception e) {
             log.error("Failed to init duckdb: {}", e.getMessage(), e);
+            if (this.dataSource != null) {
+                this.dataSource.close();
+            }
             return false;
         }
     }
@@ -116,12 +131,14 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
             thread.setDaemon(true);
             return thread;
         });
+        // Run every 1 hour
         scheduledExecutor.scheduleAtFixedRate(() -> {
+            log.info("[duckdb] start data cleaner and checkpoint...");
             long expireTime;
             try {
                 if (NumberUtils.isParsable(expireTimeStr)) {
                     expireTime = NumberUtils.toLong(expireTimeStr);
-                    expireTime = (ZonedDateTime.now().toEpochSecond() + 
expireTime) * 1000L;
+                    expireTime = (ZonedDateTime.now().toEpochSecond() - 
expireTime) * 1000L;
                 } else {
                     TemporalAmount temporalAmount = 
TimePeriodUtil.parseTokenTime(expireTimeStr);
                     ZonedDateTime dateTime = 
ZonedDateTime.now().minus(temporalAmount);
@@ -132,13 +149,23 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
                 ZonedDateTime dateTime = 
ZonedDateTime.now().minus(Duration.ofDays(30));
                 expireTime = dateTime.toEpochSecond() * 1000L;
             }
-            try (Connection connection = 
DriverManager.getConnection(URL_PREFIX + dbPath);
-                 PreparedStatement statement = 
connection.prepareStatement("DELETE FROM hzb_history WHERE record_time < ?")) {
-                statement.setLong(1, expireTime);
-                int rows = statement.executeUpdate();
-                if (rows > 0) {
-                    log.info("[duckdb] delete {} expired records.", rows);
+
+            try (Connection connection = this.dataSource.getConnection()) {
+                // 1. Delete expired data
+                try (PreparedStatement statement = 
connection.prepareStatement("DELETE FROM hzb_history WHERE record_time < ?")) {
+                    statement.setLong(1, expireTime);
+                    int rows = statement.executeUpdate();
+                    if (rows > 0) {
+                        log.info("[duckdb] delete {} expired records.", rows);
+                    }
+                }
+
+                // 2. Force Checkpoint to compress data and flush WAL
+                // This is crucial for keeping file size small and moving data 
from WAL to column store
+                try (Statement statement = connection.createStatement()) {
+                    statement.execute("CHECKPOINT");
                 }
+
             } catch (Exception e) {
                 log.error("[duckdb] clean expired data error: {}", 
e.getMessage(), e);
             }
@@ -155,7 +182,7 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
         String metrics = metricsData.getMetrics();
         String insertSql = "INSERT INTO hzb_history VALUES (?, ?, ?, ?, ?, ?, 
?, ?, ?, ?)";
 
-        try (Connection connection = DriverManager.getConnection(URL_PREFIX + 
dbPath);
+        try (Connection connection = this.dataSource.getConnection();
              PreparedStatement preparedStatement = 
connection.prepareStatement(insertSql)) {
 
             RowWrapper rowWrapper = metricsData.readRow();
@@ -200,12 +227,7 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
                                 }
                                 case CommonConstants.TYPE_TIME -> {
                                     preparedStatement.setShort(5, (short) 
CommonConstants.TYPE_TIME);
-                                    try {
-                                        preparedStatement.setInt(6, 
Integer.parseInt(columnValue));
-                                    } catch (NumberFormatException nfe) {
-                                        log.warn("Failed to parse columnValue 
'{}' as integer for metric '{}'. Setting value to null.", columnValue, metric, 
nfe);
-                                        preparedStatement.setObject(6, null);
-                                    }
+                                    preparedStatement.setInt(6, 
Integer.parseInt(columnValue));
                                     preparedStatement.setObject(7, null);
                                     preparedStatement.setObject(8, null);
                                 }
@@ -240,9 +262,18 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
             return instanceValuesMap;
         }
 
-        // Raw data query - limit to avoid memory issues
-        StringBuilder sqlBuilder = new StringBuilder("SELECT record_time, 
metric_type, int32_value, double_value,"
-                + " str_value, labels FROM hzb_history WHERE instance = ? AND 
app = ? AND metrics = ? AND metric = ?");
+        StringBuilder sqlBuilder = new StringBuilder("""
+            SELECT record_time,
+            metric_type,
+            int32_value,
+            double_value,
+            str_value,
+            labels FROM hzb_history
+            WHERE instance = ?
+            AND app = ?
+            AND metrics = ?
+            AND metric = ?
+            """);
 
         long timeBefore = 0;
         if (history != null) {
@@ -257,7 +288,7 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
         }
         sqlBuilder.append(" ORDER BY record_time DESC LIMIT 20000"); // Add 
safety limit for raw data
 
-        try (Connection connection = DriverManager.getConnection(URL_PREFIX + 
dbPath);
+        try (Connection connection = this.dataSource.getConnection();
              PreparedStatement preparedStatement = 
connection.prepareStatement(sqlBuilder.toString())) {
 
             preparedStatement.setString(1, instance);
@@ -330,7 +361,7 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
                 GROUP BY ts_bucket, metric_type, labels
                 ORDER BY ts_bucket""";
 
-        try (Connection connection = DriverManager.getConnection(URL_PREFIX + 
dbPath);
+        try (Connection connection = this.dataSource.getConnection();
              PreparedStatement preparedStatement = 
connection.prepareStatement(sql)) {
 
             preparedStatement.setLong(1, interval);
@@ -398,5 +429,8 @@ public class DuckdbDatabaseDataStorage extends 
AbstractHistoryDataStorage {
 
     @Override
     public void destroy() throws Exception {
+        if (this.dataSource != null) {
+            this.dataSource.close();
+        }
     }
-}
\ No newline at end of file
+}
diff --git 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbProperties.java
 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbProperties.java
index 40fba5fe18..5f50a5a686 100644
--- 
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbProperties.java
+++ 
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/duckdb/DuckdbProperties.java
@@ -6,7 +6,7 @@
  * (the "License"); you may not use this file except in compliance with
  * the License.  You may obtain a copy of the License at
  *
- *     http://www.apache.org/licenses/LICENSE-2.0
+ *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -26,14 +26,15 @@ import 
org.springframework.boot.context.properties.bind.DefaultValue;
 /**
  * Duckdb configuration information
  * @param enabled use duckdb store metrics history data
- * @param expireTime save data expire time(ms)
+ * @param expireTime save data expire time (supports formats like '30d', 
'90d', or milliseconds)
+ * @param storePath duckdb database file path
  */
-
 @ConfigurationProperties(prefix = 
ConfigConstants.FunctionModuleConstants.WAREHOUSE
-               + SignConstants.DOT
-               + WarehouseConstants.STORE
-               + SignConstants.DOT
-               + WarehouseConstants.HistoryName.DUCKDB)
+    + SignConstants.DOT
+    + WarehouseConstants.STORE
+    + SignConstants.DOT
+    + WarehouseConstants.HistoryName.DUCKDB)
 public record DuckdbProperties(@DefaultValue("true") boolean enabled,
-                                @DefaultValue("90d") String expireTime) {
+                               @DefaultValue("90d") String expireTime,
+                               @DefaultValue("data/history.duckdb") String 
storePath) {
 }
diff --git a/script/application.yml b/script/application.yml
index c5d00079ed..85ff831d59 100644
--- a/script/application.yml
+++ b/script/application.yml
@@ -166,6 +166,7 @@ warehouse:
       enabled: true
       # The maximum retention time for history records, after which records 
will be deleted
       expire-time: 90d
+      store-path: data/history.duckdb
     victoria-metrics:
       # Standalone mode toggle — must be set to false when using cluster mode
       enabled: false


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to