chore: promote develop to main and fuse docs navigation

Brings develop's compiler/CI fixes into main (main was red; the fixes
in PR#88-91 lived only on develop). This unblocks the dependabot PRs
that target main.

Resolves the docs/.vitepress/config.mts merge conflict by fusing both
navigation designs into 7 pillars (总览·架构·驱动·AI·基础·开发·运维):
develop's overview/operations pillars + main's standalone AI pillar.
- Keeps develop's version-switcher (version.mts/VersionSwitcher/VersionBanner)
- AdSense stays dropped per main's earlier perf decision (drop AdSense)
- technology-stack remains under 开发, since the develop-side rename to
  introduction/ was not carried through the merge (file still at development/)
This commit is contained in:
pnoker
2026-08-03 11:24:25 +08:00
79 changed files with 2517 additions and 483 deletions
+56
View File
@@ -215,6 +215,8 @@ RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
EXPOSE ${TCP_PORT}
EXPOSE ${UDP_PORT}
VOLUME /dc3-driver/dc3-driver-listening-virtual/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-listening-virtual/dc3/data/driver/listening-virtual
VOLUME /dc3-driver/dc3-driver-listening-virtual/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-listening-virtual.jar"]
@@ -229,6 +231,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-modbus-tcp/dc3/logs/driver/modbus-tcp/gc
COPY --from=builder /build/dc3-driver/dc3-driver-modbus-tcp/target/dc3-driver-modbus-tcp.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-modbus-tcp/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-modbus-tcp/dc3/data/driver/modbus-tcp
VOLUME /dc3-driver/dc3-driver-modbus-tcp/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-modbus-tcp.jar"]
@@ -243,6 +247,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-modbus-rtu/dc3/logs/driver/modbus-rtu/gc
COPY --from=builder /build/dc3-driver/dc3-driver-modbus-rtu/target/dc3-driver-modbus-rtu.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-modbus-rtu/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-modbus-rtu/dc3/data/driver/modbus-rtu
VOLUME /dc3-driver/dc3-driver-modbus-rtu/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-modbus-rtu.jar"]
@@ -257,6 +263,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-mqtt/dc3/logs/driver/mqtt/gc
COPY --from=builder /build/dc3-driver/dc3-driver-mqtt/target/dc3-driver-mqtt.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-mqtt/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-mqtt/dc3/data/driver/mqtt
VOLUME /dc3-driver/dc3-driver-mqtt/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-mqtt.jar"]
@@ -271,6 +279,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-opc-da/dc3/logs/driver/opc-da/gc
COPY --from=builder /build/dc3-driver/dc3-driver-opc-da/target/dc3-driver-opc-da.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-opc-da/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-opc-da/dc3/data/driver/opc-da
VOLUME /dc3-driver/dc3-driver-opc-da/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-opc-da.jar"]
@@ -285,6 +295,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-opc-ua/dc3/logs/driver/opc-ua/gc
COPY --from=builder /build/dc3-driver/dc3-driver-opc-ua/target/dc3-driver-opc-ua.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-opc-ua/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-opc-ua/dc3/data/driver/opc-ua
VOLUME /dc3-driver/dc3-driver-opc-ua/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-opc-ua.jar"]
@@ -299,6 +311,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-plcs7/dc3/logs/driver/plcs7/gc
COPY --from=builder /build/dc3-driver/dc3-driver-plcs7/target/dc3-driver-plcs7.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-plcs7/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-plcs7/dc3/data/driver/plcs7
VOLUME /dc3-driver/dc3-driver-plcs7/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-plcs7.jar"]
@@ -313,6 +327,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-virtual/dc3/logs/driver/virtual/gc
COPY --from=builder /build/dc3-driver/dc3-driver-virtual/target/dc3-driver-virtual.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-virtual/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-virtual/dc3/data/driver/virtual
VOLUME /dc3-driver/dc3-driver-virtual/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-virtual.jar"]
# ---------- dc3-driver-bacnet-ip ----------
@@ -325,6 +341,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-bacnet-ip/dc3/logs/driver/bacnet-ip/gc
COPY --from=builder /build/dc3-driver/dc3-driver-bacnet-ip/target/dc3-driver-bacnet-ip.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-bacnet-ip/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-bacnet-ip/dc3/data/driver/bacnet-ip
VOLUME /dc3-driver/dc3-driver-bacnet-ip/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-bacnet-ip.jar"]
@@ -338,6 +356,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-ble/dc3/logs/driver/ble/gc
COPY --from=builder /build/dc3-driver/dc3-driver-ble/target/dc3-driver-ble.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-ble/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-ble/dc3/data/driver/ble
VOLUME /dc3-driver/dc3-driver-ble/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-ble.jar"]
@@ -351,6 +371,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-can/dc3/logs/driver/can/gc
COPY --from=builder /build/dc3-driver/dc3-driver-can/target/dc3-driver-can.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-can/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-can/dc3/data/driver/can
VOLUME /dc3-driver/dc3-driver-can/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-can.jar"]
@@ -364,6 +386,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-coap/dc3/logs/driver/coap/gc
COPY --from=builder /build/dc3-driver/dc3-driver-coap/target/dc3-driver-coap.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-coap/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-coap/dc3/data/driver/coap
VOLUME /dc3-driver/dc3-driver-coap/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-coap.jar"]
@@ -377,6 +401,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-dlms/dc3/logs/driver/dlms/gc
COPY --from=builder /build/dc3-driver/dc3-driver-dlms/target/dc3-driver-dlms.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-dlms/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-dlms/dc3/data/driver/dlms
VOLUME /dc3-driver/dc3-driver-dlms/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-dlms.jar"]
@@ -390,6 +416,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-ethernet-ip/dc3/logs/driver/ethernet-ip/gc
COPY --from=builder /build/dc3-driver/dc3-driver-ethernet-ip/target/dc3-driver-ethernet-ip.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-ethernet-ip/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-ethernet-ip/dc3/data/driver/ethernet-ip
VOLUME /dc3-driver/dc3-driver-ethernet-ip/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-ethernet-ip.jar"]
@@ -403,6 +431,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-fins/dc3/logs/driver/fins/gc
COPY --from=builder /build/dc3-driver/dc3-driver-fins/target/dc3-driver-fins.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-fins/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-fins/dc3/data/driver/fins
VOLUME /dc3-driver/dc3-driver-fins/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-fins.jar"]
@@ -416,6 +446,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-http/dc3/logs/driver/http/gc
COPY --from=builder /build/dc3-driver/dc3-driver-http/target/dc3-driver-http.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-http/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-http/dc3/data/driver/http
VOLUME /dc3-driver/dc3-driver-http/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-http.jar"]
@@ -429,6 +461,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-iec104/dc3/logs/driver/iec104/gc
COPY --from=builder /build/dc3-driver/dc3-driver-iec104/target/dc3-driver-iec104.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-iec104/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-iec104/dc3/data/driver/iec104
VOLUME /dc3-driver/dc3-driver-iec104/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-iec104.jar"]
@@ -442,6 +476,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-lwm2m/dc3/logs/driver/lwm2m/gc
COPY --from=builder /build/dc3-driver/dc3-driver-lwm2m/target/dc3-driver-lwm2m.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-lwm2m/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-lwm2m/dc3/data/driver/lwm2m
VOLUME /dc3-driver/dc3-driver-lwm2m/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-lwm2m.jar"]
@@ -455,6 +491,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-melsec/dc3/logs/driver/melsec/gc
COPY --from=builder /build/dc3-driver/dc3-driver-melsec/target/dc3-driver-melsec.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-melsec/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-melsec/dc3/data/driver/melsec
VOLUME /dc3-driver/dc3-driver-melsec/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-melsec.jar"]
@@ -468,6 +506,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-mysql/dc3/logs/driver/mysql/gc
COPY --from=builder /build/dc3-driver/dc3-driver-mysql/target/dc3-driver-mysql.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-mysql/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-mysql/dc3/data/driver/mysql
VOLUME /dc3-driver/dc3-driver-mysql/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-mysql.jar"]
@@ -481,6 +521,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-oracle/dc3/logs/driver/oracle/gc
COPY --from=builder /build/dc3-driver/dc3-driver-oracle/target/dc3-driver-oracle.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-oracle/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-oracle/dc3/data/driver/oracle
VOLUME /dc3-driver/dc3-driver-oracle/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-oracle.jar"]
@@ -494,6 +536,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-postgresql/dc3/logs/driver/postgresql/gc
COPY --from=builder /build/dc3-driver/dc3-driver-postgresql/target/dc3-driver-postgresql.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-postgresql/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-postgresql/dc3/data/driver/postgresql
VOLUME /dc3-driver/dc3-driver-postgresql/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-postgresql.jar"]
@@ -507,6 +551,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-serial/dc3/logs/driver/serial/gc
COPY --from=builder /build/dc3-driver/dc3-driver-serial/target/dc3-driver-serial.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-serial/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-serial/dc3/data/driver/serial
VOLUME /dc3-driver/dc3-driver-serial/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-serial.jar"]
@@ -520,6 +566,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-sl651/dc3/logs/driver/sl651/gc
COPY --from=builder /build/dc3-driver/dc3-driver-sl651/target/dc3-driver-sl651.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-sl651/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-sl651/dc3/data/driver/sl651
VOLUME /dc3-driver/dc3-driver-sl651/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-sl651.jar"]
@@ -533,6 +581,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-snmp/dc3/logs/driver/snmp/gc
COPY --from=builder /build/dc3-driver/dc3-driver-snmp/target/dc3-driver-snmp.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-snmp/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-snmp/dc3/data/driver/snmp
VOLUME /dc3-driver/dc3-driver-snmp/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-snmp.jar"]
@@ -546,6 +596,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-sqlserver/dc3/logs/driver/sqlserver/gc
COPY --from=builder /build/dc3-driver/dc3-driver-sqlserver/target/dc3-driver-sqlserver.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-sqlserver/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-sqlserver/dc3/data/driver/sqlserver
VOLUME /dc3-driver/dc3-driver-sqlserver/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-sqlserver.jar"]
@@ -559,6 +611,8 @@ RUN mkdir -p /dc3-driver/dc3-driver-tcp-udp/dc3/logs/driver/tcp-udp/gc
COPY --from=builder /build/dc3-driver/dc3-driver-tcp-udp/target/dc3-driver-tcp-udp.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-tcp-udp/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-tcp-udp/dc3/data/driver/tcp-udp
VOLUME /dc3-driver/dc3-driver-tcp-udp/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-tcp-udp.jar"]
@@ -572,5 +626,7 @@ RUN mkdir -p /dc3-driver/dc3-driver-zigbee/dc3/logs/driver/zigbee/gc
COPY --from=builder /build/dc3-driver/dc3-driver-zigbee/target/dc3-driver-zigbee.jar ./
RUN cp /usr/share/dc3/entrypoint.sh ./entrypoint.sh
VOLUME /dc3-driver/dc3-driver-zigbee/dc3/logs
RUN mkdir -p /dc3-driver/dc3-driver-zigbee/dc3/data/driver/zigbee
VOLUME /dc3-driver/dc3-driver-zigbee/dc3/data
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dc3-driver-zigbee.jar"]
+21
View File
@@ -134,6 +134,12 @@ help:
@printf ' %s\n' 'make up GROUP=core'
@printf ' %s\n' 'make up STACK=optional SERVICES="prometheus grafana"'
@printf ' %s\n' 'make logs SERVICES="gateway agentic"'
@printf '%s\n' ''
@printf '%s\n' 'Documentation:'
@printf ' %-24s %s\n' 'make docs-dev' 'Start VitePress dev server (localhost:5173)'
@printf ' %-24s %s\n' 'make docs-build' 'Build static site to docs/.vitepress/dist/'
@printf ' %-24s %s\n' 'make docs-preview' 'Preview the built site'
@printf ' %-24s %s\n' 'make docs-version VERSION=X' 'Build versioned docs (archived, noindex)'
env:
@printf 'ENV_FILE=%s\n' "$(ENV_FILE)"
@@ -266,4 +272,19 @@ changelog:
openapi:
@OPENAPI_BASE="$(OPENAPI_BASE)" dc3/bin/export_openapi.sh $(OPENAPI_OUT)
.PHONY: docs-dev docs-build docs-preview docs-version
docs-dev:
cd docs && pnpm dev
docs-build:
cd docs && pnpm build
docs-preview:
cd docs && pnpm preview
docs-version:
@test -n "$(VERSION)" || { echo 'Usage: make docs-version VERSION=2025.9'; exit 1; }
cd docs && DC3_DOCS_VERSION=$(VERSION) DC3_DOCS_IS_LATEST=false pnpm build
@@ -54,6 +54,11 @@ public class ScheduleConstant {
*/
public static final String DEVICE_HEALTH_SCHEDULE_JOB = "device-health-schedule-job";
/**
* Buffer republish schedule job
*/
public static final String BUFFER_REPUBLISH_SCHEDULE_JOB = "buffer-republish-schedule-job";
/**
* Driver health schedule cron
*/
@@ -104,7 +104,13 @@ public class PointValueServiceImpl implements PointValueService {
}).collect(Collectors.groupingBy(PointValueBO::getDeviceId));
group.forEach(this::savePointValuesToRepository);
alarmRuleTriggerService.processPointValues(pointValueBOList);
try {
alarmRuleTriggerService.processPointValues(pointValueBOList);
} catch (Exception e) {
// Alarm evaluation runs after persistence: a failure here must not trigger a
// re-queue that would re-insert the already-persisted rows.
log.warn("Alarm rule evaluation failed, size={}, skipped", pointValueBOList.size(), e);
}
}
@Override
@@ -232,14 +238,15 @@ public class PointValueServiceImpl implements PointValueService {
// local hot cache
pointValueLocalCacheService.savePointValue(deviceId, pointValueBOList);
// other repository
// Repository persistence — wrap any failure (incl. checked IOException) so the
// ingest buffer can re-queue the batch for retry instead of silently dropping it.
RepositoryService repositoryService = getFirstRepositoryService();
List<List<PointValueBO>> splitPointValueBOList = ListUtils.partition(pointValueBOList, 100);
for (List<PointValueBO> splitPointValueBO : splitPointValueBOList) {
repositoryService.savePointValues(splitPointValueBO);
}
} catch (Exception e) {
log.error("Save point values failed, deviceId={}, size={}", deviceId, pointValueBOList.size(), e);
throw new RepositoryException(e);
}
}
@@ -19,44 +19,38 @@ package io.github.pnoker.common.data.biz.impl;
import io.github.pnoker.common.constant.driver.ScheduleConstant;
import io.github.pnoker.common.data.biz.ScheduleForDataService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.data.job.HourlyJobForData;
import io.github.pnoker.common.data.job.PointValueJob;
import io.github.pnoker.common.exception.ServiceException;
import io.github.pnoker.common.quartz.QuartzService;
import lombok.RequiredArgsConstructor;
import org.quartz.DateBuilder;
import org.quartz.SchedulerException;
import org.springframework.stereotype.Service;
/**
* Business service implementation for data-center scheduled jobs.
*
* <p>Point-value ingestion no longer has a Quartz tick here — it is driven by
* {@link io.github.pnoker.common.data.buffer.PointValueIngestBuffer}'s worker threads. Only the
* hourly maintenance job remains.
*
* @author pnoker
* @version 2025.9.0
* @version 2026.7.8
* @since 2016.10.1
*/
@Service
@RequiredArgsConstructor
public class ScheduleForDataServiceImpl implements ScheduleForDataService {
private final PointBatchProperties pointBatchProperties;
private final QuartzService quartzService;
/**
* Initialize data scheduling
* Initialize data scheduling.
*/
@Override
public void initial() {
try {
quartzService.createJobWithInterval(ScheduleConstant.DATA_SCHEDULE_GROUP, "data-point-value-schedule-job",
pointBatchProperties.getInterval(), DateBuilder.IntervalUnit.SECOND, PointValueJob.class);
// Custom scheduling
quartzService.createJobWithCron(ScheduleConstant.DATA_SCHEDULE_GROUP, "hourly-job", "0 0 0/1 * * ?",
HourlyJobForData.class);
quartzService.startScheduler();
} catch (SchedulerException e) {
throw new ServiceException("Failed to initialize data scheduler", e);
@@ -0,0 +1,192 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.data.buffer;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.entity.bo.PointValueBO;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Bounded in-memory buffer that decouples point-value consumption from repository persistence.
*
* <p>Replaces the legacy speed-threshold dual path (single-row save vs. unbounded list + Quartz
* tick). Every received point value enters a bounded {@link ArrayBlockingQueue}; worker threads
* drain it on a size-or-time trigger and call {@link PointValueService#save(List)}. When the
* queue is full {@link #offer} returns {@code false} so the receiver can nack-requeue and
* back-pressure RabbitMQ instead of OOM-ing. A failed save re-queues the batch for retry.
*
* @author pnoker
* @version 2026.7.8
* @since 2026.7.8
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PointValueIngestBuffer {
private final PointBatchProperties pointBatchProperties;
private final PointValueService pointValueService;
private ArrayBlockingQueue<PointValueBO> queue;
private ExecutorService worker;
private volatile boolean running;
private final AtomicLong droppedCount = new AtomicLong(0);
/**
* Start the worker pool and begin draining.
*/
@PostConstruct
void start() {
queue = new ArrayBlockingQueue<>(pointBatchProperties.getQueueCapacity());
int workers = pointBatchProperties.getWorkerCount();
worker = Executors.newFixedThreadPool(workers, r -> {
Thread thread = new Thread(r, "dc3-point-value-ingest");
thread.setDaemon(true);
return thread;
});
running = true;
for (int i = 0; i < workers; i++) {
worker.submit(this::drainLoop);
}
log.info("PointValueIngestBuffer started, queueCapacity={}, batchSize={}, flushIntervalMillis={}, workerCount={}",
pointBatchProperties.getQueueCapacity(), pointBatchProperties.getBatchSize(),
pointBatchProperties.getFlushIntervalMillis(), workers);
}
/**
* Stop workers and flush whatever remains in the queue.
*/
@PreDestroy
void stop() {
running = false;
if (Objects.nonNull(worker)) {
worker.shutdown();
try {
worker.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
worker.shutdownNow();
}
}
if (Objects.nonNull(queue)) {
List<PointValueBO> remaining = new ArrayList<>();
queue.drainTo(remaining);
if (!remaining.isEmpty()) {
log.warn("PointValueIngestBuffer flushing {} remaining records on shutdown", remaining.size());
try {
pointValueService.save(remaining);
} catch (Exception e) {
log.error("PointValueIngestBuffer failed to flush {} remaining records on shutdown",
remaining.size(), e);
}
}
}
}
/**
* Enqueue a point value without blocking.
*
* @param pointValueBO the value to buffer
* @return {@code true} if accepted, {@code false} if the queue is full (caller should nack-requeue)
*/
public boolean offer(PointValueBO pointValueBO) {
return queue.offer(pointValueBO);
}
/**
* @return number of point values currently buffered, awaiting the next flush
*/
public int pendingCount() {
return Objects.nonNull(queue) ? queue.size() : 0;
}
/**
* @return cumulative count of records dropped because the queue was full on re-queue
*/
public long droppedCount() {
return droppedCount.get();
}
/**
* Worker loop: block on the first record (up to the flush interval), then non-blockingly
* drain up to {@code batchSize-1} more, then persist. Triggers on either a full batch or
* the flush-interval timeout.
*/
private void drainLoop() {
int batchSize = pointBatchProperties.getBatchSize();
long flushMillis = pointBatchProperties.getFlushIntervalMillis();
while (running) {
try {
PointValueBO first = queue.poll(flushMillis, TimeUnit.MILLISECONDS);
if (Objects.isNull(first)) {
continue;
}
List<PointValueBO> batch = new ArrayList<>(batchSize);
batch.add(first);
queue.drainTo(batch, batchSize - 1);
saveWithRetry(batch);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.error("PointValueIngestBuffer drain loop error", e);
}
}
}
/**
* Persist a batch; on failure, re-queue it for retry so a transient DB outage does not
* lose data.
*/
private void saveWithRetry(List<PointValueBO> batch) {
try {
pointValueService.save(batch);
} catch (Exception e) {
log.error("Save point values batch failed, size={}, re-queuing for retry", batch.size(), e);
requeue(batch);
}
}
/**
* Re-queue a failed batch entry by entry. If the queue is full (sustained DB outage with
* continued inflow), drop the record and count it so it surfaces in monitoring.
*/
private void requeue(List<PointValueBO> batch) {
for (PointValueBO pointValueBO : batch) {
if (!queue.offer(pointValueBO)) {
long dropped = droppedCount.incrementAndGet();
log.error("PointValueIngestBuffer re-queue full, dropping record, deviceId={}, pointId={}, totalDropped={}",
pointValueBO.getDeviceId(), pointValueBO.getPointId(), dropped);
}
}
}
}
@@ -24,11 +24,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Point-value batch processing thresholds.
* Point-value ingest buffer tuning. Replaces the legacy speed/interval threshold pair: every
* received point value enters a bounded queue and is flushed to the repository by worker
* threads on a size-or-time trigger.
*
* @author pnoker
* @version 2026.5.10
* @since 2026.5.10
* @version 2026.7.8
* @since 2026.7.8
*/
@Getter
@Setter
@@ -36,10 +38,15 @@ import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "dc3.data.point.batch")
public class PointBatchProperties {
@Min(value = 1, message = "Point batch speed must be greater than 0")
private int speed = 100;
@Min(value = 1, message = "Point batch queue capacity must be greater than 0")
private int queueCapacity = 100000;
@Min(value = 1, message = "Point batch interval must be greater than 0")
private int interval = 5;
@Min(value = 1, message = "Point batch size must be greater than 0")
private int batchSize = 1000;
@Min(value = 1, message = "Point batch flush interval must be greater than 0")
private long flushIntervalMillis = 500;
@Min(value = 1, message = "Point batch worker count must be greater than 0")
private int workerCount = 4;
}
@@ -1,167 +0,0 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.data.job;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.entity.bo.PointValueBO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Quartz job for point value batch processing.
*
* @author pnoker
* @version 2025.9.0
* @since 2016.10.1
*/
@Slf4j
@Component
@RequiredArgsConstructor
@DisallowConcurrentExecution
public class PointValueJob extends QuartzJobBean {
private static final ReentrantReadWriteLock VALUE_LOCK = new ReentrantReadWriteLock();
private static final AtomicLong VALUE_COUNT = new AtomicLong(0);
private static final AtomicLong VALUE_SPEED = new AtomicLong(0);
private static final List<PointValueBO> POINT_VALUE_LIST = new ArrayList<>();
private final PointBatchProperties pointBatchProperties;
private final PointValueService pointValueService;
private final ExecutorService virtualThreadExecutor;
/**
* Return the number of point values currently buffered, awaiting the next batch flush.
*
* @return the buffered point value count
*/
public static int getPointValuesSize() {
VALUE_LOCK.readLock().lock();
try {
return POINT_VALUE_LIST.size();
} finally {
VALUE_LOCK.readLock().unlock();
}
}
/**
* Clear all buffered point values without flushing them to the repository.
*/
public static void clearPointValues() {
VALUE_LOCK.writeLock().lock();
try {
POINT_VALUE_LIST.clear();
} finally {
VALUE_LOCK.writeLock().unlock();
}
}
/**
* Append a single point value to the in-memory batch buffer.
*
* @param pointValueBO the point value to buffer
*/
public static void addPointValues(PointValueBO pointValueBO) {
VALUE_LOCK.writeLock().lock();
try {
POINT_VALUE_LIST.add(pointValueBO);
} finally {
VALUE_LOCK.writeLock().unlock();
}
}
/**
* Increment the received-point-value counter, sampled each batch tick to derive the
* receive speed.
*/
public static void recordPointValue() {
VALUE_COUNT.getAndIncrement();
}
/**
* Return the cumulative count of point values received since the last reset.
*
* @return the received point value count
*/
public static long getValueCount() {
return VALUE_COUNT.get();
}
/**
* Return the point value receive speed (values per second) computed at the last batch
* tick.
*
* @return the receive speed in values per second
*/
public static long getValueSpeed() {
return VALUE_SPEED.get();
}
/**
* Reset both the received-count and receive-speed counters to zero.
*/
public static void resetMetrics() {
VALUE_COUNT.set(0);
VALUE_SPEED.set(0);
}
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
// Statistical point value receive rate
long speed = VALUE_COUNT.getAndSet(0) / pointBatchProperties.getInterval();
VALUE_SPEED.set(speed);
if (speed >= pointBatchProperties.getSpeed()) {
log.debug("Point value receiver speed: {} /s, value size: {}, interval: {}", speed, getPointValuesSize(),
pointBatchProperties.getInterval());
}
// Swap out the accumulated buffer under the lock; run the save on a private
// snapshot outside the lock so concurrent addPointValues callers are not blocked
// by DB I/O.
List<PointValueBO> snapshot;
VALUE_LOCK.writeLock().lock();
try {
if (POINT_VALUE_LIST.isEmpty()) {
return;
}
snapshot = new ArrayList<>(POINT_VALUE_LIST);
POINT_VALUE_LIST.clear();
} finally {
VALUE_LOCK.writeLock().unlock();
}
virtualThreadExecutor.execute(() -> pointValueService.save(snapshot));
}
}
@@ -18,11 +18,8 @@
package io.github.pnoker.common.data.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.data.job.PointValueJob;
import io.github.pnoker.common.data.buffer.PointValueIngestBuffer;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.utils.JsonUtil;
import io.github.pnoker.common.utils.RabbitAckUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +33,12 @@ import java.util.Objects;
/**
* RabbitMQ receiver for point value ingestion events.
*
* <p>Every valid message is handed to {@link PointValueIngestBuffer}; ack when accepted,
* nack-requeue when the buffer is full so RabbitMQ back-pressures instead of the center
* OOM-ing. Uses the high-throughput container factory (wider prefetch / concurrency).
*
* @author pnoker
* @version 2025.9.0
* @version 2026.7.8
* @since 2016.10.1
*/
@Slf4j
@@ -45,21 +46,19 @@ import java.util.Objects;
@RequiredArgsConstructor
public class PointValueReceiver {
private final PointBatchProperties pointBatchProperties;
private final PointValueService pointValueService;
private final PointValueIngestBuffer pointValueIngestBuffer;
/**
* Consume a point value message: route it to the batch buffer when the receive speed
* exceeds the threshold, otherwise save it directly. Manual ack on success, requeue
* on failure.
* Consume a point value message: validate, offer to the ingest buffer, ack on success or
* nack-requeue when the buffer is full (back-pressure). Invalid messages are rejected.
*
* @param channel the RabbitMQ channel for manual ack
* @param message the raw message carrying the delivery tag
* @param pointValueBO the deserialized point value
*/
@RabbitHandler
@RabbitListener(queues = "#{pointValueQueue.name}")
@RabbitListener(queues = "#{pointValueQueue.name}",
containerFactory = "highThroughputRabbitListenerContainerFactory")
public void pointValueReceive(Channel channel, Message message, PointValueBO pointValueBO) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
@@ -69,27 +68,19 @@ public class PointValueReceiver {
RabbitAckUtil.reject(channel, deliveryTag);
return;
}
PointValueJob.recordPointValue();
log.debug("Receive point value from: {}, {}", message.getMessageProperties().getReceivedRoutingKey(),
JsonUtil.toJsonString(pointValueBO));
// Judge whether to process data in batch according to the data transmission
// speed
if (PointValueJob.getValueSpeed() < pointBatchProperties.getSpeed()) {
// Save point value to local latest-value cache and repository storage
pointValueService.save(pointValueBO);
if (pointValueIngestBuffer.offer(pointValueBO)) {
RabbitAckUtil.ack(channel, deliveryTag);
} else {
// Save point value to schedule
PointValueJob.addPointValues(pointValueBO);
log.warn("Point value ingest buffer full, nack-requeue to back-pressure, deviceId={}, pointId={}",
pointValueBO.getDeviceId(), pointValueBO.getPointId());
RabbitAckUtil.nack(channel, deliveryTag, true);
}
RabbitAckUtil.ack(channel, deliveryTag);
} catch (Exception e) {
log.error("Point value consume failed, deviceId={}, pointId={}, deliveryTag={}, routingKey={}",
log.error("Point value consume failed, deviceId={}, pointId={}, deliveryTag={}",
Objects.nonNull(pointValueBO) ? pointValueBO.getDeviceId() : null,
Objects.nonNull(pointValueBO) ? pointValueBO.getPointId() : null,
deliveryTag, message.getMessageProperties().getReceivedRoutingKey(), e);
deliveryTag, e);
RabbitAckUtil.nack(channel, deliveryTag, true);
}
}
}
@@ -18,9 +18,7 @@
package io.github.pnoker.common.data.biz.impl;
import io.github.pnoker.common.constant.driver.ScheduleConstant;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.data.job.HourlyJobForData;
import io.github.pnoker.common.data.job.PointValueJob;
import io.github.pnoker.common.exception.ServiceException;
import io.github.pnoker.common.quartz.QuartzService;
import org.junit.jupiter.api.BeforeEach;
@@ -28,7 +26,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.quartz.DateBuilder;
import org.quartz.SchedulerException;
import static org.mockito.ArgumentMatchers.any;
@@ -37,32 +34,31 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* Point-value ingestion no longer has a Quartz tick (it is driven by PointValueIngestBuffer),
* so only the hourly cron job registration is asserted here.
*
* @author pnoker
* @version 2026.7.8
* @since 2026.7.8
*/
@ExtendWith(MockitoExtension.class)
class ScheduleForDataServiceImplTest {
@Mock
private QuartzService quartzService;
private PointBatchProperties properties;
private ScheduleForDataServiceImpl service;
@BeforeEach
void setUp() {
properties = new PointBatchProperties();
properties.setInterval(5);
service = new ScheduleForDataServiceImpl(properties, quartzService);
service = new ScheduleForDataServiceImpl(quartzService);
}
@Test
void initialRegistersIntervalAndCronJobsAndStartsScheduler() throws Exception {
void initialRegistersHourlyJobAndStartsScheduler() throws Exception {
service.initial();
verify(quartzService).createJobWithInterval(
eq(ScheduleConstant.DATA_SCHEDULE_GROUP),
eq("data-point-value-schedule-job"),
eq(5),
eq(DateBuilder.IntervalUnit.SECOND),
eq(PointValueJob.class));
verify(quartzService).createJobWithCron(
eq(ScheduleConstant.DATA_SCHEDULE_GROUP),
eq("hourly-job"),
@@ -74,7 +70,7 @@ class ScheduleForDataServiceImplTest {
@Test
void initialThrowsServiceExceptionOnSchedulerFailure() throws Exception {
doThrow(new SchedulerException("scheduler down")).when(quartzService)
.createJobWithInterval(any(), any(), any(int.class), any(), any());
.createJobWithCron(any(), any(), any(), any());
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.initial())
.isInstanceOf(ServiceException.class)
.hasMessageContaining("Failed to initialize data scheduler")
@@ -0,0 +1,130 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.data.buffer;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.entity.bo.PointValueBO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
/**
* Verifies the ingest buffer offer/flush/re-queue/back-pressure lifecycle with a mocked
* {@link PointValueService}.
*
* @author pnoker
* @version 2026.7.8
* @since 2026.7.8
*/
@ExtendWith(MockitoExtension.class)
class PointValueIngestBufferTest {
@Mock
private PointValueService pointValueService;
private PointBatchProperties properties;
private PointValueIngestBuffer buffer;
@BeforeEach
void setUp() {
properties = new PointBatchProperties();
properties.setQueueCapacity(1000);
properties.setBatchSize(2);
properties.setFlushIntervalMillis(200);
properties.setWorkerCount(1);
buffer = new PointValueIngestBuffer(properties, pointValueService);
buffer.start();
}
@AfterEach
void tearDown() {
buffer.stop();
}
@Test
void flushesBatchedValues() {
buffer.offer(bo(1));
buffer.offer(bo(2));
verify(pointValueService, timeout(1000)).save(anyList());
}
@Test
void flushesSingleRecordPromptly() {
// Below batchSize — the first record still triggers a flush via the poll-then-drain loop.
buffer.offer(bo(1));
verify(pointValueService, timeout(1000)).save(anyList());
}
@Test
void requeuesBatchOnSaveFailure() {
doThrow(new RuntimeException("db down")).when(pointValueService).save(anyList());
buffer.offer(bo(1));
// Failed save re-queues the batch, which is then re-drained and re-saved.
verify(pointValueService, timeout(1000).atLeast(2)).save(anyList());
}
@Test
void offerReturnsFalseWhenQueueFull() throws Exception {
PointBatchProperties small = new PointBatchProperties();
small.setQueueCapacity(2);
small.setBatchSize(100);
small.setFlushIntervalMillis(10_000);
small.setWorkerCount(1);
PointValueIngestBuffer full = new PointValueIngestBuffer(small, pointValueService);
full.start();
try {
CountDownLatch firstTaken = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
doAnswer(inv -> {
firstTaken.countDown();
release.await();
return null;
}).when(pointValueService).save(anyList());
assertThat(full.offer(bo(1))).isTrue();
// Wait until the worker has taken the first record and is blocked inside save(),
// so the queue is empty and we can deterministically fill it to capacity.
assertThat(firstTaken.await(2, TimeUnit.SECONDS)).isTrue();
assertThat(full.offer(bo(2))).isTrue();
assertThat(full.offer(bo(3))).isTrue();
// Queue capacity is 2 — the 4th offer must be rejected (back-pressure signal).
assertThat(full.offer(bo(4))).isFalse();
release.countDown();
} finally {
full.stop();
}
}
private PointValueBO bo(int i) {
return PointValueBO.builder().deviceId((long) i).pointId((long) i).rawValue("v" + i).build();
}
}
@@ -0,0 +1,41 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.data.job;
import org.junit.jupiter.api.Test;
import org.quartz.JobExecutionContext;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.Mockito.mock;
/**
* Migrated from the deleted PointValueJobTest — the hourly maintenance job has nothing to do
* with the ingest buffer and gets its own focused test.
*
* @author pnoker
* @version 2026.7.8
* @since 2026.7.8
*/
class HourlyJobForDataTest {
@Test
void executesWithoutThrowing() {
HourlyJobForData hourly = new HourlyJobForData();
assertThatNoException().isThrownBy(() -> hourly.executeInternal(mock(JobExecutionContext.class)));
}
}
@@ -1,121 +0,0 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.data.job;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.entity.bo.PointValueBO;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.quartz.JobExecutionContext;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class PointValueJobTest {
@Mock
private PointValueService pointValueService;
@Mock
private JobExecutionContext jobExecutionContext;
private PointValueJob job;
private ExecutorService executor;
private PointBatchProperties properties;
@BeforeEach
void setUp() {
properties = new PointBatchProperties();
properties.setSpeed(100);
properties.setInterval(5);
executor = Executors.newSingleThreadExecutor();
job = new PointValueJob(properties, pointValueService, executor);
PointValueJob.resetMetrics();
PointValueJob.clearPointValues();
}
@AfterEach
void tearDown() {
executor.shutdownNow();
PointValueJob.resetMetrics();
PointValueJob.clearPointValues();
}
@Test
void executeRotatesValueCountIntoSpeed() throws Exception {
for (int i = 0; i < 50; i++) {
PointValueJob.recordPointValue();
}
job.executeInternal(jobExecutionContext);
assertThat(PointValueJob.getValueSpeed()).isEqualTo(10);
assertThat(PointValueJob.getValueCount()).isEqualTo(0);
}
@Test
void executeWithEmptyBufferDoesNotCallSave() throws Exception {
job.executeInternal(jobExecutionContext);
// Yield once so any spurious async submission would land before the assertion.
verify(pointValueService, never().description("save must not run for an empty buffer")).save(any(List.class));
}
@Test
void executeFlushesBufferedValuesAsynchronously() throws Exception {
PointValueJob.addPointValues(PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build());
PointValueJob.addPointValues(PointValueBO.builder().deviceId(10L).pointId(21L).rawValue("v").build());
job.executeInternal(jobExecutionContext);
// Saved on the executor — wait for the async submission to land.
verify(pointValueService, timeout(Duration.ofSeconds(2).toMillis())).save(any(List.class));
// Buffer is drained even before the async save completes.
Awaitility.await().atMost(Duration.ofSeconds(2)).pollInterval(Duration.ofMillis(50))
.untilAsserted(() -> assertThat(PointValueJob.getPointValuesSize()).isZero());
}
@Test
void addPointValuesAppendsAndClearResetsBuffer() {
PointValueJob.addPointValues(PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build());
assertThat(PointValueJob.getPointValuesSize()).isEqualTo(1);
PointValueJob.clearPointValues();
assertThat(PointValueJob.getPointValuesSize()).isZero();
}
@Test
void hourlyJobForDataExecutesWithoutThrowing() {
HourlyJobForData hourly = new HourlyJobForData();
assertThatNoException().isThrownBy(() -> hourly.executeInternal(jobExecutionContext));
}
}
@@ -18,11 +18,8 @@
package io.github.pnoker.common.data.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import io.github.pnoker.common.data.job.PointValueJob;
import io.github.pnoker.common.data.buffer.PointValueIngestBuffer;
import io.github.pnoker.common.entity.bo.PointValueBO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,91 +28,70 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Verifies the receiver routes messages to the ingest buffer and applies back-pressure
* (nack-requeue) when the buffer is full.
*
* @author pnoker
* @version 2026.7.8
* @since 2026.7.8
*/
@ExtendWith(MockitoExtension.class)
class PointValueReceiverTest {
@Mock
private PointValueService pointValueService;
private PointValueIngestBuffer buffer;
@Mock
private Channel channel;
private PointValueReceiver receiver;
private Message message;
private PointBatchProperties properties;
@BeforeEach
void setUp() {
properties = new PointBatchProperties();
properties.setSpeed(100);
properties.setInterval(5);
receiver = new PointValueReceiver(properties, pointValueService);
receiver = new PointValueReceiver(buffer);
MessageProperties props = new MessageProperties();
props.setDeliveryTag(7L);
message = new Message(new byte[0], props);
// Reset shared state used by the rate-throttling branch
PointValueJob.resetMetrics();
PointValueJob.clearPointValues();
}
@AfterEach
void resetSharedState() {
PointValueJob.resetMetrics();
PointValueJob.clearPointValues();
}
@Test
void rejectsNullPayload() throws Exception {
receiver.pointValueReceive(channel, message, null);
verifyNoInteractions(pointValueService);
verifyNoInteractions(buffer);
verify(channel).basicReject(eq(7L), eq(false));
verify(channel, never()).basicAck(eq(7L), eq(false));
}
@Test
void rejectsPayloadWithoutDeviceId() throws Exception {
PointValueBO bo = PointValueBO.builder().pointId(20L).build();
receiver.pointValueReceive(channel, message, bo);
verifyNoInteractions(pointValueService);
verifyNoInteractions(buffer);
verify(channel).basicReject(eq(7L), eq(false));
}
@Test
void belowSpeedThresholdSavesImmediatelyAndAcks() throws Exception {
void offersAndAcks() throws Exception {
PointValueBO bo = PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build();
// VALUE_SPEED defaults to 0 in setUp, below the 100 threshold
when(buffer.offer(bo)).thenReturn(true);
receiver.pointValueReceive(channel, message, bo);
verify(pointValueService).save(bo);
verify(buffer).offer(bo);
verify(channel).basicAck(eq(7L), eq(false));
// Counter is incremented per message — pinned so the job-side rate calculation
// stays accurate.
assertThat(PointValueJob.getValueCount()).isEqualTo(1);
}
@Test
void aboveSpeedThresholdBuffersToScheduleAndAcks() throws Exception {
properties.setSpeed(0);
void nacksAndRequeuesWhenBufferFull() throws Exception {
PointValueBO bo = PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build();
when(buffer.offer(bo)).thenReturn(false);
receiver.pointValueReceive(channel, message, bo);
verify(pointValueService, never()).save(any(PointValueBO.class));
verify(channel).basicAck(eq(7L), eq(false));
assertThat(PointValueJob.getPointValuesSize()).isEqualTo(1);
}
@Test
void nacksAndRequeuesOnServiceFailure() throws Exception {
PointValueBO bo = PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build();
org.mockito.Mockito.doThrow(new RuntimeException("downstream offline"))
.when(pointValueService).save(any(PointValueBO.class));
receiver.pointValueReceive(channel, message, bo);
verify(buffer).offer(bo);
verify(channel).basicNack(eq(7L), eq(false), eq(true));
verify(channel, never()).basicAck(eq(7L), eq(false));
}
+10
View File
@@ -77,6 +77,16 @@
<artifactId>caffeine</artifactId>
</dependency>
<!-- Embedded database for the local point-value buffer (resume on broker outage) -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<!-- DC3 API Related -->
<dependency>
<groupId>io.github.pnoker</groupId>
@@ -0,0 +1,66 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import io.github.pnoker.common.driver.entity.bean.PointValue;
/**
* Local SQLite-backed buffer for point values that could not be delivered to RabbitMQ.
*
* <p>Failed/NACKed readings are persisted and republished by a Quartz job once the
* broker recovers, so a RabbitMQ outage no longer loses collected data.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
public interface BufferService {
/**
* Initialize the SQLite database: create parent directories, open the connection
* pool, and create the buffer table. Idempotent; a no-op when the buffer is disabled.
*/
void initialize();
/**
* Persist a point value that failed to publish, keyed by the publisher-confirm
* correlation id so a later NACK republish overwrites the same row (INSERT OR REPLACE).
*
* @param pointValue the failed point value
* @param routingKey RabbitMQ routing key to republish with
* @param correlationId publisher-confirm correlation id, used as the buffer row primary key
* @param attempt ordinal of the send attempt that just failed
*/
void offer(PointValue pointValue, String routingKey, String correlationId, int attempt);
/**
* Republish up to {@code batchSize} due buffered point values, deleting the ones that
* leave the channel cleanly and back-offing the ones that throw.
*/
void republishBatch();
/**
* @return whether the buffer is enabled in configuration
*/
boolean isEnabled();
/**
* @return current number of records awaiting republish (0 when disabled)
*/
long pendingCount();
}
@@ -0,0 +1,195 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import io.github.pnoker.common.constant.driver.RabbitConstant;
import io.github.pnoker.common.driver.entity.bean.PointValue;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.utils.JsonUtil;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/**
* SQLite-backed {@link BufferService}. Persists point values that failed to reach RabbitMQ
* (synchronous {@link AmqpException} or asynchronous publisher NACK) and republishes them
* from a Quartz job with exponential backoff. When the buffer file exceeds the configured
* size cap the oldest records are evicted to keep the newest readings.
*
* <p>Republish is optimistic: a record that leaves the channel without throwing is deleted,
* and a later NACK re-queues it through the confirm callback using the same correlation id.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BufferServiceImpl implements BufferService {
private final DriverProperties driverProperties;
private final RabbitTemplate rabbitTemplate;
private PointValueBuffer buffer;
@Override
public void initialize() {
DriverProperties.BufferProperties config = driverProperties.getBuffer();
if (Objects.isNull(config) || !Boolean.TRUE.equals(config.getEnabled())) {
log.info("Point value buffer disabled, skip initialization");
return;
}
this.buffer = new PointValueBuffer(config.getDbPath());
this.buffer.initialize();
}
@Override
public boolean isEnabled() {
DriverProperties.BufferProperties config = driverProperties.getBuffer();
return Objects.nonNull(config) && Boolean.TRUE.equals(config.getEnabled()) && Objects.nonNull(buffer);
}
@Override
public long pendingCount() {
return Objects.nonNull(buffer) ? buffer.count() : 0;
}
@Override
public void offer(PointValue pointValue, String routingKey, String correlationId, int attempt) {
if (!isEnabled()) {
return;
}
DriverProperties.BufferProperties config = driverProperties.getBuffer();
long now = epochSecond();
BufferedPointValue record = new BufferedPointValue(
correlationId,
pointValue.getDeviceId(),
pointValue.getPointId(),
pointValue.getDriverId(),
pointValue.getTenantId(),
JsonUtil.toJsonString(pointValue),
routingKey,
attempt,
now + backoffSeconds(attempt, config),
now
);
buffer.upsert(record);
if (log.isDebugEnabled()) {
log.debug("Buffered point value, id={}, deviceId={}, pointId={}, attempt={}, queueSize={}",
correlationId, pointValue.getDeviceId(), pointValue.getPointId(), attempt, buffer.count());
}
enforceCapacity(config);
}
@Override
public void republishBatch() {
if (!isEnabled()) {
return;
}
DriverProperties.BufferProperties config = driverProperties.getBuffer();
List<BufferedPointValue> records = buffer.selectPending(config.getBatchSize(), epochSecond());
if (records.isEmpty()) {
return;
}
log.debug("Republishing {} buffered point values", records.size());
for (BufferedPointValue record : records) {
republishOne(record, config);
}
enforceCapacity(config);
}
/**
* Republish a single buffered record. Records that have exhausted {@code maxRetry} are
* dropped as poison with an ERROR log; the rest are re-sent with an incremented attempt
* counter carried in the correlation so a NACK re-queue stores the right ordinal.
*/
private void republishOne(BufferedPointValue record, DriverProperties.BufferProperties config) {
if (record.attempt() >= config.getMaxRetry()) {
log.error("Buffer record exceeded max retry ({}), dropping poison, id={}, deviceId={}, pointId={}",
config.getMaxRetry(), record.id(), record.deviceId(), record.pointId());
buffer.delete(record.id());
return;
}
PointValue pointValue;
try {
pointValue = JsonUtil.parseObject(record.payloadJson(), PointValue.class);
} catch (Exception e) {
log.error("Buffer record payload corrupted, dropping, id={}, deviceId={}, pointId={}",
record.id(), record.deviceId(), record.pointId(), e);
buffer.delete(record.id());
return;
}
int nextAttempt = record.attempt() + 1;
PointValueCorrelation correlation = new PointValueCorrelation(
record.id(), record.deviceId(), record.pointId(), nextAttempt,
record.payloadJson(), record.routingKey());
try {
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_VALUE, record.routingKey(), pointValue, correlation);
// Optimistic delete: the message left the channel. A later NACK re-queues it
// via the ConfirmCallback using the same correlation id.
buffer.delete(record.id());
} catch (AmqpException e) {
long backoff = backoffSeconds(nextAttempt, config);
log.warn("Buffer republish rejected, id={}, attempt={}, retrying in {}s",
record.id(), nextAttempt, backoff);
buffer.markRetry(record.id(), nextAttempt, epochSecond() + backoff);
}
}
/**
* When the SQLite file exceeds the configured size cap, evict the oldest batch. SQLite
* reuses freed pages, so the file does not shrink without a VACUUM — that is acceptable
* for a bounded buffer that cycles through records.
*/
private void enforceCapacity(DriverProperties.BufferProperties config) {
long maxBytes = config.getMaxSizeMb() * 1024L * 1024L;
if (buffer.fileSize() <= maxBytes) {
return;
}
int evicted = buffer.deleteOldest(config.getBatchSize());
log.warn("Buffer capacity exceeded ({}B > {}B), evicted {} oldest records",
buffer.fileSize(), maxBytes, evicted);
}
/**
* Exponential backoff in seconds: {@code backoffSeconds * 2^(attempt-1)}, capped at
* {@code maxBackoffSeconds}.
*/
private long backoffSeconds(int attempt, DriverProperties.BufferProperties config) {
long delay = (long) (config.getBackoffSeconds() * Math.pow(2, attempt - 1));
return Math.min(delay, config.getMaxBackoffSeconds());
}
private static long epochSecond() {
return System.currentTimeMillis() / 1000;
}
@PreDestroy
void destroy() {
if (Objects.nonNull(buffer)) {
buffer.close();
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
/**
* DAO-level row mapping for a buffered point value awaiting republish.
*
* <p>Times are epoch seconds so the SQLite store stays free of date-format/timezone
* pitfalls.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
public record BufferedPointValue(
String id,
Long deviceId,
Long pointId,
Long driverId,
Long tenantId,
String payloadJson,
String routingKey,
int attempt,
long nextAttemptAt,
long createdAt
) {
}
@@ -0,0 +1,270 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import io.github.pnoker.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* SQLite-backed DAO for the local point-value buffer. Owns a single-connection HikariCP
* pool (SQLite is a single-writer database) in WAL mode and exposes the small CRUD
* surface the buffer service needs.
*
* <p>All times are epoch seconds; the schema deliberately avoids date types so the store
* is free of timezone/format pitfalls.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
@Slf4j
public class PointValueBuffer {
private static final String CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS point_value_buffer (
id TEXT PRIMARY KEY,
device_id INTEGER NOT NULL,
point_id INTEGER NOT NULL,
driver_id INTEGER,
tenant_id INTEGER,
payload_json TEXT NOT NULL,
routing_key TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
""";
private static final String CREATE_INDEX_NEXT_SQL =
"CREATE INDEX IF NOT EXISTS idx_buffer_next_attempt ON point_value_buffer(next_attempt_at)";
private static final String CREATE_INDEX_CREATED_SQL =
"CREATE INDEX IF NOT EXISTS idx_buffer_created ON point_value_buffer(created_at)";
private static final String UPSERT_SQL = """
INSERT OR REPLACE INTO point_value_buffer
(id, device_id, point_id, driver_id, tenant_id, payload_json, routing_key, attempt, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
private static final String SELECT_PENDING_SQL = """
SELECT id, device_id, point_id, driver_id, tenant_id, payload_json, routing_key, attempt, next_attempt_at, created_at
FROM point_value_buffer
WHERE next_attempt_at <= ?
ORDER BY next_attempt_at ASC
LIMIT ?
""";
private static final String DELETE_SQL = "DELETE FROM point_value_buffer WHERE id = ?";
private static final String MARK_RETRY_SQL =
"UPDATE point_value_buffer SET attempt = ?, next_attempt_at = ? WHERE id = ?";
private static final String DELETE_OLDEST_SQL = """
DELETE FROM point_value_buffer WHERE id IN (
SELECT id FROM point_value_buffer ORDER BY created_at ASC LIMIT ?
)
""";
private static final String COUNT_SQL = "SELECT COUNT(*) FROM point_value_buffer";
private final String dbPath;
private HikariDataSource dataSource;
public PointValueBuffer(String dbPath) {
this.dbPath = dbPath;
}
/**
* Open the connection pool, apply WAL, and create the buffer table. Idempotent.
*/
public void initialize() {
File parent = new File(dbPath).getParentFile();
if (Objects.nonNull(parent) && !parent.exists() && !parent.mkdirs()) {
throw new ServiceException("Failed to create buffer db parent directory: " + parent.getAbsolutePath());
}
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:sqlite:" + dbPath);
config.setDriverClassName("org.sqlite.JDBC");
config.setMaximumPoolSize(1);
config.setMinimumIdle(1);
config.setConnectionInitSql("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
config.setPoolName("dc3-driver-buffer");
this.dataSource = new HikariDataSource(config);
createTableIfNotExists();
log.info("Point value buffer initialized, dbPath={}", dbPath);
}
private void createTableIfNotExists() {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(CREATE_TABLE_SQL);
stmt.execute(CREATE_INDEX_NEXT_SQL);
stmt.execute(CREATE_INDEX_CREATED_SQL);
} catch (SQLException e) {
throw new ServiceException("Failed to init point-value buffer table", e);
}
}
/**
* Insert or replace a buffered record keyed by the correlation id.
*/
public void upsert(BufferedPointValue record) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(UPSERT_SQL)) {
ps.setString(1, record.id());
setLong(ps, 2, record.deviceId());
setLong(ps, 3, record.pointId());
setLong(ps, 4, record.driverId());
setLong(ps, 5, record.tenantId());
ps.setString(6, record.payloadJson());
ps.setString(7, record.routingKey());
ps.setInt(8, record.attempt());
ps.setLong(9, record.nextAttemptAt());
ps.setLong(10, record.createdAt());
ps.executeUpdate();
} catch (SQLException e) {
log.error("Buffer upsert failed, id={}, attempt={}", record.id(), record.attempt(), e);
}
}
/**
* Return up to {@code batchSize} records due for republish (next_attempt_at &lt;= now),
* oldest-first.
*/
public List<BufferedPointValue> selectPending(int batchSize, long nowEpochSec) {
List<BufferedPointValue> records = new ArrayList<>(batchSize);
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(SELECT_PENDING_SQL)) {
ps.setLong(1, nowEpochSec);
ps.setInt(2, batchSize);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
records.add(new BufferedPointValue(
rs.getString("id"),
rs.getLong("device_id"),
rs.getLong("point_id"),
getNullableLong(rs, "driver_id"),
getNullableLong(rs, "tenant_id"),
rs.getString("payload_json"),
rs.getString("routing_key"),
rs.getInt("attempt"),
rs.getLong("next_attempt_at"),
rs.getLong("created_at")
));
}
}
} catch (SQLException e) {
log.error("Buffer selectPending failed", e);
}
return records;
}
/**
* Delete a record after it has left the channel cleanly.
*/
public void delete(String id) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(DELETE_SQL)) {
ps.setString(1, id);
ps.executeUpdate();
} catch (SQLException e) {
log.error("Buffer delete failed, id={}", id, e);
}
}
/**
* Bump the attempt counter and push back the next retry time after a failed republish.
*/
public void markRetry(String id, int attempt, long nextAttemptAt) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(MARK_RETRY_SQL)) {
ps.setInt(1, attempt);
ps.setLong(2, nextAttemptAt);
ps.setString(3, id);
ps.executeUpdate();
} catch (SQLException e) {
log.error("Buffer markRetry failed, id={}, attempt={}", id, attempt, e);
}
}
/**
* Delete the {@code evictBatch} oldest records (by created_at) for capacity enforcement.
*
* @return number of records deleted
*/
public int deleteOldest(int evictBatch) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(DELETE_OLDEST_SQL)) {
ps.setInt(1, evictBatch);
return ps.executeUpdate();
} catch (SQLException e) {
log.error("Buffer deleteOldest failed", e);
return 0;
}
}
/**
* @return current number of buffered records
*/
public long count() {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(COUNT_SQL)) {
return rs.next() ? rs.getLong(1) : 0;
} catch (SQLException e) {
log.error("Buffer count failed", e);
return 0;
}
}
/**
* @return on-disk size of the SQLite database file in bytes
*/
public long fileSize() {
File file = new File(dbPath);
return file.exists() ? file.length() : 0;
}
/**
* Close the connection pool.
*/
public void close() {
if (Objects.nonNull(dataSource) && !dataSource.isClosed()) {
dataSource.close();
}
}
private static void setLong(PreparedStatement ps, int index, Long value) throws SQLException {
if (Objects.isNull(value)) {
ps.setNull(index, Types.INTEGER);
} else {
ps.setLong(index, value);
}
}
private static Long getNullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import lombok.Getter;
import org.springframework.amqp.rabbit.connection.CorrelationData;
/**
* Carries the buffer record identity and serialized payload through the publisher-confirm
* callback so a NACK can re-queue the point value without re-reading the message body.
*
* <p>{@code attempt} is the ordinal of the send attempt that just failed, so a NACK
* republish stores the same counter the synchronous catch path would.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
@Getter
public class PointValueCorrelation extends CorrelationData {
private final Long deviceId;
private final Long pointId;
private final int attempt;
private final String payloadJson;
private final String routingKey;
public PointValueCorrelation(String id, Long deviceId, Long pointId, int attempt,
String payloadJson, String routingKey) {
super(id);
this.deviceId = deviceId;
this.pointId = pointId;
this.attempt = attempt;
this.payloadJson = payloadJson;
this.routingKey = routingKey;
}
}
@@ -94,6 +94,12 @@ public class DriverProperties {
@Valid
private HealthProperties health = new HealthProperties();
/**
* Local buffer configuration for point-value resume on broker outage.
*/
@Valid
private BufferProperties buffer = new BufferProperties();
/**
* Metadata cache tuning for the driver runtime.
*/
@@ -283,4 +289,65 @@ public class DriverProperties {
}
/**
* Local point-value buffer options. Failed/NACKed point values are persisted to an
* embedded SQLite database and republished by a Quartz job once the broker recovers,
* so a RabbitMQ outage no longer loses collected readings.
*/
@Getter
@Setter
public static class BufferProperties {
/**
* Whether to persist failed/NACKed point values locally for later republish.
* On by default so drivers resume broker outages out of the box.
*/
private Boolean enabled = true;
/**
* SQLite database path, relative to the driver working directory. Mirrors the
* {@code dc3/logs} layout so each driver writes its own buffer file.
*/
@NotBlank(message = "Buffer db path can't be empty")
private String dbPath = "dc3/data/driver/buffer.db";
/**
* Upper bound on the buffer database size in megabytes. When exceeded the oldest
* records are evicted to keep the newest readings (capacity over completeness).
*/
@Min(1)
private long maxSizeMb = 256;
/**
* Number of buffered point values republished per Quartz tick.
*/
@Min(1)
private int batchSize = 200;
/**
* Quartz cron expression for the buffer republish job.
*/
@NotBlank(message = "Buffer republish cron can't be empty")
private String republishCron = "0/10 * * * * ?";
/**
* Maximum republish attempts before a buffered record is dropped as poison.
*/
@Min(1)
private int maxRetry = 50;
/**
* Initial backoff before the first republish retry, in seconds.
*/
@Min(1)
private long backoffSeconds = 10;
/**
* Upper bound the doubling backoff is capped at, in seconds.
*/
@Min(1)
private long maxBackoffSeconds = 600;
}
}
@@ -17,6 +17,7 @@
package io.github.pnoker.common.driver.init;
import io.github.pnoker.common.driver.buffer.BufferService;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.service.DriverCustomService;
import io.github.pnoker.common.driver.service.DriverRegisterService;
@@ -64,6 +65,9 @@ public class DriverInitRunner implements ApplicationRunner {
private final DriverScheduleService driverScheduleService;
/** Local point-value buffer, initialized before registration so readings survive a manager outage. */
private final BufferService bufferService;
/**
* Runs the driver bootstrap sequence on startup: register with the manager center
* (with retry), execute custom initialization, then initialize scheduled tasks.
@@ -73,6 +77,10 @@ public class DriverInitRunner implements ApplicationRunner {
*/
@Override
public void run(ApplicationArguments args) throws Exception {
// Initialize the local point-value buffer before registration so collected
// readings can be persisted even if the manager center is unreachable.
bufferService.initialize();
// Initialize driver registration and synchronize basic information with the
// platform; tolerate manager center being temporarily unavailable.
registerWithRetry();
@@ -0,0 +1,52 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.job;
import io.github.pnoker.common.driver.buffer.BufferService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;
/**
* Quartz job that drains the local point-value buffer back to RabbitMQ. Failures are
* logged (not rethrown) so one bad batch does not abort the scheduler.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
@Slf4j
@Component
@RequiredArgsConstructor
@DisallowConcurrentExecution
public class BufferRepublishScheduleJob extends QuartzJobBean {
private final BufferService bufferService;
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) {
try {
bufferService.republishBatch();
} catch (Exception e) {
log.error("Buffer republish job failed", e);
}
}
}
@@ -19,6 +19,7 @@ package io.github.pnoker.common.driver.service.impl;
import io.github.pnoker.common.constant.driver.ScheduleConstant;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.job.BufferRepublishScheduleJob;
import io.github.pnoker.common.driver.job.DeviceHealthScheduleJob;
import io.github.pnoker.common.driver.job.DriverCustomScheduleJob;
import io.github.pnoker.common.driver.job.DriverHealthScheduleJob;
@@ -103,6 +104,17 @@ public class DriverScheduleServiceImpl implements DriverScheduleService {
DriverCustomScheduleJob.class);
}
// Create and schedule the buffer republish job if enabled
DriverProperties.BufferProperties buffer = driverProperties.getBuffer();
if (Objects.nonNull(buffer) && Boolean.TRUE.equals(buffer.getEnabled())) {
if (!CronExpression.isValidExpression(buffer.getRepublishCron())) {
throw new CronException("Buffer republish schedule cron expression is invalid");
}
quartzService.createJobWithCron(ScheduleConstant.DRIVER_SCHEDULE_GROUP,
ScheduleConstant.BUFFER_REPUBLISH_SCHEDULE_JOB, buffer.getRepublishCron(),
BufferRepublishScheduleJob.class);
}
// Start the scheduler after all jobs are configured
quartzService.startScheduler();
} catch (SchedulerException e) {
@@ -18,6 +18,8 @@
package io.github.pnoker.common.driver.service.impl;
import io.github.pnoker.common.constant.driver.RabbitConstant;
import io.github.pnoker.common.driver.buffer.BufferService;
import io.github.pnoker.common.driver.buffer.PointValueCorrelation;
import io.github.pnoker.common.driver.entity.bean.PointValue;
import io.github.pnoker.common.driver.entity.bo.DriverBO;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
@@ -72,12 +74,30 @@ public class DriverSenderServiceImpl implements DriverSenderService {
*/
private final RabbitTemplate rabbitTemplate;
/**
* Local buffer for point values that fail to reach RabbitMQ, republished once the broker recovers.
*/
private final BufferService bufferService;
@PostConstruct
void init() {
rabbitTemplate.setConfirmCallback((correlation, ack, reason) -> {
if (!ack && correlation instanceof PointValueCorrelation ctx) {
log.warn("Point value publish NACKed: deviceId={}, pointId={}, reason={}",
ctx.deviceId, ctx.pointId, reason);
if (ack) {
return;
}
if (correlation instanceof PointValueCorrelation ctx) {
log.warn("Point value publish NACKed, buffering for retry: deviceId={}, pointId={}, attempt={}, reason={}",
ctx.getDeviceId(), ctx.getPointId(), ctx.getAttempt(), reason);
try {
PointValue pointValue = JsonUtil.parseObject(ctx.getPayloadJson(), PointValue.class);
bufferService.offer(pointValue, ctx.getRoutingKey(), ctx.getId(), ctx.getAttempt());
} catch (Exception e) {
log.error("Failed to re-queue NACKed point value, payload corrupted, correlationId={}",
ctx.getId(), e);
}
} else {
log.error("RabbitMQ publisher confirm NACK, correlationId={}, cause={}",
Objects.nonNull(correlation) ? correlation.getId() : null, reason);
}
});
}
@@ -210,13 +230,22 @@ public class DriverSenderServiceImpl implements DriverSenderService {
}
String routingKey = RabbitConstant.ROUTING_POINT_VALUE_PREFIX + driverProperties.getService();
CorrelationData correlationData = new PointValueCorrelation(
UUID.randomUUID().toString(), entityDTO.getDeviceId(), entityDTO.getPointId());
boolean buffering = bufferService.isEnabled();
CorrelationData correlationData = buffering
? new PointValueCorrelation(UUID.randomUUID().toString(), entityDTO.getDeviceId(),
entityDTO.getPointId(), 1, JsonUtil.toJsonString(entityDTO), routingKey)
: new CorrelationData(UUID.randomUUID().toString());
try {
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_VALUE, routingKey, entityDTO, correlationData);
} catch (AmqpException e) {
log.error("Point value publish rejected: deviceId={}, pointId={}",
entityDTO.getDeviceId(), entityDTO.getPointId(), e);
if (buffering) {
log.warn("Point value publish rejected, buffering for retry: deviceId={}, pointId={}",
entityDTO.getDeviceId(), entityDTO.getPointId(), e);
bufferService.offer(entityDTO, routingKey, correlationData.getId(), 1);
} else {
log.error("Point value publish rejected: deviceId={}, pointId={}",
entityDTO.getDeviceId(), entityDTO.getPointId(), e);
}
}
}
@@ -302,25 +331,4 @@ public class DriverSenderServiceImpl implements DriverSenderService {
deviceStateSender(deviceState);
}
/**
* Carries device/point context through the publisher-confirm callback.
*/
private static class PointValueCorrelation extends CorrelationData {
/**
* Device owning the point value, included so the NACK callback can log actionable context.
*/
final Long deviceId;
/**
* Point whose value failed to publish, included so the NACK callback can log actionable context.
*/
final Long pointId;
PointValueCorrelation(String id, Long deviceId, Long pointId) {
super(id);
this.deviceId = deviceId;
this.pointId = pointId;
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import io.github.pnoker.common.driver.entity.bean.PointValue;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
/**
* Verifies the buffer service offer/republish lifecycle with a mocked RabbitTemplate.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
@ExtendWith(MockitoExtension.class)
class BufferServiceImplTest {
@TempDir
Path tmp;
@Mock
private RabbitTemplate rabbitTemplate;
private DriverProperties properties;
private BufferServiceImpl service;
@BeforeEach
void setUp() {
properties = new DriverProperties();
properties.getBuffer().setEnabled(true);
properties.getBuffer().setDbPath(tmp.resolve("buffer.db").toString());
properties.getBuffer().setBatchSize(10);
properties.getBuffer().setMaxRetry(3);
properties.getBuffer().setBackoffSeconds(0);
service = new BufferServiceImpl(properties, rabbitTemplate);
service.initialize();
}
@Test
void offerPersistsAndRepublishSendsThenDrains() {
service.offer(pointValue(), "rk", "id-1", 1);
assertThat(service.pendingCount()).as("offer should persist one record").isEqualTo(1);
service.republishBatch();
verify(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
assertThat(service.pendingCount()).as("republish should drain the buffer").isEqualTo(0);
reset(rabbitTemplate);
service.republishBatch();
verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
}
@Test
void republishRequeuesOnAmqpExceptionThenResends() {
service.offer(pointValue(), "rk", "id-1", 1);
doThrow(new AmqpException("broker down")).when(rabbitTemplate)
.convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
service.republishBatch();
reset(rabbitTemplate);
service.republishBatch();
verify(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
reset(rabbitTemplate);
service.republishBatch();
verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
}
@Test
void disabledBufferIsNoOp() {
DriverProperties disabledProps = new DriverProperties();
disabledProps.getBuffer().setEnabled(false);
disabledProps.getBuffer().setDbPath(tmp.resolve("disabled.db").toString());
BufferServiceImpl disabled = new BufferServiceImpl(disabledProps, rabbitTemplate);
disabled.initialize();
assertThat(disabled.isEnabled()).isFalse();
disabled.offer(pointValue(), "rk", "id-1", 1);
disabled.republishBatch();
verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
}
private PointValue pointValue() {
return PointValue.builder().deviceId(1L).pointId(2L).rawValue("42").build();
}
}
@@ -0,0 +1,114 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.pnoker.common.driver.buffer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies the SQLite DAO against a temporary on-disk database.
*
* @author pnoker
* @version 2026.5.22
* @since 2026.6.2
*/
class PointValueBufferTest {
@Test
void upsertSelectDeleteRoundtrip(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsert(rec("id-1", 10L, 20L, 1, now, now));
buffer.upsert(rec("id-2", 11L, 21L, 1, now, now));
assertThat(buffer.selectPending(10, now)).hasSize(2);
buffer.delete("id-1");
assertThat(buffer.selectPending(10, now)).hasSize(1);
buffer.close();
}
@Test
void selectPendingSkipsFutureAttempts(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsert(rec("due", 10L, 20L, 1, now, now));
buffer.upsert(rec("future", 11L, 21L, 1, now + 3600, now));
List<BufferedPointValue> pending = buffer.selectPending(10, now);
assertThat(pending).hasSize(1).extracting(BufferedPointValue::id).contains("due");
buffer.close();
}
@Test
void markRetryBumpsAttemptAndBackoff(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsert(rec("id", 10L, 20L, 1, now, now));
buffer.markRetry("id", 2, now + 60);
assertThat(buffer.selectPending(10, now)).isEmpty();
List<BufferedPointValue> later = buffer.selectPending(10, now + 60);
assertThat(later).hasSize(1);
assertThat(later.get(0).attempt()).isEqualTo(2);
buffer.close();
}
@Test
void deleteOldestEvictsByCreatedAt(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsert(rec("old", 10L, 20L, 1, now, now - 100));
buffer.upsert(rec("new", 11L, 21L, 1, now, now));
assertThat(buffer.deleteOldest(1)).isEqualTo(1);
List<BufferedPointValue> pending = buffer.selectPending(10, now);
assertThat(pending).hasSize(1).extracting(BufferedPointValue::id).contains("new");
buffer.close();
}
@Test
void upsertReplacesExistingRow(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsert(rec("id", 10L, 20L, 1, now, now));
buffer.upsert(rec("id", 10L, 20L, 2, now + 30, now));
assertThat(buffer.count()).isEqualTo(1);
assertThat(buffer.selectPending(10, now + 30).get(0).attempt()).isEqualTo(2);
buffer.close();
}
private PointValueBuffer newBuffer(Path tmp) {
PointValueBuffer buffer = new PointValueBuffer(tmp.resolve("buffer.db").toString());
buffer.initialize();
return buffer;
}
private BufferedPointValue rec(String id, Long deviceId, Long pointId, int attempt, long nextAttemptAt, long createdAt) {
return new BufferedPointValue(id, deviceId, pointId, 1L, 2L, "{}", "rk", attempt, nextAttemptAt, createdAt);
}
private static long epoch() {
return System.currentTimeMillis() / 1000;
}
}
@@ -77,12 +77,21 @@
<artifactId>dc3-common-public</artifactId>
</dependency>
<!-- OpenAPI annotations -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-common</artifactId>
</dependency>
<!-- OpenTelemetry API for tracing context propagation -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-context</artifactId>
</dependency>
</dependencies>
</project>
@@ -24,6 +24,8 @@ import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import org.slf4j.MDC;
import org.springframework.grpc.client.GlobalClientInterceptor;
import org.springframework.stereotype.Component;
@@ -34,9 +36,17 @@ import org.springframework.stereotype.Component;
* the same id in its log lines. This closes the traceId gap across the
* HTTP&nbsp;→&nbsp;Gateway&nbsp;→&nbsp;gRPC&nbsp;→&nbsp;center-services hop.
*
* <p>The header name mirrors the HTTP {@code X-Request-Id} convention. When the MDC has no
* request id (e.g. a gRPC call triggered outside any HTTP request, such as a Quartz job or
* a driver registration), nothing is attached and the call proceeds unchanged.
* <p><b>Production-grade OpenTelemetry Integration:</b> This interceptor now integrates
* with OpenTelemetry. It will use:
* <ol>
* <li>MDC requestId (from existing RequestId mechanism)</li>
* <li>OpenTelemetry Trace ID (if available and no MDC value)</li>
* </ol>
* This ensures full compatibility with both systems while maintaining backward compatibility.
*
* <p>The header name mirrors the HTTP {@code X-Request-Id} convention. When no id is available
* (e.g. a gRPC call triggered outside any HTTP request, such as a Quartz job or a driver
* registration), nothing is attached and the call proceeds unchanged.
*
* <p>Registered as a {@code @GlobalClientInterceptor} bean so spring-grpc applies it to every
* client channel automatically.
@@ -65,13 +75,25 @@ public class RequestIdGrpcClientInterceptor implements ClientInterceptor {
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions, Channel next) {
String requestId = MDC.get(MDC_REQUEST_ID);
// If MDC doesn't have requestId, try to get it from OpenTelemetry
if (requestId == null || requestId.isBlank()) {
Span currentSpan = Span.current();
SpanContext spanContext = currentSpan.getSpanContext();
if (spanContext.isValid()) {
requestId = spanContext.getTraceId();
}
}
if (requestId == null || requestId.isBlank()) {
return next.newCall(method, callOptions);
}
final String finalRequestId = requestId;
return new ForwardingClientCall.SimpleForwardingClientCall<>(next.newCall(method, callOptions)) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(REQUEST_ID_KEY, requestId);
headers.put(REQUEST_ID_KEY, finalRequestId);
super.start(responseListener, headers);
}
};
@@ -22,6 +22,8 @@ import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import org.slf4j.MDC;
import org.springframework.grpc.server.GlobalServerInterceptor;
import org.springframework.stereotype.Component;
@@ -34,9 +36,18 @@ import java.util.UUID;
* every log line emitted by the service implementation carries the same id as the
* originating HTTP request.
*
* <p>When the caller did not send an {@code X-Request-Id} (e.g. a driver registering itself,
* or any gRPC call not initiated from an HTTP request), a fresh UUID is generated so the
* request is still traceable within this service.
* <p><b>Production-grade OpenTelemetry Integration:</b> This interceptor now integrates
* with OpenTelemetry. It will use (in order of priority):
* <ol>
* <li>X-Request-Id from gRPC metadata (backward compatibility)</li>
* <li>OpenTelemetry Trace ID (if available)</li>
* <li>A fresh UUID as last resort</li>
* </ol>
* This ensures full compatibility with both systems while maintaining backward compatibility.
*
* <p>When no id is available (e.g. a driver registering itself, or any gRPC call not
* initiated from an HTTP request), a fresh UUID is generated so the request is still
* traceable within this service.
*
* <p>MDC is set on {@code onHalfClose} (right before the service method runs) and cleared on
* completion or cancellation, to avoid leaking the entry across pooled netty threads. It is
@@ -70,9 +81,22 @@ public class RequestIdGrpcServerInterceptor implements ServerInterceptor {
// Read the id supplied by the caller (set by RequestIdGrpcClientInterceptor upstream),
// or mint one when absent so the call is still self-consistent in this service's logs.
String requestId = headers.get(REQUEST_ID_KEY);
// Priority 1: Use X-Request-Id from header
// Priority 2: Use OpenTelemetry Trace ID if available
if (requestId == null || requestId.isBlank()) {
Span currentSpan = Span.current();
SpanContext spanContext = currentSpan.getSpanContext();
if (spanContext.isValid()) {
requestId = spanContext.getTraceId();
}
}
// Priority 3: Fall back to UUID
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
return new RequestIdListener<>(next.startCall(call, headers), requestId);
}
@@ -436,15 +436,15 @@ public class McpGatewayController {
* Record a tool-call audit entry (success, denial, or error) with trace id,
* duration, argument digest, and client metadata from the exchange.
*
* @param context the introspected token context
* @param tool the resolved tool definition
* @param traceId the call trace id
* @param arguments the tool arguments
* @param controls confirmation and idempotency controls
* @param status audit status (success/denied/error)
* @param errorCode error code on failure, empty otherwise
* @param start the call start nanos, for duration
* @param exchange current server exchange, for client metadata and remote ip
* @param context the introspected token context
* @param tool the resolved tool definition
* @param traceId the call trace id
* @param arguments the tool arguments
* @param controls confirmation and idempotency controls
* @param status audit status (success/denied/error)
* @param errorCode error code on failure, empty otherwise
* @param start the call start nanos, for duration
* @param exchange current server exchange, for client metadata and remote ip
* @return a mono completing when the audit is recorded
*/
private Mono<Void> audit(McpIntrospectResponseDTO context, McpToolResolveResponseDTO tool, String traceId,
@@ -20,7 +20,7 @@
<configuration scan="true">
<!-- Override the parameter configurations in base.xml. -->
<property name="CONSOLE_LOG_PATTERN"
value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr([%X{requestId:-}]){faint} %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){magenta} [%clr(%4line){magenta}] %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx"/>
value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr([%X{requestId:-},%X{traceId:-},%X{spanId:-}]){faint} %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){magenta} [%clr(%4line){magenta}] %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx"/>
<property name="LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START" value="false"/>
<property name="LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE" value="200MB"/>
<property name="LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP" value="20GB"/>
+9
View File
@@ -83,6 +83,15 @@
<groupId>io.github.pnoker</groupId>
<artifactId>dc3-common-public</artifactId>
</dependency>
<!-- OpenTelemetry API for tracing context propagation -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-context</artifactId>
</dependency>
</dependencies>
@@ -17,6 +17,8 @@
package io.github.pnoker.common.config;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.slf4j.MDC;
@@ -29,13 +31,22 @@ import java.util.UUID;
* Restores the request id into the MDC for the duration of a RabbitMQ message handling, so the
* traceId chain is continuous across the HTTP&nbsp;→&nbsp;gRPC&nbsp;→&nbsp;RabbitMQ hops.
*
* <p><b>Production-grade OpenTelemetry Integration:</b> This advice now integrates
* with OpenTelemetry. It will use (in order of priority):
* <ol>
* <li>X-Request-Id from message header (backward compatibility)</li>
* <li>OpenTelemetry Trace ID (if available)</li>
* <li>A fresh UUID as last resort</li>
* </ol>
* This ensures full compatibility with both systems while maintaining backward compatibility.
*
* <p>The id is carried in the {@value #HEADER_REQUEST_ID} message header, stamped on the
* producer side by the {@code beforePublish} post-processor in {@link RabbitConfig}. On the
* consumer side, the listener container runs on a pooled thread; this advice reads the header
* before the listener runs, publishes it into the MDC, and removes it afterwards — guaranteeing
* the entry never leaks to the next message processed on the same pooled thread.
*
* <p>When the header is absent (e.g. a driver registration, a Quartz-triggered publish, or a
* <p>When no id is available (e.g. a driver registration, a Quartz-triggered publish, or a
* producer that predates this wiring), a fresh UUID is minted so the consumer's logs are still
* self-consistent within that single message's handling.
*
@@ -88,10 +99,18 @@ public class MdcRequestIdListenerAdvice implements MethodInterceptor {
}
/**
* Read the request id from the message header, falling back to a fresh UUID so the consumer
* is always traceable within itself even when the producer did not stamp one.
* Read the request id from the message header, falling back to OpenTelemetry Trace ID,
* and finally a fresh UUID so the consumer is always traceable.
* <p>
* Priority order:
* <ol>
* <li>X-Request-Id from message header (backward compatibility)</li>
* <li>OpenTelemetry Trace ID (if available)</li>
* <li>Fresh UUID as last resort</li>
* </ol>
*/
private String readRequestId(Message message) {
// Priority 1: Use X-Request-Id from header
if (message != null) {
MessageProperties properties = message.getMessageProperties();
if (properties != null) {
@@ -101,6 +120,15 @@ public class MdcRequestIdListenerAdvice implements MethodInterceptor {
}
}
}
// Priority 2: Use OpenTelemetry Trace ID if available
Span currentSpan = Span.current();
SpanContext spanContext = currentSpan.getSpanContext();
if (spanContext.isValid()) {
return spanContext.getTraceId();
}
// Priority 3: Fall back to UUID
return UUID.randomUUID().toString();
}
}
@@ -17,6 +17,8 @@
package io.github.pnoker.common.config;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import org.slf4j.MDC;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessagePostProcessor;
@@ -27,7 +29,15 @@ import org.springframework.amqp.core.MessagePostProcessor;
* {@link MdcRequestIdListenerAdvice} and keep the traceId chain continuous across the broker
* hop.
*
* <p>When MDC has no request id (a publish not driven by an HTTP request — driver
* <p><b>Production-grade OpenTelemetry Integration:</b> This post processor now integrates
* with OpenTelemetry. It will use (in order of priority):
* <ol>
* <li>MDC requestId (from existing RequestId mechanism)</li>
* <li>OpenTelemetry Trace ID (if available and no MDC value)</li>
* </ol>
* This ensures full compatibility with both systems while maintaining backward compatibility.
*
* <p>When no id is available (a publish not driven by an HTTP request — driver
* registration, Quartz job, …), nothing is stamped and the consumer mints its own id.
*
* <p>Extracted as a named class (rather than a lambda in {@link RabbitConfig}) so it can be
@@ -52,6 +62,16 @@ public class MdcRequestIdMessagePostProcessor implements MessagePostProcessor {
@Override
public Message postProcessMessage(Message message) {
String requestId = MDC.get(MDC_REQUEST_ID);
// If MDC doesn't have requestId, try to get it from OpenTelemetry
if (requestId == null || requestId.isBlank()) {
Span currentSpan = Span.current();
SpanContext spanContext = currentSpan.getSpanContext();
if (spanContext.isValid()) {
requestId = spanContext.getTraceId();
}
}
if (requestId != null && !requestId.isBlank()) {
message.getMessageProperties().setHeader(HEADER_REQUEST_ID, requestId);
}
+27 -1
View File
@@ -87,9 +87,35 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- OpenTelemetry Tracing (Full stack) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk-trace</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-extension-trace-propagators</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-context</artifactId>
</dependency>
<!-- Utility Libraries -->
@@ -17,6 +17,8 @@
package io.github.pnoker.common.filter;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
@@ -26,18 +28,30 @@ import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;
import reactor.util.context.ContextView;
import java.util.UUID;
/**
* Propagates a per-request id so every log line within a request carries the same
* {@code requestId} (rendered by the {@code [%X{requestId:-}]} slot in the logback pattern).
* <p>
* <b>Production-grade OpenTelemetry Integration:</b> This filter now integrates with
* OpenTelemetry. The requestId is set to the OpenTelemetry Trace ID when a trace exists,
* falling back to a UUID when no trace is present. This ensures:
* <ul>
* <li>Logs and distributed traces use the same identifier</li>
* <li>Backward compatibility with X-Request-Id header</li>
* <li>Full interoperability with OpenTelemetry ecosystem</li>
* </ul>
*
* <p>The id is taken from the inbound {@code X-Request-Id} header when present (allowing an
* upstream gateway or caller to supply one), otherwise a fresh UUID is generated. The same id is
* echoed back on the response via {@code X-Request-Id}, so callers can correlate a failing
* request with server logs.
* <p>The id is taken from (in order of priority):
* <ol>
* <li>The inbound {@code X-Request-Id} header (backward compatibility)</li>
* <li>The current OpenTelemetry Trace ID</li>
* <li>A fresh UUID as a last resort</li>
* </ol>
* The same id is echoed back on the response via {@code X-Request-Id}, so callers can
* correlate a failing request with server logs and distributed traces.
*
* <p><b>Why Reactor Context, not MDC:</b> This is a WebFlux application. Controllers run their
* blocking suppliers on {@code Schedulers.boundedElastic()} (see {@code BaseController.async()}),
@@ -76,11 +90,24 @@ public class RequestIdWebFilter implements WebFilter {
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
HttpHeaders headers = exchange.getRequest().getHeaders();
String requestId = headers.getFirst(HEADER_REQUEST_ID);
// Priority 1: Use X-Request-Id from header (backward compatibility)
// Priority 2: Use OpenTelemetry Trace ID if available
if (requestId == null || requestId.isBlank()) {
Span currentSpan = Span.current();
SpanContext spanContext = currentSpan.getSpanContext();
if (spanContext.isValid()) {
requestId = spanContext.getTraceId();
}
}
// Priority 3: Fall back to UUID if no trace or header
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
String finalRequestId = requestId;
// Echo back so callers can correlate a failing request with server-side logs.
// Echo back so callers can correlate a failing request with server-side logs and traces.
exchange.getResponse().getHeaders().add(HEADER_REQUEST_ID, finalRequestId);
// Publish the id through the Reactor Context (not just MDC): the Context propagates
// along the reactive call chain regardless of thread hops, so BaseController.async()
@@ -46,8 +46,32 @@ management:
endpoints:
web:
exposure:
# Expose health and Prometheus endpoints for readiness and metrics scraping.
include: health,prometheus
# Expose health, Prometheus, and additional observability endpoints.
include: health,prometheus,info,metrics
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
# OpenTelemetry Tracing Configuration (Production-grade)
tracing:
enabled: ${OTEL_TRACING_ENABLED:true}
sampling:
probability: ${OTEL_TRACING_SAMPLING_PROBABILITY:1.0} # 1.0 for development, reduce to 0.1-0.3 in production
propagation:
type: B3,W3C # B3 for legacy systems, W3C for standard trace context
baggage:
enabled: true
export:
otlp:
enabled: ${OTEL_OTLP_EXPORTER_ENABLED:true} # Enable OTLP exporter by default
# OTLP tracing export endpoint (Spring Boot: management.otlp.tracing.*, NOT management.tracing.otlp.*)
otlp:
tracing:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://dc3-jaeger:4318/v1/traces}
timeout: ${OTEL_EXPORTER_OTLP_TIMEOUT:10s}
compression: gzip
# OpenAPI / Swagger UI baseline, shared by every web service (auth, manager,
# data, agentic, single) through the auto-activated "web" profile. Document
@@ -105,6 +105,10 @@ dc3:
default-value: PRESENT_VALUE
remark: Property identifier
buffer:
enable: true
db-path: dc3/data/driver/bacnet-ip/buffer.db
spring:
application:
name: @project.artifactId@
@@ -85,6 +85,10 @@ dc3:
default-value: ''
remark: GATT Characteristic UUID for writing
buffer:
enable: true
db-path: dc3/data/driver/ble/buffer.db
spring:
application:
name: @project.artifactId@
@@ -89,6 +89,10 @@ dc3:
attribute-type-flag: STRING
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/can/buffer.db
spring:
application:
name: @project.artifactId@
@@ -64,6 +64,10 @@ dc3:
remark:
Content format: json, text, cbor, octet-stream
buffer:
enable: true
db-path: dc3/data/driver/coap/buffer.db
spring:
application:
name: @project.artifactId@
@@ -100,6 +100,10 @@ dc3:
remark: Attribute ID (2=Present Value)
command-attribute: [ ]
buffer:
enable: true
db-path: dc3/data/driver/dlms/buffer.db
spring:
application:
name: @project.artifactId@
@@ -71,6 +71,10 @@ dc3:
attribute-type-flag: STRING
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/ethernet-ip/buffer.db
spring:
application:
name: @project.artifactId@
@@ -102,6 +102,10 @@ dc3:
attribute-type-flag: STRING
default-value: UINT16
buffer:
enable: true
db-path: dc3/data/driver/fins/buffer.db
spring:
application:
name: @project.artifactId@
@@ -90,6 +90,10 @@ dc3:
default-value: POST
remark: HTTP method for command
buffer:
enable: true
db-path: dc3/data/driver/http/buffer.db
spring:
application:
name: @project.artifactId@
@@ -79,6 +79,10 @@ dc3:
attribute-type-flag: STRING
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/iec104/buffer.db
spring:
application:
name: @project.artifactId@
@@ -63,6 +63,10 @@ dc3:
default-value: string
remark: Parse type, short, int, long, float, double, boolean, string
buffer:
enable: true
db-path: dc3/data/driver/listening-virtual/buffer.db
spring:
application:
name: @project.artifactId@
@@ -100,6 +100,10 @@ dc3:
secure-port: 5684
security-mode: NOSEC
buffer:
enable: true
db-path: dc3/data/driver/lwm2m/buffer.db
spring:
application:
name: @project.artifactId@
@@ -64,6 +64,10 @@ dc3:
default-value: 0
remark: String read length (0 for non-string types)
buffer:
enable: true
db-path: dc3/data/driver/melsec/buffer.db
spring:
application:
name: @project.artifactId@
@@ -100,6 +100,10 @@ dc3:
default-value: '${value}'
remark: Value template rendered with command params
buffer:
enable: true
db-path: dc3/data/driver/modbus-rtu/buffer.db
spring:
application:
name: @project.artifactId@
@@ -85,6 +85,10 @@ dc3:
default-value: '${value}'
remark: Value template rendered with command params
buffer:
enable: true
db-path: dc3/data/driver/modbus-tcp/buffer.db
spring:
application:
name: @project.artifactId@
@@ -80,6 +80,10 @@ dc3:
default-value: $.payload
remark: JSON path used to resolve event payload
buffer:
enable: true
db-path: dc3/data/driver/mqtt/buffer.db
spring:
application:
name: @project.artifactId@
@@ -85,6 +85,10 @@ dc3:
default-value:
remark: SQL query to execute for command
buffer:
enable: true
db-path: dc3/data/driver/mysql/buffer.db
spring:
application:
name: @project.artifactId@
@@ -69,6 +69,10 @@ dc3:
default-value: TAG
remark: OPC DA item tag name
buffer:
enable: true
db-path: dc3/data/driver/opc-da/buffer.db
spring:
application:
name: @project.artifactId@
@@ -64,6 +64,10 @@ dc3:
default-value: TAG
remark: OPC UA node tag name
buffer:
enable: true
db-path: dc3/data/driver/opc-ua/buffer.db
spring:
application:
name: @project.artifactId@
@@ -100,6 +100,10 @@ dc3:
default-value:
remark: SQL query to execute for command
buffer:
enable: true
db-path: dc3/data/driver/oracle/buffer.db
spring:
application:
name: @project.artifactId@
@@ -69,6 +69,10 @@ dc3:
default-value: 0
remark: Bit offset (only used for boolean type)
buffer:
enable: true
db-path: dc3/data/driver/plcs7/buffer.db
spring:
application:
name: @project.artifactId@
@@ -85,6 +85,10 @@ dc3:
default-value:
remark: SQL query to execute for command
buffer:
enable: true
db-path: dc3/data/driver/postgresql/buffer.db
spring:
application:
name: @project.artifactId@
@@ -128,6 +128,10 @@ dc3:
remark:
Byte order for encoding value: BIG, LITTLE
buffer:
enable: true
db-path: dc3/data/driver/serial/buffer.db
spring:
application:
name: @project.artifactId@
@@ -52,6 +52,10 @@ dc3:
default-value: 0
remark: Zero-based index into the telemetry body element list
buffer:
enable: true
db-path: dc3/data/driver/sl651/buffer.db
spring:
application:
name: @project.artifactId@
@@ -97,6 +97,10 @@ dc3:
attribute-type-flag: STRING
default-value: OCTET_STRING
buffer:
enable: true
db-path: dc3/data/driver/snmp/buffer.db
spring:
application:
name: @project.artifactId@
@@ -95,6 +95,10 @@ dc3:
default-value:
remark: SQL query to execute for command
buffer:
enable: true
db-path: dc3/data/driver/sqlserver/buffer.db
spring:
application:
name: @project.artifactId@
@@ -103,6 +103,10 @@ dc3:
attribute-type-flag: STRING
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/tcp-udp/buffer.db
spring:
application:
name: @project.artifactId@
@@ -76,6 +76,10 @@ dc3:
default-value: $.payload
remark: JSON path used to resolve event payload
buffer:
enable: true
db-path: dc3/data/driver/virtual/buffer.db
spring:
application:
name: @project.artifactId@
@@ -105,6 +105,10 @@ dc3:
default-value: 0
remark: Attribute ID for writing
buffer:
enable: true
db-path: dc3/data/driver/zigbee/buffer.db
spring:
application:
name: @project.artifactId@
+29 -1
View File
@@ -237,6 +237,7 @@ services:
hostname: dc3-driver-listening-virtual
volumes:
- logs:/dc3-driver/dc3-driver-listening-virtual/dc3/logs
- driver_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -259,6 +260,7 @@ services:
hostname: dc3-driver-modbus-tcp
volumes:
- logs:/dc3-driver/dc3-driver-modbus-tcp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -281,6 +283,7 @@ services:
hostname: dc3-driver-modbus-rtu
volumes:
- logs:/dc3-driver/dc3-driver-modbus-rtu/dc3/logs
- driver_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -318,6 +321,7 @@ services:
hostname: dc3-driver-bacnet-ip
volumes:
- logs:/dc3-driver/dc3-driver-bacnet-ip/dc3/logs
- driver_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -340,6 +344,7 @@ services:
hostname: dc3-driver-ble
volumes:
- logs:/dc3-driver/dc3-driver-ble/dc3/logs
- driver_data:/dc3-driver/dc3-driver-ble/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -362,6 +367,7 @@ services:
hostname: dc3-driver-can
volumes:
- logs:/dc3-driver/dc3-driver-can/dc3/logs
- driver_data:/dc3-driver/dc3-driver-can/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -384,6 +390,7 @@ services:
hostname: dc3-driver-coap
volumes:
- logs:/dc3-driver/dc3-driver-coap/dc3/logs
- driver_data:/dc3-driver/dc3-driver-coap/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -406,6 +413,7 @@ services:
hostname: dc3-driver-dlms
volumes:
- logs:/dc3-driver/dc3-driver-dlms/dc3/logs
- driver_data:/dc3-driver/dc3-driver-dlms/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -428,6 +436,7 @@ services:
hostname: dc3-driver-ethernet-ip
volumes:
- logs:/dc3-driver/dc3-driver-ethernet-ip/dc3/logs
- driver_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -450,6 +459,7 @@ services:
hostname: dc3-driver-fins
volumes:
- logs:/dc3-driver/dc3-driver-fins/dc3/logs
- driver_data:/dc3-driver/dc3-driver-fins/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -472,6 +482,7 @@ services:
hostname: dc3-driver-http
volumes:
- logs:/dc3-driver/dc3-driver-http/dc3/logs
- driver_data:/dc3-driver/dc3-driver-http/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -494,6 +505,7 @@ services:
hostname: dc3-driver-iec104
volumes:
- logs:/dc3-driver/dc3-driver-iec104/dc3/logs
- driver_data:/dc3-driver/dc3-driver-iec104/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -516,6 +528,7 @@ services:
hostname: dc3-driver-lwm2m
volumes:
- logs:/dc3-driver/dc3-driver-lwm2m/dc3/logs
- driver_data:/dc3-driver/dc3-driver-lwm2m/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -538,6 +551,7 @@ services:
hostname: dc3-driver-melsec
volumes:
- logs:/dc3-driver/dc3-driver-melsec/dc3/logs
- driver_data:/dc3-driver/dc3-driver-melsec/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -560,6 +574,7 @@ services:
hostname: dc3-driver-mysql
volumes:
- logs:/dc3-driver/dc3-driver-mysql/dc3/logs
- driver_data:/dc3-driver/dc3-driver-mysql/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -582,6 +597,7 @@ services:
hostname: dc3-driver-oracle
volumes:
- logs:/dc3-driver/dc3-driver-oracle/dc3/logs
- driver_data:/dc3-driver/dc3-driver-oracle/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -604,6 +620,7 @@ services:
hostname: dc3-driver-postgresql
volumes:
- logs:/dc3-driver/dc3-driver-postgresql/dc3/logs
- driver_data:/dc3-driver/dc3-driver-postgresql/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -626,6 +643,7 @@ services:
hostname: dc3-driver-serial
volumes:
- logs:/dc3-driver/dc3-driver-serial/dc3/logs
- driver_data:/dc3-driver/dc3-driver-serial/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -648,6 +666,7 @@ services:
hostname: dc3-driver-sl651
volumes:
- logs:/dc3-driver/dc3-driver-sl651/dc3/logs
- driver_data:/dc3-driver/dc3-driver-sl651/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -670,6 +689,7 @@ services:
hostname: dc3-driver-snmp
volumes:
- logs:/dc3-driver/dc3-driver-snmp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-snmp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -692,6 +712,7 @@ services:
hostname: dc3-driver-sqlserver
volumes:
- logs:/dc3-driver/dc3-driver-sqlserver/dc3/logs
- driver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -714,6 +735,7 @@ services:
hostname: dc3-driver-tcp-udp
volumes:
- logs:/dc3-driver/dc3-driver-tcp-udp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -736,6 +758,7 @@ services:
hostname: dc3-driver-zigbee
volumes:
- logs:/dc3-driver/dc3-driver-zigbee/dc3/logs
- driver_data:/dc3-driver/dc3-driver-zigbee/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -744,6 +767,7 @@ services:
volumes:
- logs:/dc3-driver/dc3-driver-mqtt/dc3/logs
- driver_data:/dc3-driver/dc3-driver-mqtt/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -766,6 +790,7 @@ services:
hostname: dc3-driver-opc-da
volumes:
- logs:/dc3-driver/dc3-driver-opc-da/dc3/logs
- driver_data:/dc3-driver/dc3-driver-opc-da/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -788,6 +813,7 @@ services:
hostname: dc3-driver-opc-ua
volumes:
- logs:/dc3-driver/dc3-driver-opc-ua/dc3/logs
- driver_data:/dc3-driver/dc3-driver-opc-ua/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -810,6 +836,7 @@ services:
hostname: dc3-driver-plcs7
volumes:
- logs:/dc3-driver/dc3-driver-plcs7/dc3/logs
- driver_data:/dc3-driver/dc3-driver-plcs7/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -832,6 +859,7 @@ services:
hostname: dc3-driver-virtual
volumes:
- logs:/dc3-driver/dc3-driver-virtual/dc3/logs
- driver_data:/dc3-driver/dc3-driver-virtual/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -840,7 +868,7 @@ services:
volumes:
logs:
driver_data:
networks:
dc3net:
driver: bridge
+19
View File
@@ -127,6 +127,25 @@ services:
aliases:
- dc3-apm
# ---------- Distributed Tracing (Jaeger) ----------
jaeger:
image: jaegertracing/all-in-one:latest
restart: always
container_name: dc3-jaeger
hostname: dc3-jaeger
ports:
- "${DC3_BIND_HOST:-127.0.0.1}:${DC3_JAEGER_UI_PORT:-16686}:16686" # Jaeger UI
- "${DC3_BIND_HOST:-127.0.0.1}:${DC3_JAEGER_OTLP_GRPC_PORT:-4317}:4317" # OTLP gRPC
- "${DC3_BIND_HOST:-127.0.0.1}:${DC3_JAEGER_OTLP_HTTP_PORT:-4318}:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
- LOG_LEVEL=info
logging: *default-logging
networks:
dc3net:
aliases:
- dc3-jaeger
# ---------- Monitoring (Prometheus + exporters + Grafana) ----------
prometheus:
image: ${DC3_IMAGE_REGISTRY:-pnoker}/dc3-prometheus:${DC3_IMAGE_TAG:-2026.6}
+26
View File
@@ -237,6 +237,7 @@ services:
hostname: dc3-driver-listening-virtual
volumes:
- logs:/dc3-driver/dc3-driver-listening-virtual/dc3/logs
- driver_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -255,6 +256,7 @@ services:
hostname: dc3-driver-modbus-tcp
volumes:
- logs:/dc3-driver/dc3-driver-modbus-tcp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -273,6 +275,7 @@ services:
hostname: dc3-driver-modbus-rtu
volumes:
- logs:/dc3-driver/dc3-driver-modbus-rtu/dc3/logs
- driver_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -291,6 +294,7 @@ services:
hostname: dc3-driver-mqtt
volumes:
- logs:/dc3-driver/dc3-driver-mqtt/dc3/logs
- driver_data:/dc3-driver/dc3-driver-mqtt/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -309,6 +313,7 @@ services:
hostname: dc3-driver-opc-da
volumes:
- logs:/dc3-driver/dc3-driver-opc-da/dc3/logs
- driver_data:/dc3-driver/dc3-driver-opc-da/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -327,6 +332,7 @@ services:
hostname: dc3-driver-opc-ua
volumes:
- logs:/dc3-driver/dc3-driver-opc-ua/dc3/logs
- driver_data:/dc3-driver/dc3-driver-opc-ua/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -345,6 +351,7 @@ services:
hostname: dc3-driver-plcs7
volumes:
- logs:/dc3-driver/dc3-driver-plcs7/dc3/logs
- driver_data:/dc3-driver/dc3-driver-plcs7/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -363,6 +370,7 @@ services:
hostname: dc3-driver-virtual
volumes:
- logs:/dc3-driver/dc3-driver-virtual/dc3/logs
- driver_data:/dc3-driver/dc3-driver-virtual/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -381,6 +389,7 @@ services:
hostname: dc3-driver-bacnet-ip
volumes:
- logs:/dc3-driver/dc3-driver-bacnet-ip/dc3/logs
- driver_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -399,6 +408,7 @@ services:
hostname: dc3-driver-fins
volumes:
- logs:/dc3-driver/dc3-driver-fins/dc3/logs
- driver_data:/dc3-driver/dc3-driver-fins/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -417,6 +427,7 @@ services:
hostname: dc3-driver-melsec
volumes:
- logs:/dc3-driver/dc3-driver-melsec/dc3/logs
- driver_data:/dc3-driver/dc3-driver-melsec/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -435,6 +446,7 @@ services:
hostname: dc3-driver-ethernet-ip
volumes:
- logs:/dc3-driver/dc3-driver-ethernet-ip/dc3/logs
- driver_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -453,6 +465,7 @@ services:
hostname: dc3-driver-iec104
volumes:
- logs:/dc3-driver/dc3-driver-iec104/dc3/logs
- driver_data:/dc3-driver/dc3-driver-iec104/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -471,6 +484,7 @@ services:
hostname: dc3-driver-sl651
volumes:
- logs:/dc3-driver/dc3-driver-sl651/dc3/logs
- driver_data:/dc3-driver/dc3-driver-sl651/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -489,6 +503,7 @@ services:
hostname: dc3-driver-snmp
volumes:
- logs:/dc3-driver/dc3-driver-snmp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-snmp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -507,6 +522,7 @@ services:
hostname: dc3-driver-dlms
volumes:
- logs:/dc3-driver/dc3-driver-dlms/dc3/logs
- driver_data:/dc3-driver/dc3-driver-dlms/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -525,6 +541,7 @@ services:
hostname: dc3-driver-coap
volumes:
- logs:/dc3-driver/dc3-driver-coap/dc3/logs
- driver_data:/dc3-driver/dc3-driver-coap/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -543,6 +560,7 @@ services:
hostname: dc3-driver-lwm2m
volumes:
- logs:/dc3-driver/dc3-driver-lwm2m/dc3/logs
- driver_data:/dc3-driver/dc3-driver-lwm2m/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -561,6 +579,7 @@ services:
hostname: dc3-driver-http
volumes:
- logs:/dc3-driver/dc3-driver-http/dc3/logs
- driver_data:/dc3-driver/dc3-driver-http/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -579,6 +598,7 @@ services:
hostname: dc3-driver-serial
volumes:
- logs:/dc3-driver/dc3-driver-serial/dc3/logs
- driver_data:/dc3-driver/dc3-driver-serial/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -597,6 +617,7 @@ services:
hostname: dc3-driver-tcp-udp
volumes:
- logs:/dc3-driver/dc3-driver-tcp-udp/dc3/logs
- driver_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -615,6 +636,7 @@ services:
hostname: dc3-driver-mysql
volumes:
- logs:/dc3-driver/dc3-driver-mysql/dc3/logs
- driver_data:/dc3-driver/dc3-driver-mysql/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -633,6 +655,7 @@ services:
hostname: dc3-driver-postgresql
volumes:
- logs:/dc3-driver/dc3-driver-postgresql/dc3/logs
- driver_data:/dc3-driver/dc3-driver-postgresql/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -651,6 +674,7 @@ services:
hostname: dc3-driver-oracle
volumes:
- logs:/dc3-driver/dc3-driver-oracle/dc3/logs
- driver_data:/dc3-driver/dc3-driver-oracle/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -669,6 +693,7 @@ services:
hostname: dc3-driver-sqlserver
volumes:
- logs:/dc3-driver/dc3-driver-sqlserver/dc3/logs
- driver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data
logging: *default-logging
networks:
dc3net:
@@ -677,6 +702,7 @@ services:
volumes:
logs:
driver_data:
nginx:
networks:
+1
View File
@@ -0,0 +1 @@
22
+43 -24
View File
@@ -18,6 +18,9 @@
import {defineConfig} from 'vitepress'
import {Lang, t} from './i18n'
import {transformHead} from './seo.mts'
import {resolveVersion} from './version.mts'
const versionInfo = resolveVersion()
// ── i18n via locales/{lang}.json ──
// All user-facing strings are resolved through t(lang, key).
@@ -33,17 +36,15 @@ type Pillar = {
groups: ReadonlyArray<Group>
}
// Pillar order: 架构 → 驱动 → AI → 基础 → 开发 (after nav 首页)
// Pillar order: 总览 → 架构 → 驱动 → AI → 基础 → 开发 → 运维 (after nav 首页)
const PILLARS: ReadonlyArray<Pillar> = [
{ // ①
navKey: 'pillar.architecture', landing: 'architecture',
paths: ['architecture', 'modules', 'introduction'],
activeMatch: '^/(zh|en)/(architecture|modules|introduction)/',
{ // ① 总览
navKey: 'pillar.overview', landing: 'introduction',
paths: ['introduction', 'quickstart'], activeMatch: '^/(zh|en)/(introduction|quickstart)/',
groups: [
{key: '', items: [['architecture']]},
{
key: 'group.project-overview',
items: [['introduction'], ['introduction/concepts'], ['introduction/paths'], ['introduction/concepts/tenant']]
key: '',
items: [['introduction'], ['introduction/concepts'], ['introduction/paths']]
},
{
key: 'group.objects-data',
@@ -51,18 +52,26 @@ const PILLARS: ReadonlyArray<Pillar> = [
},
{
key: 'group.capabilities-boundaries',
items: [['introduction/concepts/command'], ['introduction/concepts/event'], ['introduction/concepts/attribute-config']]
items: [['introduction/concepts/command'], ['introduction/concepts/event'], ['introduction/concepts/attribute-config'], ['introduction/concepts/tenant']]
},
{key: 'group.quickstart', items: [['quickstart'], ['quickstart/environment'], ['quickstart/first-device']]},
{key: 'group.appendix', items: [['introduction/glossary'], ['introduction/license']]}
]
},
{ // ② 架构
navKey: 'pillar.architecture', landing: 'architecture',
paths: ['architecture', 'modules'], activeMatch: '^/(zh|en)/(architecture|modules)/',
groups: [
{key: '', items: [['architecture']]},
{key: 'group.services-collab', items: [['architecture/services'], ['architecture/facade-modes']]},
{
key: 'group.pipelines-model',
items: [['architecture/data-plane'], ['architecture/command-plane'], ['architecture/auth-rbac'], ['architecture/domain-model']]
},
{key: 'group.modules', items: [['architecture/modules'], ['modules']]},
{key: 'group.appendix', items: [['introduction/glossary'], ['introduction/license']]}
{key: 'group.modules', items: [['architecture/modules'], ['modules']]}
]
},
{ //
{ // ③ 驱动
navKey: 'pillar.drivers', landing: 'drivers',
paths: ['drivers', 'operation/device-onboarding'],
activeMatch: '^/(zh|en)/(drivers/|operation/device-onboarding)',
@@ -89,7 +98,7 @@ const PILLARS: ReadonlyArray<Pillar> = [
{key: 'group.appendix-drivers', items: [['drivers/matrix']]}
]
},
{ //
{ // ④ AI
navKey: 'pillar.ai',
landing: 'ai',
paths: ['ai'],
@@ -99,7 +108,7 @@ const PILLARS: ReadonlyArray<Pillar> = [
{key: 'group.ai-integration', items: [['ai/agentic'], ['ai/mcp'], ['ai/spring-ai-deep-dive']]}
]
},
{ //
{ // ⑤ 基础
navKey: 'pillar.foundations', landing: 'foundations',
paths: ['foundations'], activeMatch: '^/(zh|en)/foundations/',
groups: [
@@ -111,24 +120,29 @@ const PILLARS: ReadonlyArray<Pillar> = [
{key: 'group.security', items: [['foundations/security']]}
]
},
{ //
{ // ⑥ 开发
navKey: 'pillar.develop',
landing: 'development',
paths: ['development', 'frontend', 'automation', 'quickstart', 'operation', 'guide'],
activeMatch: '^/(zh|en)/(development|frontend|automation|quickstart|operation|guide)/',
paths: ['development', 'frontend', 'automation'],
activeMatch: '^/(zh|en)/(development|frontend|automation)/',
groups: [
{key: 'group.quickstart', items: [['quickstart'], ['quickstart/environment'], ['quickstart/first-device']]},
{
key: 'group.deploy-ops',
items: [['guide/usage'], ['guide/observability'], ['guide/logging'], ['guide/troubleshooting']]
},
{
key: 'group.development',
items: [['development'], ['development/driver-authoring'], ['development/api-documentation'], ['development/technology-stack'], ['development/testing'], ['development/changelog']]
},
{key: 'group.frontend', items: [['frontend'], ['frontend/test-debugging']]},
{key: 'group.automation', items: [['automation/cli']]},
{key: 'group.operations', items: [['operation'], ['operation/data-commands'], ['operation/alarms']]}
{key: 'group.automation', items: [['automation/cli']]}
]
},
{ // ⑦ 运维
navKey: 'pillar.operations', landing: 'operation',
paths: ['operation', 'guide'], activeMatch: '^/(zh|en)/(operation|guide)/',
groups: [
{key: 'group.operations', items: [['operation'], ['operation/data-commands'], ['operation/alarms']]},
{
key: 'group.deploy-ops',
items: [['guide/usage'], ['guide/observability'], ['guide/logging'], ['guide/troubleshooting']]
}
]
}
]
@@ -271,6 +285,11 @@ export default defineConfig({
// Preconnect to analytics origins so the first request isn't blocked on a cold DNS/TLS handshake
['link', {rel: 'preconnect', href: 'https://www.googletagmanager.com'}],
['link', {rel: 'preconnect', href: 'https://hm.baidu.com'}],
// Version meta — read at runtime by VersionSwitcher and VersionBanner
['meta', {name: 'dc3-doc-version', content: versionInfo.version}],
['meta', {name: 'dc3-doc-full-version', content: versionInfo.fullVersion}],
['meta', {name: 'dc3-doc-is-latest', content: String(versionInfo.isLatest)}],
['meta', {name: 'dc3-doc-latest-url', content: 'https://docs.dc3.site'}],
// Google Analytics (GA4)
['script', {async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-0S37KX68Y3'}],
['script', {}, `window.dataLayer = window.dataLayer || [];
+4 -1
View File
@@ -20,6 +20,9 @@ import {execSync} from 'node:child_process'
import {fileURLToPath} from 'node:url'
import {resolve} from 'node:path'
import type {HeadConfig, TransformContext} from 'vitepress'
import {resolveVersion} from './version.mts'
const versionInfo = resolveVersion()
const SITE_URL = 'https://docs.dc3.site'
const DOCS_ROOT = fileURLToPath(new URL('..', import.meta.url))
@@ -392,7 +395,7 @@ export function transformHead(context: TransformContext): HeadConfig[] {
return [
['meta', {name: 'description', content: description}],
['meta', {name: 'robots', content: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1'}],
['meta', {name: 'robots', content: versionInfo.isLatest ? 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1' : 'noindex,follow'}],
['meta', {name: 'author', content: 'IoT DC3 Contributors'}],
['link', {rel: 'canonical', href: canonicalUrl}],
['link', {rel: 'alternate', type: 'text/plain', href: `${SITE_URL}/llms.txt`, title: 'AI-readable site summary'}],
@@ -0,0 +1,95 @@
<!--
- Copyright 2016-present the IoT DC3 original author or authors.
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts" setup>
import {ref, onMounted, computed} from 'vue'
import {useData} from 'vitepress'
const {lang} = useData()
const isLatest = ref(true)
const currentVersion = ref('')
const latestUrl = ref('')
const isZh = computed(() => lang.value.startsWith('zh'))
onMounted(() => {
const metaLatest = document.querySelector('meta[name="dc3-doc-is-latest"]')
isLatest.value = metaLatest?.getAttribute('content') !== 'false'
const metaVersion = document.querySelector('meta[name="dc3-doc-version"]')
currentVersion.value = metaVersion?.getAttribute('content') || ''
const metaLatestUrl = document.querySelector('meta[name="dc3-doc-latest-url"]')
latestUrl.value = metaLatestUrl?.getAttribute('content') || '/'
})
</script>
<template>
<div v-if="!isLatest" class="dc3-version-banner">
<svg class="banner-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
/>
</svg>
<span class="banner-text">
<template v-if="isZh">
你正在查看 <strong>{{ currentVersion }}</strong> 的文档已归档查看
<a :href="latestUrl">最新版本</a>
</template>
<template v-else>
You are viewing archived documentation for <strong>{{ currentVersion }}</strong>.
View the <a :href="latestUrl">latest version</a>.
</template>
</span>
</div>
</template>
<style scoped>
.dc3-version-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 16px;
font-size: 13px;
line-height: 1.4;
color: #fff;
background: var(--vp-c-brand-1);
}
.banner-icon {
flex-shrink: 0;
opacity: 0.85;
}
.banner-text strong {
font-weight: 700;
}
.banner-text a {
color: #fff;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.banner-text a:hover {
opacity: 0.85;
}
</style>
@@ -0,0 +1,286 @@
<!--
- Copyright 2016-present the IoT DC3 original author or authors.
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts" setup>
import {ref, onMounted, onBeforeUnmount, computed} from 'vue'
interface VersionEntry {
version: string
url: string
latest?: boolean
}
interface VersionsFile {
latest: string
versions: VersionEntry[]
}
const STORAGE_KEY = 'dc3-doc-versions'
const STORAGE_TTL = 10 * 60 * 1000 // 10 minutes
const currentVersion = ref('')
const versions = ref<VersionEntry[]>([])
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const hasMultiple = computed(() => versions.value.length > 1)
function readCurrentVersion() {
const meta = document.querySelector('meta[name="dc3-doc-version"]')
currentVersion.value = meta?.getAttribute('content') || 'dev'
}
async function loadVersions() {
// Check localStorage cache
try {
const cached = localStorage.getItem(STORAGE_KEY)
if (cached) {
const parsed = JSON.parse(cached)
if (Date.now() - parsed.ts < STORAGE_TTL && parsed.data?.versions?.length) {
versions.value = parsed.data.versions
return
}
}
} catch (_) {
// localStorage unavailable or corrupted
}
// Fetch from server
try {
const resp = await fetch('/versions.json', {cache: 'no-cache'})
if (resp.ok) {
const data: VersionsFile = await resp.json()
versions.value = data.versions || []
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ts: Date.now(), data}))
} catch (_) {
// localStorage unavailable
}
}
} catch (_) {
// Network error or file not found — switcher shows badge only
}
}
function toggle() {
open.value = !open.value
}
function close() {
open.value = false
}
function onClickOutside(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) {
close()
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
onMounted(() => {
readCurrentVersion()
loadVersions()
document.addEventListener('click', onClickOutside)
document.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
document.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div ref="root" class="dc3-version-switcher" :class="{open}">
<button
class="version-trigger"
:aria-expanded="open"
aria-haspopup="listbox"
@click="toggle"
>
<svg class="version-icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93C7.05 19.44 4 16.08 4 12c0-.61.08-1.21.21-1.78L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41C17.93 5.78 20 8.65 20 12c0 2.08-.81 3.98-2.1 5.39z"
/>
</svg>
<span class="version-label">{{ currentVersion }}</span>
<svg
class="caret"
:class="{rotated: open}"
viewBox="0 0 24 24"
width="12"
height="12"
aria-hidden="true"
>
<path fill="currentColor" d="M7 10l5 5 5-5z"/>
</svg>
</button>
<Transition name="dc3-dropdown">
<ul v-if="open && hasMultiple" class="version-menu" role="listbox">
<li v-for="v in versions" :key="v.version" role="option">
<a
:href="v.url"
:class="['version-link', {active: v.version === currentVersion}]"
:aria-current="v.version === currentVersion ? 'version' : undefined"
>
<span class="link-label">{{ v.version }}</span>
<span v-if="v.latest" class="latest-badge">Latest</span>
<svg
v-if="v.version === currentVersion"
class="check-icon"
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
>
<path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</a>
</li>
</ul>
</Transition>
</div>
</template>
<style scoped>
.dc3-version-switcher {
position: relative;
display: inline-flex;
align-items: center;
}
.version-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
height: 32px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.version-trigger:hover {
border-color: var(--vp-c-brand-2);
color: var(--vp-c-brand-2);
}
.dc3-version-switcher.open .version-trigger {
border-color: var(--vp-c-brand-2);
color: var(--vp-c-brand-1);
}
.version-icon {
opacity: 0.7;
}
.version-label {
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
.caret {
opacity: 0.5;
transition: transform 0.2s ease;
}
.caret.rotated {
transform: rotate(180deg);
}
.version-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 100;
min-width: 160px;
padding: 6px;
margin: 0;
list-style: none;
background: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.version-link {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-2);
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.version-link:hover {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
}
.version-link.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
}
.link-label {
flex: 1;
font-variant-numeric: tabular-nums;
}
.latest-badge {
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #fff;
background: var(--vp-c-brand-2);
}
.check-icon {
color: var(--vp-c-brand-2);
flex-shrink: 0;
}
/* Dropdown transition */
.dc3-dropdown-enter-active,
.dc3-dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dc3-dropdown-enter-from,
.dc3-dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+5 -1
View File
@@ -30,6 +30,8 @@ import FourLayersDiagram from './components/FourLayersDiagram.vue'
import HeroLogo from './components/HeroLogo.vue'
import HeroParticles from './components/HeroParticles.vue'
import HeroWaves from './components/HeroWaves.vue'
import VersionSwitcher from './components/VersionSwitcher.vue'
import VersionBanner from './components/VersionBanner.vue'
// The language preference is written back to localStorage after internal navigation (including the top language switcher), so the head inline script can keep it on refresh.
function persistLang(path: string) {
@@ -46,8 +48,10 @@ const theme: Theme = {
// Home hero: two background layers (HeroWaves wave dot-matrix at the bottom + full-width converging particles HeroParticles above it, home-hero-before), and the image area is the logo particle animation (home-hero-image)
Layout() {
return h(DefaultTheme.Layout, null, {
'layout-top': () => h(VersionBanner),
'home-hero-before': () => [h(HeroWaves), h(HeroParticles)],
'home-hero-image': () => h(HeroLogo)
'home-hero-image': () => h(HeroLogo),
'nav-bar-content-after': () => h(VersionSwitcher)
})
},
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {readFileSync} from 'node:fs'
import {fileURLToPath} from 'node:url'
import {resolve} from 'node:path'
export interface VersionInfo {
/** Display version, e.g. "2026.5" (trimmed from POM patch version) */
version: string
/** Full version including patch, e.g. "2026.5.22" */
fullVersion: string
/** Whether this build is the latest (main/release branch). Archived builds set DC3_DOCS_IS_LATEST=false */
isLatest: boolean
}
const POM_PATH = resolve(fileURLToPath(new URL('../../pom.xml', import.meta.url)))
/**
* Resolve the documentation version at build time.
*
* Resolution order:
* 1. DC3_DOCS_VERSION env var (set by CI for versioned/archived builds)
* 2. Parse <version> from the parent pom.xml (e.g. "2026.5.22")
* 3. Fallback to "dev"
*
* The display version is trimmed to YYYY.M (patch releases share the same docs).
*/
export function resolveVersion(): VersionInfo {
const envVersion = process.env.DC3_DOCS_VERSION
if (envVersion) {
const parts = envVersion.split('.')
const display = parts.length >= 2 ? parts.slice(0, 2).join('.') : envVersion
return {
version: display,
fullVersion: envVersion,
isLatest: process.env.DC3_DOCS_IS_LATEST !== 'false'
}
}
try {
const pom = readFileSync(POM_PATH, 'utf8')
const match = pom.match(/<version>(\d{4}\.\d+\.\d+)<\/version>/)
if (match) {
const full = match[1]
const parts = full.split('.')
return {
version: `${parts[0]}.${parts[1]}`,
fullVersion: full,
isLatest: true
}
}
} catch (_) {
// POM not found — standalone docs deployment
}
return {version: 'dev', fullVersion: 'dev', isLatest: true}
}
+1 -1
View File
@@ -19,5 +19,5 @@
"medium-zoom": "^1.1.0",
"vitepress": "^1.6.4"
},
"packageManager": "pnpm@11.15.0"
"packageManager": "pnpm@11.17.0"
}
+10
View File
@@ -0,0 +1,10 @@
{
"latest": "2026.5",
"versions": [
{
"version": "2026.5",
"url": "https://docs.dc3.site",
"latest": true
}
]
}
+24
View File
@@ -91,6 +91,9 @@
<!-- Data access -->
<mybatis.plus.version>3.5.16</mybatis.plus.version>
<mybatis.plus.dynamic.version>4.5.0</mybatis.plus.dynamic.version>
<!-- Embedded database for the driver local point-value buffer -->
<sqlite-jdbc.version>3.53.2.0</sqlite-jdbc.version>
<!-- Serialization and data formats -->
<tools.jackson.version>3.1.4</tools.jackson.version>
@@ -121,6 +124,7 @@
<jwt.version>0.13.0</jwt.version>
<bouncycastle.version>1.84</bouncycastle.version>
<micrometer.version>1.16.5</micrometer.version>
<opentelemetry.version>1.45.0</opentelemetry.version>
<!-- Legacy integration -->
<j.interop.version>2.0.4</j.interop.version>
@@ -285,6 +289,19 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- OpenTelemetry BOM -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>${opentelemetry.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-semconv</artifactId>
<version>${opentelemetry.version}-alpha</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-bom</artifactId>
@@ -293,6 +310,13 @@
<scope>import</scope>
</dependency>
<!-- Embedded database for the driver local point-value buffer -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>${sqlite-jdbc.version}</version>
</dependency>
<!-- Test toolchain -->
<dependency>
<groupId>org.assertj</groupId>