feat(driver)!: enforce lease-fenced durable telemetry

Use PostgreSQL leases and fencing for distributed ownership, require a durable SQLite outbox before RabbitMQ publication, and make Data Center ingestion transactional and idempotent.

BREAKING CHANGE: drivers require mandatory durable outbox configuration and use lease-aware ownership and telemetry contracts.
This commit is contained in:
pnoker
2026-08-18 00:50:57 +08:00
parent 044eafa2fa
commit 956de3dd38
136 changed files with 3478 additions and 1594 deletions
+14 -2
View File
@@ -84,8 +84,20 @@ MQTT_BATCH_SPEED=100
MQTT_BATCH_INTERVAL=5
# Point processing defaults
POINT_BATCH_SPEED=100
POINT_BATCH_INTERVAL=5
POINT_BATCH_SIZE=500
POINT_BATCH_RECEIVE_TIMEOUT_MILLIS=100
POINT_CONCURRENT_CONSUMERS=4
POINT_MAX_CONCURRENT_CONSUMERS=16
POINT_PREFETCH_COUNT=1000
POINT_RETRY_MAX_RETRIES=3
POINT_RETRY_INITIAL_INTERVAL_MILLIS=1000
POINT_RETRY_MULTIPLIER=2
POINT_RETRY_MAX_INTERVAL_MILLIS=10000
# Distributed driver runtime lease (PostgreSQL-backed, no Redis)
DC3_DRIVER_LEASE_SECONDS=30
DC3_DRIVER_LEASE_RENEW_CRON=0/10 * * * * ?
DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS=300000
# Optional EMQX stack published ports
DC3_EMQX_WS_PORT=38083
@@ -37,6 +37,9 @@ service DriverApi {
// Query the current registered driver metadata without re-registering it
rpc GetById (GrpcDriverQuery) returns (GrpcRDriverRegisterDTO);
// Renew this runtime instance and stream a bounded snapshot of the devices it owns.
rpc RenewLease (GrpcDriverLeaseRequest) returns (stream GrpcRDriverLeaseDTO);
}
// Driver registration response structure
@@ -47,8 +50,7 @@ message GrpcRDriverRegisterDTO {
// Basic driver information
GrpcDriverDTO driver = 2;
// List of device IDs managed by the driver
repeated int64 device_ids = 3;
reserved 3, 8, 9, 10;
// List of driver attribute configurations
repeated GrpcDriverAttributeDTO driver_attributes = 4;
@@ -61,4 +63,16 @@ message GrpcRDriverRegisterDTO {
// List of event attribute configurations supported by the driver
repeated GrpcEventAttributeDTO event_attributes = 7;
}
message GrpcRDriverLeaseDTO {
GrpcR result = 1;
repeated GrpcDeviceLeaseDTO device_leases = 2;
int64 lease_until_epoch_millis = 3;
int64 assignment_version = 4;
bool assignments_changed = 5;
// True only on the final batch. Changed assignments are installed atomically
// by the client after this marker and normal stream completion.
bool snapshot_complete = 6;
}
@@ -49,4 +49,27 @@ message GrpcDriverRegisterDTO {
// List of event configuration parameters supported by the driver
repeated GrpcEventAttributeDTO event_attributes = 7;
// Stable identity of this runtime process. Driver definitions are shared;
// runtime nodes are independently leased.
string node = 8;
// Requested instance lease duration in seconds.
int32 lease_seconds = 9;
}
message GrpcDeviceLeaseDTO {
int64 device_id = 1;
int64 fencing_token = 2;
}
message GrpcDriverLeaseRequest {
int64 tenant_id = 1;
int64 driver_id = 2;
string node = 3;
string client = 4;
string host = 5;
int32 lease_seconds = 6;
// Assignment version currently installed in this driver process.
int64 assignment_version = 7;
}
@@ -43,6 +43,8 @@ service DeviceApi {
rpc GetByDeviceId (GrpcDeviceQuery) returns (GrpcRDeviceDTO);
// Batch query device information by Device IDs
rpc ListByDeviceIds (GrpcDeviceIdsQuery) returns (GrpcRDeviceListDTO);
// Resolve the active runtime owner used for fenced command routing.
rpc GetActiveOwner (GrpcDeviceQuery) returns (GrpcRDeviceOwnerDTO);
}
// Response wrapper for paginated device query
@@ -80,3 +82,10 @@ message GrpcRDeviceDTO {
// Returned single device data
GrpcDeviceDTO data = 2;
}
message GrpcRDeviceOwnerDTO {
GrpcR result = 1;
int64 driver_id = 2;
string owner_node = 3;
int64 fencing_token = 4;
}
@@ -34,8 +34,15 @@ dc3:
data:
point:
batch:
speed: ${POINT_BATCH_SPEED:100}
interval: ${POINT_BATCH_INTERVAL:5}
batch-size: ${POINT_BATCH_SIZE:500}
receive-timeout-millis: ${POINT_BATCH_RECEIVE_TIMEOUT_MILLIS:100}
concurrent-consumers: ${POINT_CONCURRENT_CONSUMERS:4}
max-concurrent-consumers: ${POINT_MAX_CONCURRENT_CONSUMERS:16}
prefetch-count: ${POINT_PREFETCH_COUNT:1000}
max-retries: ${POINT_RETRY_MAX_RETRIES:3}
retry-initial-interval-millis: ${POINT_RETRY_INITIAL_INTERVAL_MILLIS:1000}
retry-multiplier: ${POINT_RETRY_MULTIPLIER:2}
retry-max-interval-millis: ${POINT_RETRY_MAX_INTERVAL_MILLIS:10000}
facade:
mode: ${DC3_FACADE_MODE:grpc}
@@ -34,8 +34,15 @@ dc3:
data:
point:
batch:
speed: ${POINT_BATCH_SPEED:100}
interval: ${POINT_BATCH_INTERVAL:5}
batch-size: ${POINT_BATCH_SIZE:500}
receive-timeout-millis: ${POINT_BATCH_RECEIVE_TIMEOUT_MILLIS:100}
concurrent-consumers: ${POINT_CONCURRENT_CONSUMERS:4}
max-concurrent-consumers: ${POINT_MAX_CONCURRENT_CONSUMERS:16}
prefetch-count: ${POINT_PREFETCH_COUNT:1000}
max-retries: ${POINT_RETRY_MAX_RETRIES:3}
retry-initial-interval-millis: ${POINT_RETRY_INITIAL_INTERVAL_MILLIS:1000}
retry-multiplier: ${POINT_RETRY_MULTIPLIER:2}
retry-max-interval-millis: ${POINT_RETRY_MAX_INTERVAL_MILLIS:10000}
facade:
mode: ${DC3_FACADE_MODE:local}
+1 -1
View File
@@ -38,7 +38,7 @@
<dependencies>
<!-- Local cache for token denylist (replaces Redis) -->
<!-- Process-local cache for the token denylist -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
@@ -31,12 +31,11 @@ import java.util.concurrent.TimeUnit;
* In-memory denylist of cancelled tokens, keyed by (loginName, tenantCode).
*
* <p>
* The platform replaced Redis with Caffeine for shared caches; this component keeps the
* same model for token revocation. We do not store the full JWT — instead we record the
* UTC milliseconds at which a user logged out. Any token whose {@code issuedAt} predates
* that timestamp is treated as cancelled. One logout therefore invalidates every active
* token for that login (which is the safer behaviour when the same account is used from
* multiple devices).
* This component is an instance-local, short-lived token revocation cache. We do not store
* the full JWT — instead we record the UTC milliseconds at which a user logged out. Any
* token whose {@code issuedAt} predates that timestamp is treated as cancelled by this
* application instance. One logout therefore invalidates every active token for that login
* on the same instance.
* </p>
*
* <p>
@@ -59,6 +59,9 @@ public class ScheduleConstant {
*/
public static final String BUFFER_REPUBLISH_SCHEDULE_JOB = "buffer-republish-schedule-job";
/** Driver runtime lease renewal job. */
public static final String DRIVER_LEASE_RENEW_SCHEDULE_JOB = "driver-lease-renew-schedule-job";
/**
* Driver health schedule cron
*/
+41 -7
View File
@@ -3,8 +3,9 @@
## Overview
`dc3-common-data` is the shared Data Center business module of the IoT DC3 platform. It provides all service
implementations for point value ingestion, command dispatch, driver/device status tracking, and data query. It is wired
into `dc3-center-data`.
implementations for point-value ingestion, owner-directed command dispatch, driver/device status tracking, and data
query. It is wired into `dc3-center-data`. PostgreSQL/TimescaleDB and RabbitMQ provide the shared state and buffering;
Redis is not required.
## Module Information
@@ -26,26 +27,59 @@ into `dc3-center-data`.
```
REST /api/v3/data/point_value/read
→ PointCommandServiceImpl
→ DriverFacade.getByDeviceId(tenantId, deviceId)
→ RabbitMQ: dc3.e.point_command / dc3.r.point_command.{serviceName}
Driver receives and acts
→ DeviceFacade.getActiveOwner(tenantId, deviceId)
→ RabbitMQ publisher confirm: dc3.e.point_command / dc3.r.point_command.{serviceName}.{ownerNode}
Only the leased owner accepts the matching fencing token
```
Custom device commands follow the parallel `dc3.e.command` / `dc3.r.command.{serviceName}` route through
`CommandHistoryServiceImpl`.
## Point-Value Ingestion Flow
```
driver SQLite outbox
→ RabbitMQ dc3.e.value / dc3.q.value.point
→ bounded broker-side consumer batch
→ validate the complete wire schema
→ one PostgreSQL transaction
→ reject expired node or stale fencing token
→ insert history with replay conflict ignored
→ upsert shared latest value using fence/time/sequence ordering
→ manual RabbitMQ ACK only after transaction commit
```
Every Data Center replica consumes from the same queue and uses the same PostgreSQL history/latest tables. There is no
process-local latest-value source of truth. Concurrent batches sort keys before persistence to keep lock ordering stable.
Malformed messages and batches that exhaust transient retries are rejected to the configured dead-letter path.
## MQ Topics
| Exchange | Queue or routing key | Direction |
|-----------------------|-------------------------------------|-----------------------------|
| `dc3.e.value` | `dc3.q.value.point` | Inbound point values |
| `dc3.e.point_command` | `dc3.r.point_command.{service}` | Outbound point read/write |
| `dc3.e.command` | `dc3.r.command.{service}` | Outbound custom commands |
| `dc3.e.point_command` | `dc3.r.point_command.{service}.{node}` | Outbound point read/write |
| `dc3.e.command` | `dc3.r.command.{service}.{node}` | Outbound custom commands |
| `dc3.e.state` | `dc3.q.state.driver` / `dc3.q.state.device` | Inbound driver/device state |
| `dc3.e.event` | `dc3.q.event.report` | Inbound reported events |
The optional `dc3.rabbit.tag` system property prefixes runtime names; `RabbitConstant` remains authoritative.
## Ingestion Settings
| Property | Default | Meaning |
|---|---:|---|
| `dc3.data.point.batch.batch-size` | `500` | Maximum database transaction batch |
| `dc3.data.point.batch.receive-timeout-millis` | `100` | Maximum wait to fill a consumer batch |
| `dc3.data.point.batch.concurrent-consumers` | `4` | Initial Data Center consumer count |
| `dc3.data.point.batch.max-concurrent-consumers` | `16` | Elastic consumer ceiling |
| `dc3.data.point.batch.prefetch-count` | `1000` | Per-consumer broker prefetch |
| `dc3.data.point.batch.max-retries` | `3` | Transient batch attempts before dead-letter rejection |
Scale the consumer count only after PostgreSQL commit latency, connection-pool capacity, RabbitMQ unacked count, and
dead-letter rate are observable. The effective in-flight upper bound is approximately `consumers × prefetch`; it should
remain below the memory and recovery budget of a Data Center replica.
## Build Instructions
```bash
@@ -26,16 +26,12 @@ import io.github.pnoker.common.data.dal.CommandHistoryManager;
import io.github.pnoker.common.data.entity.bo.CommandCallBO;
import io.github.pnoker.common.data.entity.builder.CommandHistoryBuilder;
import io.github.pnoker.common.data.entity.model.CommandHistoryDO;
import io.github.pnoker.common.data.entity.model.EntityStateDO;
import io.github.pnoker.common.data.entity.vo.CommandHistoryQueryVO;
import io.github.pnoker.common.data.entity.vo.CommandHistoryVO;
import io.github.pnoker.common.data.mapper.EntityStateMapper;
import io.github.pnoker.common.entity.common.Pages;
import io.github.pnoker.common.entity.dto.CommandCallDTO;
import io.github.pnoker.common.enums.CommandHistorySourceEnum;
import io.github.pnoker.common.enums.EnableFlagEnum;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.enums.EntityTypeEnum;
import io.github.pnoker.common.enums.PointCommandStatusEnum;
import io.github.pnoker.common.exception.NotFoundException;
import io.github.pnoker.common.exception.ServiceException;
@@ -45,10 +41,12 @@ import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.api.DriverFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeCommandBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO;
import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeCommandQuery;
import io.github.pnoker.common.utils.JsonUtil;
import io.github.pnoker.common.utils.RabbitPublishConfirm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -57,6 +55,7 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
@@ -75,10 +74,6 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
private static final int DEFAULT_COMMAND_TIMEOUT_SECONDS = 30;
private static final int LEGACY_MILLISECONDS_THRESHOLD = 1000;
private static final int MILLISECONDS_PER_SECOND = 1000;
private final DeviceFacade deviceFacade;
private final DriverFacade driverFacade;
@@ -91,8 +86,6 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
private final CommandHistoryBuilder commandHistoryBuilder;
private final EntityStateMapper entityStateMapper;
@Override
public String call(Long tenantId, CommandCallBO entityBO) {
FacadeCommandBO command = validateCommandScope(tenantId, entityBO.getDeviceId(), entityBO.getCommandId(),
@@ -103,7 +96,7 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
if (Objects.isNull(driver)) {
throw new ServiceException("No driver registered for this device");
}
checkDriverOnline(tenantId, driver.getId());
FacadeDeviceOwnerBO owner = requireActiveOwner(tenantId, entityBO.getDeviceId(), driver.getId());
int timeoutSeconds = resolveCommandTimeout(command);
@@ -125,9 +118,11 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
recordDO.setSchemaVersion((short) 1);
commandHistoryManager.save(recordDO);
publishCommand(CommandCallDTO.builder()
CommandCallDTO commandDTO = CommandCallDTO.builder()
.recordId(recordId)
.tenantId(tenantId)
.ownerNode(owner.ownerNode())
.fencingToken(owner.fencingToken())
.deviceId(entityBO.getDeviceId())
.commandId(commandId)
.commandCode(command.getCommandCode())
@@ -136,7 +131,13 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
.occurredAt(now)
.expireAt(now.plusSeconds(timeoutSeconds))
.schemaVersion(1)
.build(), driver.getServiceName(), recordId);
.build();
try {
publishCommand(commandDTO, driver.getServiceName(), owner.ownerNode(), recordId);
} catch (Exception e) {
markPublishFailed(recordDO, e);
throw new ServiceException("Failed to route custom command to active driver owner", e);
}
recordDO.setStatus(PointCommandStatusEnum.SENT);
recordDO.setSendTime(LocalDateTime.now());
@@ -168,24 +169,6 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
return commandHistoryBuilder.buildVOPageByDOPage(page);
}
/**
* Verify the driver serving the command is online, throwing {@link ServiceException}
* when no online state exists for the driver.
*
* @param tenantId tenant scope
* @param driverId the driver to check
*/
private void checkDriverOnline(Long tenantId, Long driverId) {
EntityStateDO driverState = entityStateMapper.selectOne(
new LambdaQueryWrapper<EntityStateDO>()
.eq(EntityStateDO::getTenantId, tenantId)
.eq(EntityStateDO::getEntityTypeFlag, EntityTypeEnum.DRIVER.getIndex())
.eq(EntityStateDO::getEntityId, driverId));
if (Objects.isNull(driverState) || !EntityStatusEnum.ONLINE.getIndex().equals(driverState.getStateFlag())) {
throw new ServiceException("Driver is offline");
}
}
/**
* Validate the device exists and is enabled within the tenant, then resolve and
* validate the command, requiring the command share the device's profile.
@@ -251,8 +234,7 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
}
/**
* Resolve the command timeout in seconds. Falls back to the default when missing or
* non-positive, and interprets legacy millisecond values as seconds.
* Resolve the command timeout in seconds. The model has one unit only: seconds.
*
* @param command the command carrying the raw timeout
* @return the timeout in seconds
@@ -261,14 +243,7 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
if (Objects.isNull(command) || Objects.isNull(command.getTimeout()) || command.getTimeout() <= 0) {
return DEFAULT_COMMAND_TIMEOUT_SECONDS;
}
int timeout = command.getTimeout();
if (timeout >= LEGACY_MILLISECONDS_THRESHOLD && timeout % MILLISECONDS_PER_SECOND == 0) {
int timeoutSeconds = timeout / MILLISECONDS_PER_SECOND;
log.warn("Interpreting command timeout as legacy milliseconds: commandId={}, rawTimeout={}, timeoutSeconds={}",
command.getId(), timeout, timeoutSeconds);
return timeoutSeconds;
}
return timeout;
return command.getTimeout();
}
/**
@@ -278,10 +253,29 @@ public class CommandHistoryServiceImpl implements CommandHistoryService {
* @param serviceName the target driver's service name
* @param recordId the command history record id, used as the correlation id
*/
private void publishCommand(CommandCallDTO dto, String serviceName, String recordId) {
private void publishCommand(CommandCallDTO dto, String serviceName, String ownerNode, String recordId) {
CorrelationData correlationData = new CorrelationData(recordId);
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_COMMAND,
RabbitConstant.ROUTING_COMMAND_PREFIX + serviceName, dto, correlationData);
RabbitConstant.ROUTING_COMMAND_PREFIX + serviceName + "." + ownerNode, dto, correlationData);
RabbitPublishConfirm.awaitRouted(correlationData, Duration.ofSeconds(5));
}
private void markPublishFailed(CommandHistoryDO recordDO, Exception cause) {
recordDO.setStatus(PointCommandStatusEnum.FAILED);
recordDO.setErrorCode("BROKER_PUBLISH_FAILED");
recordDO.setErrorMessage(cause.getMessage());
recordDO.setFinishTime(LocalDateTime.now());
commandHistoryManager.updateById(recordDO);
}
private FacadeDeviceOwnerBO requireActiveOwner(Long tenantId, Long deviceId, Long driverId) {
FacadeDeviceOwnerBO owner = deviceFacade.getActiveOwner(tenantId, deviceId);
if (Objects.isNull(owner) || !Objects.equals(owner.driverId(), driverId)
|| Objects.isNull(owner.ownerNode()) || owner.ownerNode().isBlank()
|| Objects.isNull(owner.fencingToken()) || owner.fencingToken() <= 0) {
throw new ServiceException("Device has no active driver owner");
}
return owner;
}
}
@@ -27,16 +27,12 @@ import io.github.pnoker.common.data.dal.PointCommandHistoryManager;
import io.github.pnoker.common.data.entity.bo.PointCommandReadBO;
import io.github.pnoker.common.data.entity.bo.PointCommandWriteBO;
import io.github.pnoker.common.data.entity.builder.PointCommandHistoryBuilder;
import io.github.pnoker.common.data.entity.model.EntityStateDO;
import io.github.pnoker.common.data.entity.model.PointCommandHistoryDO;
import io.github.pnoker.common.data.entity.vo.PointCommandHistoryQueryVO;
import io.github.pnoker.common.data.entity.vo.PointCommandHistoryVO;
import io.github.pnoker.common.data.mapper.EntityStateMapper;
import io.github.pnoker.common.data.validator.PointCommandValidator;
import io.github.pnoker.common.entity.dto.PointCommandDTO;
import io.github.pnoker.common.enums.EnableFlagEnum;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.enums.EntityTypeEnum;
import io.github.pnoker.common.enums.PointCommandSourceEnum;
import io.github.pnoker.common.enums.PointCommandStatusEnum;
import io.github.pnoker.common.enums.PointCommandTypeEnum;
@@ -48,8 +44,10 @@ import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.api.DriverFacade;
import io.github.pnoker.common.facade.api.PointFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO;
import io.github.pnoker.common.facade.entity.bo.FacadePointBO;
import io.github.pnoker.common.utils.RabbitPublishConfirm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
@@ -57,6 +55,7 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
@@ -88,8 +87,6 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
private final PointCommandHistoryBuilder pointCommandHistoryBuilder;
private final EntityStateMapper entityStateMapper;
private final PointCommandValidator pointCommandValidator;
@Override
@@ -106,7 +103,7 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
if (Objects.isNull(driver)) {
throw new ServiceException("No driver registered for this device");
}
checkDriverOnline(tenantId, driver.getId());
FacadeDeviceOwnerBO owner = requireActiveOwner(tenantId, entityBO.getDeviceId(), driver.getId());
String commandId = resolveCommandId(entityBO.getCommandId());
LocalDateTime nowLocal = LocalDateTime.now();
@@ -124,8 +121,13 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
commandDO.setSchemaVersion((short) 1);
pointCommandHistoryManager.save(commandDO);
publishCommand(PointCommandDTO.ofRead(commandId, tenantId, entityBO.getDeviceId(),
entityBO.getPointId()), driver.getServiceName(), commandId);
try {
publishCommand(PointCommandDTO.ofRead(commandId, tenantId, owner.ownerNode(), owner.fencingToken(),
entityBO.getDeviceId(), entityBO.getPointId()), driver.getServiceName(), owner.ownerNode(), commandId);
} catch (Exception e) {
markPublishFailed(commandDO, e);
throw new ServiceException("Failed to route point command to active driver owner", e);
}
commandDO.setStatus(PointCommandStatusEnum.SENT);
commandDO.setSendTime(LocalDateTime.now());
@@ -148,7 +150,7 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
if (Objects.isNull(driver)) {
throw new ServiceException("No driver registered for this device");
}
checkDriverOnline(tenantId, driver.getId());
FacadeDeviceOwnerBO owner = requireActiveOwner(tenantId, entityBO.getDeviceId(), driver.getId());
pointCommandValidator.validateWriteValue(entityBO.getValue());
@@ -169,8 +171,14 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
commandDO.setSchemaVersion((short) 1);
pointCommandHistoryManager.save(commandDO);
publishCommand(PointCommandDTO.ofWrite(commandId, tenantId, entityBO.getDeviceId(),
entityBO.getPointId(), entityBO.getValue()), driver.getServiceName(), commandId);
try {
publishCommand(PointCommandDTO.ofWrite(commandId, tenantId, owner.ownerNode(), owner.fencingToken(),
entityBO.getDeviceId(), entityBO.getPointId(), entityBO.getValue()),
driver.getServiceName(), owner.ownerNode(), commandId);
} catch (Exception e) {
markPublishFailed(commandDO, e);
throw new ServiceException("Failed to route point command to active driver owner", e);
}
commandDO.setStatus(PointCommandStatusEnum.SENT);
commandDO.setSendTime(LocalDateTime.now());
@@ -201,24 +209,6 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
return pointCommandHistoryBuilder.buildVOPageByDOPage(page);
}
/**
* Verify the driver serving the command is online, throwing {@link ServiceException}
* when no online state exists for the driver.
*
* @param tenantId tenant scope
* @param driverId the driver to check
*/
private void checkDriverOnline(Long tenantId, Long driverId) {
EntityStateDO driverState = entityStateMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<EntityStateDO>()
.eq(EntityStateDO::getTenantId, tenantId)
.eq(EntityStateDO::getEntityTypeFlag, EntityTypeEnum.DRIVER.getIndex())
.eq(EntityStateDO::getEntityId, driverId));
if (Objects.isNull(driverState) || !EntityStatusEnum.ONLINE.getIndex().equals(driverState.getStateFlag())) {
throw new ServiceException("Driver is offline");
}
}
/**
* Validate the device and point exist within the tenant, are enabled, and share a
* profile.
@@ -291,10 +281,29 @@ public class PointCommandServiceImpl implements PointCommandService, PointComman
/**
* Publish a point command DTO to the driver via RabbitMQ.
*/
private void publishCommand(PointCommandDTO dto, String serviceName, String commandId) {
private void publishCommand(PointCommandDTO dto, String serviceName, String ownerNode, String commandId) {
CorrelationData correlationData = new CorrelationData(commandId);
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND,
RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + serviceName, dto, correlationData);
RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + serviceName + "." + ownerNode, dto, correlationData);
RabbitPublishConfirm.awaitRouted(correlationData, Duration.ofSeconds(5));
}
private void markPublishFailed(PointCommandHistoryDO commandDO, Exception cause) {
commandDO.setStatus(PointCommandStatusEnum.FAILED);
commandDO.setErrorCode("BROKER_PUBLISH_FAILED");
commandDO.setErrorMessage(cause.getMessage());
commandDO.setFinishTime(LocalDateTime.now());
pointCommandHistoryManager.updateById(commandDO);
}
private FacadeDeviceOwnerBO requireActiveOwner(Long tenantId, Long deviceId, Long driverId) {
FacadeDeviceOwnerBO owner = deviceFacade.getActiveOwner(tenantId, deviceId);
if (Objects.isNull(owner) || !Objects.equals(owner.driverId(), driverId)
|| Objects.isNull(owner.ownerNode()) || owner.ownerNode().isBlank()
|| Objects.isNull(owner.fencingToken()) || owner.fencingToken() <= 0) {
throw new ServiceException("Device has no active driver owner");
}
return owner;
}
}
@@ -21,7 +21,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.pnoker.common.constant.service.DataConstant;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.data.biz.alarm.AlarmRuleTriggerService;
import io.github.pnoker.common.data.cache.PointValueLocalCache;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.entity.common.Pages;
import io.github.pnoker.common.entity.query.PointValueQuery;
@@ -40,7 +39,6 @@ import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.ListUtils;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -48,7 +46,6 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Business service implementation for point value operations.
@@ -66,8 +63,6 @@ public class PointValueServiceImpl implements PointValueService {
private final DeviceFacade deviceFacade;
private final PointValueLocalCache pointValueLocalCacheService;
private final AlarmRuleTriggerService alarmRuleTriggerService;
@Override
@@ -83,8 +78,15 @@ public class PointValueServiceImpl implements PointValueService {
pointValueBO.setCreateTime(LocalDateTimeUtil.now());
}
pointValueBO.setOperateTime(LocalDateTimeUtil.now());
savePointValueToRepository(pointValueBO);
alarmRuleTriggerService.processPointValue(pointValueBO);
if (!persistPointValue(pointValueBO)) {
return;
}
try {
alarmRuleTriggerService.processPointValue(pointValueBO);
} catch (Exception e) {
log.warn("Alarm rule evaluation failed after point persistence, messageId={}",
pointValueBO.getMessageId(), e);
}
}
@Override
@@ -93,23 +95,29 @@ public class PointValueServiceImpl implements PointValueService {
return;
}
final Map<Long, List<PointValueBO>> group = pointValueBOList.stream().map(pointValue -> {
final java.time.LocalDateTime operateTime = LocalDateTimeUtil.now();
pointValueBOList.forEach(pointValue -> {
if (Objects.isNull(pointValue.getCreateTime())) {
pointValue.setCreateTime(LocalDateTimeUtil.now());
}
// See single-row save() — operate_time is the persistence
// timestamp, not a mirror of create_time.
pointValue.setOperateTime(LocalDateTimeUtil.now());
return pointValue;
}).collect(Collectors.groupingBy(PointValueBO::getDeviceId));
pointValue.setOperateTime(operateTime);
});
group.forEach(this::savePointValuesToRepository);
List<PointValueBO> acceptedValues = persistPointValues(pointValueBOList);
int rejected = pointValueBOList.size() - acceptedValues.size();
if (rejected > 0) {
log.warn("Point-value batch contained replayed or stale-owner events, received={}, accepted={}, rejected={}",
pointValueBOList.size(), acceptedValues.size(), rejected);
}
if (acceptedValues.isEmpty()) {
return;
}
try {
alarmRuleTriggerService.processPointValues(pointValueBOList);
alarmRuleTriggerService.processPointValues(acceptedValues);
} 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);
log.warn("Alarm rule evaluation failed, size={}, skipped", acceptedValues.size(), e);
}
}
@@ -156,23 +164,13 @@ public class PointValueServiceImpl implements PointValueService {
}
Long tenantId = entityQuery.getTenantId();
Map<Long, PointValueBO> pointValueBOMap = pointValueLocalCacheService.selectLatestPointValue(tenantId,
entityQuery.getDeviceId(), pointIds);
RepositoryService repositoryService = getFirstRepositoryService();
// Collect point IDs not found in the local cache and batch-query the repository
List<Long> missingIds = pointIds.stream()
.filter(id -> !pointValueBOMap.containsKey(id))
.toList();
if (!missingIds.isEmpty()) {
List<PointValueBO> dbResults = repositoryService.listLatestPointValues(tenantId,
entityQuery.getDeviceId(), missingIds);
for (PointValueBO bo : dbResults) {
if (Objects.nonNull(bo) && Objects.nonNull(bo.getPointId())) {
pointValueBOMap.put(bo.getPointId(), bo);
}
}
}
Map<Long, PointValueBO> pointValueBOMap = repositoryService
.listLatestPointValues(tenantId, entityQuery.getDeviceId(), pointIds)
.stream()
.filter(Objects::nonNull)
.filter(value -> Objects.nonNull(value.getPointId()))
.collect(java.util.stream.Collectors.toMap(PointValueBO::getPointId, value -> value));
// Build the final list maintaining the original pointIds order
List<PointValueBO> pointValueBOList = pointIds.stream().map(id -> {
@@ -208,48 +206,6 @@ public class PointValueServiceImpl implements PointValueService {
return repositoryService.listPagePointValue(entityQuery);
}
/**
* Save PointValue to the specified storage service
*
* @param pointValueBO PointValue
*/
private void savePointValueToRepository(PointValueBO pointValueBO) {
try {
// local hot cache
pointValueLocalCacheService.savePointValue(pointValueBO);
// other repository
RepositoryService repositoryService = getFirstRepositoryService();
repositoryService.savePointValue(pointValueBO);
} catch (Exception e) {
log.error("Save point value failed, tenantId={}, deviceId={}, pointId={}", pointValueBO.getTenantId(),
pointValueBO.getDeviceId(), pointValueBO.getPointId(), e);
}
}
/**
* Save PointValues to the specified storage service
*
* @param deviceId Device ID
* @param pointValueBOList Array
*/
private void savePointValuesToRepository(Long deviceId, List<PointValueBO> pointValueBOList) {
try {
// local hot cache
pointValueLocalCacheService.savePointValue(deviceId, pointValueBOList);
// 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) {
throw new RepositoryException(e);
}
}
/**
* Validate that the device and point exist within the tenant and that the point
* belongs to the device's profile. A no-op when {@code tenantId} is null; throws
@@ -296,6 +252,22 @@ public class PointValueServiceImpl implements PointValueService {
return Objects.nonNull(id) && id > 0;
}
private boolean persistPointValue(PointValueBO pointValueBO) {
try {
return getFirstRepositoryService().savePointValue(pointValueBO);
} catch (Exception e) {
throw new RepositoryException(e);
}
}
private List<PointValueBO> persistPointValues(List<PointValueBO> pointValueBOList) {
try {
return getFirstRepositoryService().savePointValues(pointValueBOList);
} catch (Exception e) {
throw new RepositoryException(e);
}
}
/**
* Build a placeholder point value indicating no latest value is available.
*
@@ -29,9 +29,8 @@ 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.
* <p>Point-value ingestion no longer has a Quartz tick here; RabbitMQ consumer batches drive
* persistence directly. Only the hourly maintenance job remains.
*
* @author pnoker
* @version 2026.7.8
@@ -30,7 +30,6 @@ import io.github.pnoker.common.entity.bo.WindowAggregateResult;
import io.github.pnoker.common.entity.common.Pages;
import io.github.pnoker.common.entity.query.PointValueQuery;
import io.github.pnoker.common.entity.query.WindowAggregateQuery;
import io.github.pnoker.common.exception.AddException;
import io.github.pnoker.common.repository.RepositoryService;
import io.github.pnoker.common.strategy.RepositoryStrategyFactory;
import io.github.pnoker.common.utils.FieldUtil;
@@ -40,10 +39,15 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* PostgreSQL-based repository service implementation for point value persistence.
@@ -57,6 +61,15 @@ import java.util.Objects;
@RequiredArgsConstructor
public class PostgresRepositoryServiceImpl implements RepositoryService, InitializingBean {
private static final Comparator<PointValueDO> INGEST_ORDER = Comparator
.comparing(PointValueDO::getTenantId, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getDeviceId, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getPointId, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getFencingToken, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getCreateTime, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getSequence, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(PointValueDO::getMessageId, Comparator.nullsFirst(Comparator.naturalOrder()));
private final PointValueBuilder pointValueBuilder;
private final PointValueManager pointValueManager;
@@ -69,19 +82,36 @@ public class PostgresRepositoryServiceImpl implements RepositoryService, Initial
}
@Override
public void savePointValue(PointValueBO entityBO) {
PointValueDO entityDO = pointValueBuilder.buildDOByBO(entityBO);
if (!pointValueManager.save(entityDO)) {
throw new AddException("Failed to create point value");
}
public boolean savePointValue(PointValueBO entityBO) {
return !savePointValues(List.of(entityBO)).isEmpty();
}
@Override
public void savePointValues(List<PointValueBO> entityBOList) {
List<PointValueDO> entityDOList = pointValueBuilder.buildDOListByBOList(entityBOList);
if (!pointValueManager.saveBatch(entityDOList)) {
throw new AddException("Failed to create point value list");
@Transactional(rollbackFor = Exception.class)
public List<PointValueBO> savePointValues(List<PointValueBO> entityBOList) {
if (Objects.isNull(entityBOList) || entityBOList.isEmpty()) {
return List.of();
}
// Every concurrent consumer acquires lease rows and latest-value keys in the
// same order. This prevents opposite RabbitMQ batch orders from creating a
// PostgreSQL deadlock under high ingest concurrency.
List<PointValueDO> entityDOList = pointValueBuilder.buildDOListByBOList(entityBOList).stream()
.sorted(INGEST_ORDER)
.toList();
Set<String> insertedIds = new java.util.HashSet<>(pointValueMapper.insertHistoryBatch(entityDOList));
if (insertedIds.isEmpty()) {
return List.of();
}
Map<String, PointValueDO> acceptedById = entityDOList.stream()
.filter(value -> insertedIds.contains(value.getMessageId()))
.collect(java.util.stream.Collectors.toMap(PointValueDO::getMessageId, value -> value,
(first, duplicate) -> first, LinkedHashMap::new));
List<PointValueDO> acceptedDOs = List.copyOf(acceptedById.values());
pointValueMapper.upsertLatestBatch(acceptedDOs);
Set<String> returnedIds = new java.util.HashSet<>();
return entityBOList.stream()
.filter(value -> insertedIds.contains(value.getMessageId()) && returnedIds.add(value.getMessageId()))
.toList();
}
@Override
@@ -102,15 +132,8 @@ public class PostgresRepositoryServiceImpl implements RepositoryService, Initial
@Override
public PointValueBO selectLatestPointValue(Long tenantId, Long deviceId, Long pointId) {
LambdaQueryWrapper<PointValueDO> wrapper = Wrappers.<PointValueDO>query().lambda();
wrapper.eq(PointValueDO::getTenantId, tenantId);
wrapper.eq(PointValueDO::getDeviceId, deviceId);
wrapper.eq(PointValueDO::getPointId, pointId);
wrapper.orderByDesc(PointValueDO::getCreateTime);
wrapper.last("limit 1");
PointValueDO entityDO = pointValueManager.getOne(wrapper);
return pointValueBuilder.buildBOByDO(entityDO);
List<PointValueBO> values = listLatestPointValues(tenantId, deviceId, List.of(pointId));
return values.isEmpty() ? null : values.getFirst();
}
@Override
@@ -1,191 +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.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 final AtomicLong droppedCount = new AtomicLong(0);
private ArrayBlockingQueue<PointValueBO> queue;
private ExecutorService worker;
private volatile boolean running;
/**
* 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);
}
}
}
}
@@ -33,9 +33,9 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
/**
* Thin Caffeine wrapper with a Redis-like surface (setKey/getKey with optional TTL, batch
* variants) so call sites only need to swap the injected type. Variable per-entry TTL is
* honored via a custom {@link Expiry}.
* Thin Caffeine wrapper for process-local, TTL-bound operational state. Durable telemetry,
* latest point values, and distributed driver ownership never use this cache. Variable
* per-entry TTL is honored via a custom {@link Expiry}.
*
* <p>
* Exposes {@link #onExpire(ExpireListener)} so callers can react when an entry is evicted
@@ -1,88 +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.cache;
import io.github.pnoker.common.constant.common.PrefixConstant;
import io.github.pnoker.common.constant.common.SymbolConstant;
import io.github.pnoker.common.entity.bo.PointValueBO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Local in-process hot cache for the latest point values.
* <p>
* Replaces the previous Redis-backed repository layer with a Caffeine cache kept inside
* the data service JVM. Misses fall through to the underlying time-series repository,
* exactly as the Redis version did.
* </p>
*
* @author pnoker
* @version 2025.9.0
* @since 2016.10.1
*/
@Service
@RequiredArgsConstructor
public class PointValueLocalCache {
private final LocalCacheImpl localCacheService;
public void savePointValue(PointValueBO entityBO) {
if (Objects.isNull(entityBO.getTenantId()) || Objects.isNull(entityBO.getDeviceId())
|| Objects.isNull(entityBO.getPointId())) {
return;
}
String key = buildKey(entityBO.getTenantId(), entityBO.getDeviceId(), entityBO.getPointId());
localCacheService.setKey(key, entityBO);
}
public void savePointValue(Long deviceId, List<PointValueBO> entityBOList) {
if (Objects.isNull(deviceId) || CollectionUtils.isEmpty(entityBOList)) {
return;
}
Map<String, PointValueBO> valuesMap = entityBOList.stream()
.filter(entityBO -> Objects.nonNull(entityBO.getTenantId()) && Objects.nonNull(entityBO.getPointId()))
.collect(Collectors.toMap(entityBO -> buildKey(entityBO.getTenantId(), deviceId, entityBO.getPointId()),
Function.identity()));
localCacheService.setKey(valuesMap);
}
public Map<Long, PointValueBO> selectLatestPointValue(Long tenantId, Long deviceId, List<Long> pointIds) {
if (Objects.isNull(tenantId) || Objects.isNull(deviceId) || CollectionUtils.isEmpty(pointIds)) {
return Collections.emptyMap();
}
List<String> keys = pointIds.stream().map(pointId -> buildKey(tenantId, deviceId, pointId)).toList();
List<PointValueBO> hits = localCacheService.getKey(keys);
return hits.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(PointValueBO::getPointId, Function.identity()));
}
private String buildKey(Long tenantId, Long deviceId, Long pointId) {
return PrefixConstant.REAL_TIME_VALUE_KEY_PREFIX + tenantId + SymbolConstant.DOT + deviceId + SymbolConstant.DOT
+ pointId;
}
}
@@ -0,0 +1,78 @@
/*
* 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.config;
import io.github.pnoker.common.config.MdcRequestIdListenerAdvice;
import io.github.pnoker.common.data.entity.property.PointBatchProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.aop.Advice;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.retry.MessageBatchRecoverer;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Point-value consumer configuration. RabbitMQ itself is the durable buffer; the
* consumer creates bounded batches and acknowledges only after the PostgreSQL
* transaction commits.
*/
@Slf4j
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class PointValueRabbitConfig {
private final ConnectionFactory connectionFactory;
private final PointBatchProperties properties;
@Bean("pointValueRabbitListenerContainerFactory")
SimpleRabbitListenerContainerFactory pointValueRabbitListenerContainerFactory(
MessageConverter messageConverter) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(messageConverter);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
factory.setConcurrentConsumers(properties.getConcurrentConsumers());
factory.setMaxConcurrentConsumers(properties.getMaxConcurrentConsumers());
factory.setPrefetchCount(Math.max(properties.getPrefetchCount(), properties.getBatchSize()));
factory.setBatchListener(true);
factory.setConsumerBatchEnabled(true);
factory.setBatchSize(properties.getBatchSize());
factory.setBatchReceiveTimeout(properties.getReceiveTimeoutMillis());
MessageBatchRecoverer recoverer = (messages, cause) -> {
log.error("Point-value batch exhausted retries, rejecting to dead-letter exchange, size={}",
messages.size(), cause);
throw new AmqpRejectAndDontRequeueException(
"Point-value batch exhausted retries", true, cause);
};
Advice retryAdvice = RetryInterceptorBuilder.stateless()
.maxRetries(properties.getMaxRetries())
.backOffOptions(properties.getRetryInitialIntervalMillis(),
properties.getRetryMultiplier(), properties.getRetryMaxIntervalMillis())
.recoverer(recoverer)
.build();
factory.setAdviceChain(new MdcRequestIdListenerAdvice(), retryAdvice);
return factory;
}
}
@@ -43,6 +43,34 @@ public class PointValueDO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* Immutable event identity used for idempotent inserts.
*/
@TableField("message_id")
private String messageId;
/**
* Wire schema version.
*/
@TableField("schema_version")
private Integer schemaVersion;
/**
* Unique runtime node that produced this reading.
*/
@TableField("driver_node")
private String driverNode;
/**
* Monotonically increasing sequence within {@link #driverNode}.
*/
@TableField("sequence")
private Long sequence;
/** Manager-issued device ownership fencing token. */
@TableField("fencing_token")
private Long fencingToken;
/**
* Device ID
*/
@@ -24,9 +24,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* 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.
* RabbitMQ-to-PostgreSQL point-value batch ingestion tuning.
*
* @author pnoker
* @version 2026.7.8
@@ -38,15 +36,30 @@ import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "dc3.data.point.batch")
public class PointBatchProperties {
@Min(value = 1, message = "Point batch queue capacity must be greater than 0")
private int queueCapacity = 100000;
@Min(value = 1, message = "Point batch size must be greater than 0")
private int batchSize = 1000;
private int batchSize = 500;
@Min(value = 1, message = "Point batch flush interval must be greater than 0")
private long flushIntervalMillis = 500;
@Min(value = 1, message = "Point batch receive timeout must be greater than 0")
private long receiveTimeoutMillis = 100;
@Min(value = 1, message = "Point batch worker count must be greater than 0")
private int workerCount = 4;
@Min(value = 1, message = "Point consumer count must be greater than 0")
private int concurrentConsumers = 4;
@Min(value = 1, message = "Point maximum consumer count must be greater than 0")
private int maxConcurrentConsumers = 16;
@Min(value = 1, message = "Point prefetch count must be greater than 0")
private int prefetchCount = 1000;
@Min(value = 0, message = "Point retry count can't be negative")
private int maxRetries = 3;
@Min(value = 1, message = "Point retry initial interval must be greater than 0")
private long retryInitialIntervalMillis = 1000;
@Min(value = 1, message = "Point retry multiplier must be at least 1")
private int retryMultiplier = 2;
@Min(value = 1, message = "Point retry maximum interval must be greater than 0")
private long retryMaxIntervalMillis = 10000;
}
@@ -18,6 +18,7 @@
package io.github.pnoker.common.data.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import io.github.pnoker.common.data.entity.model.PointValueDO;
import io.github.pnoker.common.entity.bo.WindowAggregateResult;
import io.github.pnoker.common.entity.query.WindowAggregateQuery;
@@ -35,6 +36,20 @@ import java.util.List;
*/
public interface PointValueMapper extends BaseMapper<PointValueDO> {
/**
* Insert a telemetry batch into the Timescale history table. Replayed events are
* ignored by the event identity unique index.
*/
@InterceptorIgnore(tenantLine = "true")
List<String> insertHistoryBatch(@Param("values") List<PointValueDO> values);
/**
* Upsert the shared latest-value projection without allowing an older reading to
* replace a newer one.
*/
@InterceptorIgnore(tenantLine = "true")
int upsertLatestBatch(@Param("values") List<PointValueDO> values);
/**
* Run a SQL aggregate (AVG/MIN/MAX/SUM/COUNT) over the rows that match
* the tenant/device/point bracket and fall inside {@code [from, to)}.
@@ -56,9 +71,7 @@ public interface PointValueMapper extends BaseMapper<PointValueDO> {
/**
* Batch query the latest point value for each point within a single device.
* Uses PostgreSQL {@code DISTINCT ON} to pick the row with the most recent
* {@code create_time} per {@code (device_id, point_id)} pair, replacing the
* N+1 loop that previously called {@code selectLatestPointValue} once per point.
* Reads from the transactional latest-value projection.
*/
List<PointValueDO> selectLatestPointValues(@Param("tenantId") Long tenantId,
@Param("deviceId") Long deviceId,
@@ -1,62 +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.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.utils.RabbitAckUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* RabbitMQ receiver for point value messages that have been TTL-expired and
* dead-lettered. Logs and acknowledges the expired message to alert operators
* of potential data loss.
*
* @author pnoker
* @version 2026.6.5
* @since 2026.6.5
*/
@Slf4j
@Component
public class PointValueDeadReceiver {
/**
* Consume a point value dead-letter message (TTL-expired) and log it for diagnostics;
* no record is updated, the message is simply acknowledged.
*
* @param channel the RabbitMQ channel for manual ack
* @param message the dead-letter message
*/
@RabbitHandler
@RabbitListener(queues = "#{pointValueDeadQueue.name}")
public void onDeadPointValue(Channel channel, Message message) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
log.warn("Point value TTL expired and was dead-lettered. Headers: {}",
message.getMessageProperties().getHeaders());
RabbitAckUtil.ack(channel, deliveryTag);
} catch (Exception e) {
log.error("Failed to ack dead-lettered point value", e);
RabbitAckUtil.nack(channel, deliveryTag, true);
}
}
}
@@ -18,24 +18,27 @@
package io.github.pnoker.common.data.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.data.buffer.PointValueIngestBuffer;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.utils.RabbitAckUtil;
import io.github.pnoker.common.utils.JsonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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).
* <p>The listener receives broker-created batches, validates the complete wire contract,
* persists history and latest projections in one transaction, and acknowledges the batch
* only after that transaction commits.
*
* @author pnoker
* @version 2026.7.8
@@ -46,41 +49,68 @@ import java.util.Objects;
@RequiredArgsConstructor
public class PointValueReceiver {
private final PointValueIngestBuffer pointValueIngestBuffer;
private static final int SUPPORTED_SCHEMA_VERSION = 1;
private final PointValueService pointValueService;
/**
* 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.
* Consume and durably persist one broker batch.
*
* @param channel the RabbitMQ channel for manual ack
* @param message the raw message carrying the delivery tag
* @param pointValueBO the deserialized point value
* @param messages raw messages in broker delivery order
*/
@RabbitHandler
@RabbitListener(queues = "#{pointValueQueue.name}",
containerFactory = "highThroughputRabbitListenerContainerFactory")
public void pointValueReceive(Channel channel, Message message, PointValueBO pointValueBO) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
if (Objects.isNull(pointValueBO) || Objects.isNull(pointValueBO.getDeviceId())) {
log.warn("Invalid point value, deviceId is null or pointValue is blank, deviceId={}",
Objects.isNull(pointValueBO) ? null : pointValueBO.getDeviceId());
RabbitAckUtil.reject(channel, deliveryTag);
return;
}
if (pointValueIngestBuffer.offer(pointValueBO)) {
RabbitAckUtil.ack(channel, deliveryTag);
} else {
log.warn("Point value ingest buffer full, nack-requeue to back-pressure, deviceId={}, pointId={}",
pointValueBO.getDeviceId(), pointValueBO.getPointId());
RabbitAckUtil.nack(channel, deliveryTag, true);
}
} catch (Exception e) {
log.error("Point value consume failed, deviceId={}, pointId={}, deliveryTag={}",
Objects.nonNull(pointValueBO) ? pointValueBO.getDeviceId() : null,
Objects.nonNull(pointValueBO) ? pointValueBO.getPointId() : null,
deliveryTag, e);
RabbitAckUtil.nack(channel, deliveryTag, true);
containerFactory = "pointValueRabbitListenerContainerFactory")
public void pointValueReceive(List<Message> messages, Channel channel) throws IOException {
if (messages.isEmpty()) {
return;
}
List<PointValueBO> values = new ArrayList<>(messages.size());
for (Message message : messages) {
PointValueBO value;
try {
value = JsonUtil.parseObject(message.getBody(), PointValueBO.class);
} catch (Exception e) {
throw poison(message, "Point-value payload is not valid JSON", e);
}
if (!valid(value)) {
throw poison(message, "Point-value payload violates schema version 1", null);
}
values.add(value);
}
pointValueService.save(values);
long lastDeliveryTag = messages.getLast().getMessageProperties().getDeliveryTag();
channel.basicAck(lastDeliveryTag, true);
log.debug("Persisted and acknowledged point-value batch, size={}, lastDeliveryTag={}",
values.size(), lastDeliveryTag);
}
private boolean valid(PointValueBO value) {
return Objects.nonNull(value)
&& Objects.equals(value.getSchemaVersion(), SUPPORTED_SCHEMA_VERSION)
&& Objects.nonNull(value.getMessageId()) && !value.getMessageId().isBlank()
&& Objects.nonNull(value.getDriverNode()) && !value.getDriverNode().isBlank()
&& positive(value.getSequence())
&& positive(value.getFencingToken())
&& positive(value.getTenantId())
&& positive(value.getDriverId())
&& positive(value.getDeviceId())
&& positive(value.getPointId())
&& Objects.nonNull(value.getRawValue())
&& Objects.nonNull(value.getCalValue())
&& Objects.nonNull(value.getCreateTime());
}
private boolean positive(Long value) {
return Objects.nonNull(value) && value > 0;
}
private AmqpRejectAndDontRequeueException poison(Message message, String reason, Exception cause) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
log.error("Reject poison point-value batch, reason={}, deliveryTag={}", reason, deliveryTag, cause);
return new AmqpRejectAndDontRequeueException(reason, true, cause);
}
}
@@ -19,6 +19,69 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.pnoker.common.data.mapper.PointValueMapper">
<select id="insertHistoryBatch" resultType="string" affectData="true" flushCache="true">
INSERT INTO dc3_point_value
(message_id, schema_version, driver_node, sequence, fencing_token,
tenant_id, device_id, point_id, raw_value, cal_value, num_value,
driver_id, create_time, operate_time)
SELECT value.message_id, value.schema_version, value.driver_node, value.sequence,
value.fencing_token, value.tenant_id, value.device_id, value.point_id,
value.raw_value, value.cal_value, value.num_value, value.driver_id,
value.create_time, value.operate_time
FROM (VALUES
<foreach collection="values" item="value" separator=",">
(#{value.messageId}, #{value.schemaVersion}, #{value.driverNode}, #{value.sequence}, #{value.fencingToken},
#{value.tenantId}, #{value.deviceId}, #{value.pointId}, #{value.rawValue},
#{value.calValue}, #{value.numValue}, #{value.driverId},
#{value.createTime}, #{value.operateTime})
</foreach>
) AS value(message_id, schema_version, driver_node, sequence, fencing_token,
tenant_id, device_id, point_id, raw_value, cal_value, num_value,
driver_id, create_time, operate_time)
JOIN dc3_manager.dc3_device_lease lease
ON lease.tenant_id = value.tenant_id
AND lease.driver_id = value.driver_id
AND lease.device_id = value.device_id
AND lease.owner_node = value.driver_node
AND lease.fencing_token = value.fencing_token
JOIN dc3_manager.dc3_driver_instance instance
ON instance.tenant_id = lease.tenant_id
AND instance.driver_id = lease.driver_id
AND instance.node_id = lease.owner_node
AND instance.lease_until &gt; CURRENT_TIMESTAMP
FOR KEY SHARE OF lease, instance
ON CONFLICT (message_id, create_time, device_id) DO NOTHING
RETURNING message_id
</select>
<insert id="upsertLatestBatch">
INSERT INTO dc3_point_latest
(tenant_id, device_id, point_id, message_id, schema_version, driver_node,
sequence, fencing_token, raw_value, cal_value, num_value, driver_id, create_time, operate_time)
VALUES
<foreach collection="values" item="value" separator=",">
(#{value.tenantId}, #{value.deviceId}, #{value.pointId}, #{value.messageId},
#{value.schemaVersion}, #{value.driverNode}, #{value.sequence}, #{value.fencingToken},
#{value.rawValue}, #{value.calValue}, #{value.numValue}, #{value.driverId},
#{value.createTime}, #{value.operateTime})
</foreach>
ON CONFLICT (tenant_id, device_id, point_id) DO UPDATE SET
message_id = EXCLUDED.message_id,
schema_version = EXCLUDED.schema_version,
driver_node = EXCLUDED.driver_node,
sequence = EXCLUDED.sequence,
fencing_token = EXCLUDED.fencing_token,
raw_value = EXCLUDED.raw_value,
cal_value = EXCLUDED.cal_value,
num_value = EXCLUDED.num_value,
driver_id = EXCLUDED.driver_id,
create_time = EXCLUDED.create_time,
operate_time = EXCLUDED.operate_time
WHERE (EXCLUDED.fencing_token, EXCLUDED.create_time, EXCLUDED.sequence, EXCLUDED.message_id)
&gt; (dc3_point_latest.fencing_token, dc3_point_latest.create_time,
dc3_point_latest.sequence, dc3_point_latest.message_id)
</insert>
<!--
Window aggregation pushdown for the long-window alarm evaluator.
The aggregate function is selected by <choose> from a fixed set so the
@@ -68,24 +131,18 @@
ORDER BY create_time
</select>
<!--
Batch latest point value query using PostgreSQL DISTINCT ON.
For each (device_id, point_id) pair, returns the single row with the
most recent create_time. Replaces the N+1 loop in PointValueServiceImpl.
-->
<!-- Shared latest-value projection; safe across multiple Data Center replicas. -->
<select id="selectLatestPointValues" resultType="io.github.pnoker.common.data.entity.model.PointValueDO">
SELECT DISTINCT ON (pv.device_id, pv.point_id)
pv.id, pv.tenant_id, pv.device_id, pv.point_id,
pv.raw_value, pv.cal_value, pv.num_value, pv.driver_id,
pv.create_time, pv.operate_time
FROM dc3_point_value pv
WHERE pv.tenant_id = #{tenantId}
AND pv.device_id = #{deviceId}
AND pv.point_id IN
SELECT tenant_id, device_id, point_id, message_id, schema_version,
driver_node, sequence, fencing_token, raw_value, cal_value, num_value,
driver_id, create_time, operate_time
FROM dc3_point_latest
WHERE tenant_id = #{tenantId}
AND device_id = #{deviceId}
AND point_id IN
<foreach collection="pointIds" item="pointId" open="(" separator="," close=")">
#{pointId}
</foreach>
ORDER BY pv.device_id, pv.point_id, pv.create_time DESC
</select>
</mapper>
@@ -17,23 +17,20 @@
package io.github.pnoker.common.data.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.github.pnoker.common.constant.driver.RabbitConstant;
import io.github.pnoker.common.data.dal.CommandHistoryManager;
import io.github.pnoker.common.data.entity.bo.CommandCallBO;
import io.github.pnoker.common.data.entity.builder.CommandHistoryBuilder;
import io.github.pnoker.common.data.entity.model.CommandHistoryDO;
import io.github.pnoker.common.data.entity.model.EntityStateDO;
import io.github.pnoker.common.data.mapper.EntityStateMapper;
import io.github.pnoker.common.entity.dto.CommandCallDTO;
import io.github.pnoker.common.enums.EnableFlagEnum;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.enums.PointCommandStatusEnum;
import io.github.pnoker.common.facade.api.CommandFacade;
import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.api.DriverFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeCommandBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO;
import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeCommandQuery;
@@ -56,6 +53,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class CommandHistoryServiceImplTest {
@@ -75,9 +74,6 @@ class CommandHistoryServiceImplTest {
@Mock
private CommandHistoryManager commandHistoryManager;
@Mock
private EntityStateMapper entityStateMapper;
@Mock
private CommandHistoryBuilder commandHistoryBuilder;
@@ -86,7 +82,15 @@ class CommandHistoryServiceImplTest {
@BeforeEach
void setUp() {
service = new CommandHistoryServiceImpl(deviceFacade, driverFacade, commandFacade, rabbitTemplate,
commandHistoryManager, commandHistoryBuilder, entityStateMapper);
commandHistoryManager, commandHistoryBuilder);
lenient().when(deviceFacade.getActiveOwner(any(), any()))
.thenReturn(new FacadeDeviceOwnerBO(40L, "node-a", 77L));
lenient().doAnswer(invocation -> {
CorrelationData correlation = invocation.getArgument(3);
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
return null;
}).when(rabbitTemplate).convertAndSend(any(String.class), any(String.class),
any(CommandCallDTO.class), any(CorrelationData.class));
}
@Test
@@ -113,9 +117,6 @@ class CommandHistoryServiceImplTest {
driver.setId(40L);
driver.setServiceName("modbus-driver");
EntityStateDO driverState = new EntityStateDO();
driverState.setStateFlag(EntityStatusEnum.ONLINE.getIndex());
CommandCallBO call = new CommandCallBO();
call.setDeviceId(deviceId);
call.setCommandId(commandId);
@@ -124,7 +125,6 @@ class CommandHistoryServiceImplTest {
when(deviceFacade.getById(tenantId, deviceId)).thenReturn(device);
when(commandFacade.getById(tenantId, commandId)).thenReturn(command);
when(driverFacade.getByDeviceId(tenantId, deviceId)).thenReturn(driver);
when(entityStateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(driverState);
LocalDateTime beforeLocal = LocalDateTime.now();
Instant beforeInstant = Instant.now();
@@ -142,7 +142,7 @@ class CommandHistoryServiceImplTest {
ArgumentCaptor<CommandCallDTO> dtoCaptor = ArgumentCaptor.forClass(CommandCallDTO.class);
verify(rabbitTemplate).convertAndSend(eq(RabbitConstant.TOPIC_EXCHANGE_COMMAND),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver"), dtoCaptor.capture(),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver.node-a"), dtoCaptor.capture(),
any(CorrelationData.class));
CommandCallDTO dto = dtoCaptor.getValue();
assertThat(dto.recordId()).isEqualTo(recordId);
@@ -152,7 +152,7 @@ class CommandHistoryServiceImplTest {
}
@Test
void callTreatsLegacyMillisecondTimeoutAsSeconds() {
void callUsesTimeoutSecondsWithoutUnitConversion() {
Long tenantId = 100L;
Long deviceId = 10L;
Long commandId = 20L;
@@ -168,16 +168,13 @@ class CommandHistoryServiceImplTest {
command.setTenantId(tenantId);
command.setProfileId(30L);
command.setCommandCode("restart");
command.setTimeout(30000);
command.setTimeout(30);
command.setEnableFlag(EnableFlagEnum.ENABLE);
FacadeDriverBO driver = new FacadeDriverBO();
driver.setId(40L);
driver.setServiceName("modbus-driver");
EntityStateDO driverState = new EntityStateDO();
driverState.setStateFlag(EntityStatusEnum.ONLINE.getIndex());
CommandCallBO call = new CommandCallBO();
call.setDeviceId(deviceId);
call.setCommandId(commandId);
@@ -185,7 +182,6 @@ class CommandHistoryServiceImplTest {
when(deviceFacade.getById(tenantId, deviceId)).thenReturn(device);
when(commandFacade.getById(tenantId, commandId)).thenReturn(command);
when(driverFacade.getByDeviceId(tenantId, deviceId)).thenReturn(driver);
when(entityStateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(driverState);
LocalDateTime beforeLocal = LocalDateTime.now();
Instant beforeInstant = Instant.now();
@@ -201,7 +197,7 @@ class CommandHistoryServiceImplTest {
ArgumentCaptor<CommandCallDTO> dtoCaptor = ArgumentCaptor.forClass(CommandCallDTO.class);
verify(rabbitTemplate).convertAndSend(eq(RabbitConstant.TOPIC_EXCHANGE_COMMAND),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver"), dtoCaptor.capture(),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver.node-a"), dtoCaptor.capture(),
any(CorrelationData.class));
CommandCallDTO dto = dtoCaptor.getValue();
assertThat(dto.expireAt()).isAfterOrEqualTo(beforeInstant.plusSeconds(30));
@@ -233,9 +229,6 @@ class CommandHistoryServiceImplTest {
driver.setId(40L);
driver.setServiceName("modbus-driver");
EntityStateDO driverState = new EntityStateDO();
driverState.setStateFlag(EntityStatusEnum.ONLINE.getIndex());
CommandCallBO call = new CommandCallBO();
call.setDeviceId(deviceId);
call.setCommandCode("restart");
@@ -244,7 +237,6 @@ class CommandHistoryServiceImplTest {
when(commandFacade.listByPage(any(FacadeCommandQuery.class)))
.thenReturn(new FacadePage<>(1L, 1L, 1L, 1L, List.of(command)));
when(driverFacade.getByDeviceId(tenantId, deviceId)).thenReturn(driver);
when(entityStateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(driverState);
service.call(tenantId, call);
@@ -256,7 +248,7 @@ class CommandHistoryServiceImplTest {
ArgumentCaptor<CommandCallDTO> dtoCaptor = ArgumentCaptor.forClass(CommandCallDTO.class);
verify(rabbitTemplate).convertAndSend(eq(RabbitConstant.TOPIC_EXCHANGE_COMMAND),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver"), dtoCaptor.capture(),
eq(RabbitConstant.ROUTING_COMMAND_PREFIX + "modbus-driver.node-a"), dtoCaptor.capture(),
any(CorrelationData.class));
assertThat(dtoCaptor.getValue().commandId()).isEqualTo(commandId);
}
@@ -22,11 +22,9 @@ import io.github.pnoker.common.data.dal.PointCommandHistoryManager;
import io.github.pnoker.common.data.entity.bo.PointCommandReadBO;
import io.github.pnoker.common.data.entity.bo.PointCommandWriteBO;
import io.github.pnoker.common.data.entity.builder.PointCommandHistoryBuilder;
import io.github.pnoker.common.data.entity.model.EntityStateDO;
import io.github.pnoker.common.data.mapper.EntityStateMapper;
import io.github.pnoker.common.data.validator.PointCommandValidator;
import io.github.pnoker.common.entity.dto.PointCommandDTO;
import io.github.pnoker.common.enums.EnableFlagEnum;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.enums.RwTypeEnum;
import io.github.pnoker.common.exception.NotFoundException;
import io.github.pnoker.common.exception.ServiceException;
@@ -35,6 +33,7 @@ import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.api.DriverFacade;
import io.github.pnoker.common.facade.api.PointFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO;
import io.github.pnoker.common.facade.entity.bo.FacadePointBO;
import org.junit.jupiter.api.BeforeEach;
@@ -44,6 +43,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -51,6 +51,8 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class PointCommandServiceImplTest {
@@ -73,9 +75,6 @@ class PointCommandServiceImplTest {
@Mock
private PointCommandHistoryBuilder pointCommandHistoryBuilder;
@Mock
private EntityStateMapper entityStateMapper;
@Mock
private PointCommandValidator pointCommandValidator;
@@ -98,6 +97,12 @@ class PointCommandServiceImplTest {
driver = new FacadeDriverBO();
driver.setId(30L);
driver.setServiceName("dc3-driver-modbus-tcp");
lenient().doAnswer(invocation -> {
CorrelationData correlation = invocation.getArgument(3);
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
return null;
}).when(rabbitTemplate).convertAndSend(any(String.class), any(String.class),
any(PointCommandDTO.class), any(CorrelationData.class));
}
@Test
@@ -105,7 +110,7 @@ class PointCommandServiceImplTest {
when(deviceFacade.getById(1L, 10L)).thenReturn(device);
when(pointFacade.getById(1L, 20L)).thenReturn(point);
when(driverFacade.getByDeviceId(1L, 10L)).thenReturn(driver);
mockDriverOnline();
mockActiveOwner();
PointCommandReadBO vo = new PointCommandReadBO();
vo.setDeviceId(10L);
@@ -114,7 +119,7 @@ class PointCommandServiceImplTest {
verify(rabbitTemplate).convertAndSend(
eq(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND),
eq(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + "dc3-driver-modbus-tcp"),
eq(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + "dc3-driver-modbus-tcp.node-a"),
any(Object.class),
any(org.springframework.amqp.rabbit.connection.CorrelationData.class));
verify(pointCommandHistoryManager).save(any());
@@ -191,7 +196,7 @@ class PointCommandServiceImplTest {
when(deviceFacade.getById(1L, 10L)).thenReturn(device);
when(pointFacade.getById(1L, 20L)).thenReturn(point);
when(driverFacade.getByDeviceId(1L, 10L)).thenReturn(driver);
mockDriverOnline();
mockActiveOwner();
PointCommandWriteBO vo = new PointCommandWriteBO();
vo.setDeviceId(10L);
@@ -201,7 +206,7 @@ class PointCommandServiceImplTest {
verify(rabbitTemplate).convertAndSend(
eq(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND),
eq(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + "dc3-driver-modbus-tcp"),
eq(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + "dc3-driver-modbus-tcp.node-a"),
any(Object.class),
any(org.springframework.amqp.rabbit.connection.CorrelationData.class));
verify(pointCommandHistoryManager).save(any());
@@ -281,9 +286,8 @@ class PointCommandServiceImplTest {
.hasMessageContaining("not writable");
}
private void mockDriverOnline() {
EntityStateDO driverState = new EntityStateDO();
driverState.setStateFlag(EntityStatusEnum.ONLINE.getIndex());
when(entityStateMapper.selectOne(any())).thenReturn(driverState);
private void mockActiveOwner() {
when(deviceFacade.getActiveOwner(1L, 10L))
.thenReturn(new FacadeDeviceOwnerBO(30L, "node-a", 77L));
}
}
@@ -20,7 +20,6 @@ package io.github.pnoker.common.data.biz.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.pnoker.common.constant.service.DataConstant;
import io.github.pnoker.common.data.biz.alarm.AlarmRuleTriggerService;
import io.github.pnoker.common.data.cache.PointValueLocalCache;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.entity.common.Pages;
import io.github.pnoker.common.entity.query.PointValueQuery;
@@ -43,14 +42,12 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -63,9 +60,6 @@ class PointValueServiceImplTest {
@Mock
private DeviceFacade deviceFacade;
@Mock
private PointValueLocalCache pointValueLocalCacheService;
@Mock
private RepositoryService repositoryService;
@@ -77,10 +71,6 @@ class PointValueServiceImplTest {
private PointValueBO pv;
private static Long eqLong(long value) {
return org.mockito.ArgumentMatchers.eq(value);
}
private static FacadeDeviceBO stubDevice(Long tenantId, Long profileId) {
FacadeDeviceBO device = new FacadeDeviceBO();
device.setTenantId(tenantId);
@@ -106,27 +96,27 @@ class PointValueServiceImplTest {
@Test
void singleSaveIgnoresNullPayload() {
assertThatNoException().isThrownBy(() -> service.save((PointValueBO) null));
verify(pointValueLocalCacheService, never()).savePointValue(any(PointValueBO.class));
verifyNoRepositoryWrites();
}
@Test
void batchSaveIgnoresNullAndEmptyList() {
assertThatNoException().isThrownBy(() -> service.save((List<PointValueBO>) null));
assertThatNoException().isThrownBy(() -> service.save(List.of()));
verify(pointValueLocalCacheService, never()).savePointValue(any(Long.class), any());
verifyNoRepositoryWrites();
}
@Test
void singleSaveStampsTimestampsAndPersistsToBothLayers() throws Exception {
void singleSaveStampsTimestampsAndPersistsTransactionally() throws Exception {
try (MockedStatic<RepositoryStrategyFactory> factory =
Mockito.mockStatic(RepositoryStrategyFactory.class)) {
factory.when(RepositoryStrategyFactory::get).thenReturn(List.of(repositoryService));
when(repositoryService.savePointValue(pv)).thenReturn(true);
service.save(pv);
assertThat(pv.getCreateTime()).isNotNull();
assertThat(pv.getOperateTime()).isNotNull();
verify(pointValueLocalCacheService).savePointValue(pv);
verify(repositoryService).savePointValue(pv);
verify(alarmRuleTriggerService).processPointValue(pv);
}
@@ -147,7 +137,7 @@ class PointValueServiceImplTest {
}
@Test
void batchSavePartitionsLargeListIntoChunksOfHundred() throws Exception {
void batchSaveUsesOneRepositoryTransaction() throws Exception {
List<PointValueBO> batch = new java.util.ArrayList<>();
for (int i = 0; i < 250; i++) {
batch.add(PointValueBO.builder().deviceId(10L).pointId((long) i).build());
@@ -156,10 +146,9 @@ class PointValueServiceImplTest {
try (MockedStatic<RepositoryStrategyFactory> factory =
Mockito.mockStatic(RepositoryStrategyFactory.class)) {
factory.when(RepositoryStrategyFactory::get).thenReturn(List.of(repositoryService));
when(repositoryService.savePointValues(batch)).thenReturn(batch);
service.save(batch);
// 250 entries -> 100 + 100 + 50 = 3 chunks
verify(repositoryService, times(3)).savePointValues(any());
verify(pointValueLocalCacheService).savePointValue(eqLong(10L), any());
verify(repositoryService).savePointValues(batch);
// PointValueServiceImpl.save(List) hands the whole batch to the trigger
// in one call now; the trigger's own contract is responsible for the
// per-element fan-out (covered in AlarmRuleTriggerServiceImplTest).
@@ -168,14 +157,15 @@ class PointValueServiceImplTest {
}
@Test
void singleSaveSwallowsRepositoryFailures() throws Exception {
void singleSavePropagatesRepositoryFailuresForBrokerRetry() throws Exception {
try (MockedStatic<RepositoryStrategyFactory> factory =
Mockito.mockStatic(RepositoryStrategyFactory.class)) {
factory.when(RepositoryStrategyFactory::get).thenReturn(List.of(repositoryService));
org.mockito.Mockito.doThrow(new RuntimeException("downstream offline"))
.when(repositoryService).savePointValue(any(PointValueBO.class));
assertThatNoException().isThrownBy(() -> service.save(pv));
assertThatThrownBy(() -> service.save(pv))
.isInstanceOf(RepositoryException.class);
}
}
@@ -286,12 +276,8 @@ class PointValueServiceImplTest {
when(deviceFacade.getById(1L, 10L)).thenReturn(stubDevice(1L, 5L));
when(pointFacade.listByPage(any())).thenReturn(new FacadePage<>(1, 10, 2, 1,
List.of(pointWithValue, pointWithoutValue)));
when(pointValueLocalCacheService.selectLatestPointValue(1L, 10L, List.of(20L, 21L)))
.thenReturn(Map.of(20L, cached));
// Point 21 is not in the local cache, so it is batch-queried from the repository,
// which returns nothing -> point 21 becomes a placeholder.
when(repositoryService.listLatestPointValues(1L, 10L, List.of(21L)))
.thenReturn(List.of());
when(repositoryService.listLatestPointValues(1L, 10L, List.of(20L, 21L)))
.thenReturn(List.of(cached));
try (MockedStatic<RepositoryStrategyFactory> factory =
Mockito.mockStatic(RepositoryStrategyFactory.class)) {
@@ -314,10 +300,7 @@ class PointValueServiceImplTest {
assertThat(placeholder.getOperateTime()).isNull();
}
// Only the cache-missing point (21) is batch-queried from the repository;
// the cached point (20) is never queried.
verify(repositoryService).listLatestPointValues(1L, 10L, List.of(21L));
verify(repositoryService, never()).listLatestPointValues(1L, 10L, List.of(20L));
verify(repositoryService).listLatestPointValues(1L, 10L, List.of(20L, 21L));
}
@Test
@@ -339,4 +322,13 @@ class PointValueServiceImplTest {
verify(repositoryService).listPagePointValue(query);
}
}
private void verifyNoRepositoryWrites() {
try {
verify(repositoryService, never()).savePointValue(any());
verify(repositoryService, never()).savePointValues(any());
} catch (Exception e) {
throw new AssertionError(e);
}
}
}
@@ -35,7 +35,7 @@ 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),
* Point-value ingestion no longer has a Quartz tick (RabbitMQ consumer batches drive it),
* so only the hourly cron job registration is asserted here.
*
* @author pnoker
@@ -19,30 +19,26 @@ package io.github.pnoker.common.data.biz.repository;
import io.github.pnoker.common.data.dal.PointValueManager;
import io.github.pnoker.common.data.entity.builder.PointValueBuilder;
import io.github.pnoker.common.data.entity.model.PointValueDO;
import io.github.pnoker.common.data.mapper.PointValueMapper;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.exception.AddException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.anyList;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link PostgresRepositoryServiceImpl} verifying that
* numValue set by the driver is passed through to the DAL layer unchanged.
*/
@ExtendWith(MockitoExtension.class)
class PostgresRepositoryServiceImplTest {
@@ -52,12 +48,12 @@ class PostgresRepositoryServiceImplTest {
@Mock
private PointValueManager pointValueManager;
@Mock
private PointValueMapper pointValueMapper;
@InjectMocks
private PostgresRepositoryServiceImpl service;
@Captor
private ArgumentCaptor<PointValueDO> doCaptor;
private PointValueBO numericBO;
private PointValueBO stringBO;
private PointValueDO numericDO;
@@ -66,103 +62,85 @@ class PostgresRepositoryServiceImplTest {
@BeforeEach
void setUp() {
LocalDateTime now = LocalDateTime.now();
numericBO = PointValueBO.builder()
.deviceId(1L).pointId(10L).tenantId(100L)
.messageId("m-1").schemaVersion(1).driverNode("node-a").sequence(1L)
.deviceId(1L).pointId(10L).driverId(20L).tenantId(100L)
.rawValue("42").calValue("42.5").numValue(42.5)
.createTime(now).operateTime(now)
.build();
.createTime(now).operateTime(now).build();
stringBO = PointValueBO.builder()
.deviceId(2L).pointId(20L).tenantId(100L)
.rawValue("on").calValue("on").numValue(null)
.createTime(now).operateTime(now)
.build();
.messageId("m-2").schemaVersion(1).driverNode("node-a").sequence(2L)
.deviceId(2L).pointId(20L).driverId(20L).tenantId(100L)
.rawValue("on").calValue("on").createTime(now).operateTime(now).build();
numericDO = new PointValueDO();
numericDO.setDeviceId(1L);
numericDO.setPointId(10L);
numericDO.setTenantId(100L);
numericDO.setRawValue("42");
numericDO.setCalValue("42.5");
numericDO.setMessageId("m-1");
numericDO.setNumValue(42.5);
stringDO = new PointValueDO();
stringDO.setDeviceId(2L);
stringDO.setPointId(20L);
stringDO.setTenantId(100L);
stringDO.setRawValue("on");
stringDO.setCalValue("on");
stringDO.setNumValue(null);
stringDO.setMessageId("m-2");
}
@Test
void savePointValuePassesNumericValueThrough() {
when(pointValueBuilder.buildDOByBO(numericBO)).thenReturn(numericDO);
when(pointValueManager.save(numericDO)).thenReturn(true);
void savesHistoryAndLatestProjectionFromSameConvertedBatch() {
List<PointValueBO> input = List.of(numericBO, stringBO);
List<PointValueDO> converted = List.of(numericDO, stringDO);
when(pointValueBuilder.buildDOListByBOList(input)).thenReturn(converted);
when(pointValueMapper.insertHistoryBatch(converted)).thenReturn(List.of("m-1", "m-2"));
service.savePointValues(input);
var order = inOrder(pointValueMapper);
order.verify(pointValueMapper).insertHistoryBatch(converted);
order.verify(pointValueMapper).upsertLatestBatch(converted);
assertThat(converted).extracting(PointValueDO::getNumValue)
.containsExactly(42.5, null);
verifyNoInteractions(pointValueManager);
}
@Test
void singleSaveUsesTheSameTransactionalBatchPath() {
when(pointValueBuilder.buildDOListByBOList(anyList())).thenReturn(List.of(numericDO));
when(pointValueMapper.insertHistoryBatch(List.of(numericDO))).thenReturn(List.of("m-1"));
service.savePointValue(numericBO);
verify(pointValueBuilder).buildDOByBO(numericBO);
verify(pointValueManager).save(doCaptor.capture());
assertThat(doCaptor.getValue().getNumValue()).isEqualTo(42.5);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<PointValueDO>> captor = ArgumentCaptor.forClass(List.class);
verify(pointValueMapper).insertHistoryBatch(captor.capture());
verify(pointValueMapper).upsertLatestBatch(captor.getValue());
assertThat(captor.getValue()).extracting(PointValueDO::getMessageId)
.containsExactly("m-1");
}
@Test
void savePointValuePassesNullNumValueThrough() {
when(pointValueBuilder.buildDOByBO(stringBO)).thenReturn(stringDO);
when(pointValueManager.save(stringDO)).thenReturn(true);
service.savePointValue(stringBO);
verify(pointValueManager).save(doCaptor.capture());
assertThat(doCaptor.getValue().getNumValue()).isNull();
void emptyBatchDoesNotTouchDatabase() {
service.savePointValues(List.of());
verifyNoInteractions(pointValueBuilder, pointValueMapper, pointValueManager);
}
@Test
void savePointValueThrowsOnFailure() {
when(pointValueBuilder.buildDOByBO(numericBO)).thenReturn(numericDO);
when(pointValueManager.save(numericDO)).thenReturn(false);
void duplicateMessageIdCannotUpsertTheSameLatestKeyTwice() {
List<PointValueBO> input = List.of(numericBO, numericBO);
when(pointValueBuilder.buildDOListByBOList(input)).thenReturn(List.of(numericDO, numericDO));
when(pointValueMapper.insertHistoryBatch(anyList())).thenReturn(List.of("m-1"));
assertThatThrownBy(() -> service.savePointValue(numericBO))
.isInstanceOf(AddException.class);
}
@Test
void savePointValuesBatchPassesNumericValuesThrough() {
when(pointValueBuilder.buildDOListByBOList(anyList())).thenReturn(java.util.List.of(numericDO, stringDO));
when(pointValueManager.saveBatch(anyList())).thenReturn(true);
service.savePointValues(java.util.List.of(numericBO, stringBO));
List<PointValueBO> accepted = service.savePointValues(input);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.List<PointValueDO>> listCaptor = ArgumentCaptor.forClass(java.util.List.class);
verify(pointValueManager).saveBatch(listCaptor.capture());
java.util.List<PointValueDO> saved = listCaptor.getValue();
assertThat(saved).hasSize(2);
assertThat(saved.get(0).getNumValue()).isEqualTo(42.5);
assertThat(saved.get(1).getNumValue()).isNull();
ArgumentCaptor<List<PointValueDO>> captor = ArgumentCaptor.forClass(List.class);
verify(pointValueMapper).upsertLatestBatch(captor.capture());
assertThat(captor.getValue()).hasSize(1);
assertThat(accepted).containsExactly(numericBO);
}
@Test
void noApplyNumericProjectionCalled() {
// Verify the service does NOT modify numValue after builder conversion.
// Before the refactoring, applyNumericProjection would overwrite numValue.
// Now the driver sets it, and we pass it through unchanged.
PointValueDO captured = new PointValueDO();
captured.setNumValue(99.9); // driver-set value
when(pointValueBuilder.buildDOByBO(any())).thenReturn(captured);
when(pointValueManager.save(any())).thenReturn(true);
void latestReadsSharedProjection() {
when(pointValueMapper.selectLatestPointValues(100L, 1L, List.of(10L)))
.thenReturn(List.of(numericDO));
when(pointValueBuilder.buildBOListByDOList(List.of(numericDO)))
.thenReturn(List.of(numericBO));
PointValueBO bo = PointValueBO.builder()
.deviceId(1L).pointId(10L).tenantId(100L)
.rawValue("99.9").calValue("99.9").numValue(99.9)
.build();
List<PointValueBO> result = service.listLatestPointValues(100L, 1L, List.of(10L));
service.savePointValue(bo);
verify(pointValueManager).save(doCaptor.capture());
// numValue should be exactly what the driver set, not re-parsed
assertThat(doCaptor.getValue().getNumValue()).isEqualTo(99.9);
assertThat(result).containsExactly(numericBO);
}
}
@@ -1,130 +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.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();
}
}
@@ -1,108 +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.cache;
import io.github.pnoker.common.constant.common.PrefixConstant;
import io.github.pnoker.common.constant.common.SymbolConstant;
import io.github.pnoker.common.entity.bo.PointValueBO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
class PointValueLocalCacheTest {
private LocalCacheImpl localCache;
private PointValueLocalCache service;
private static PointValueBO pv(Long tenantId, Long deviceId, Long pointId, String raw) {
return PointValueBO.builder()
.tenantId(tenantId)
.deviceId(deviceId)
.pointId(pointId)
.rawValue(raw)
.build();
}
@BeforeEach
void setUp() {
localCache = new LocalCacheImpl();
localCache.init();
service = new PointValueLocalCache(localCache);
}
@Test
void singleSaveSilentlyDropsEntriesMissingTenantOrDeviceOrPoint() {
assertThatNoException().isThrownBy(() -> service.savePointValue(pv(null, 10L, 20L, "v")));
assertThatNoException().isThrownBy(() -> service.savePointValue(pv(1L, null, 20L, "v")));
assertThatNoException().isThrownBy(() -> service.savePointValue(pv(1L, 10L, null, "v")));
assertThat(service.selectLatestPointValue(1L, 10L, List.of(20L))).isEmpty();
}
@Test
void singleSaveStoresUnderTenantDevicePointKey() {
PointValueBO bo = pv(1L, 10L, 20L, "42.5");
service.savePointValue(bo);
Map<Long, PointValueBO> hits = service.selectLatestPointValue(1L, 10L, List.of(20L));
assertThat(hits).containsKey(20L);
assertThat(hits.get(20L).getRawValue()).isEqualTo("42.5");
// The internal key shape is part of the contract for cross-service consumers.
String key = PrefixConstant.REAL_TIME_VALUE_KEY_PREFIX + "1" + SymbolConstant.DOT + "10"
+ SymbolConstant.DOT + "20";
assertThat((PointValueBO) localCache.getKey(key)).isSameAs(bo);
}
@Test
void batchSaveSilentlyDropsBlankInputs() {
assertThatNoException().isThrownBy(() -> service.savePointValue(null, List.of(pv(1L, 10L, 20L, "v"))));
assertThatNoException().isThrownBy(() -> service.savePointValue(10L, null));
assertThatNoException().isThrownBy(() -> service.savePointValue(10L, List.of()));
}
@Test
void batchSaveSkipsEntriesMissingTenantOrPointButKeepsTheRest() {
PointValueBO ok = pv(1L, 10L, 20L, "ok");
PointValueBO missingTenant = pv(null, 10L, 21L, "skipped");
PointValueBO missingPoint = pv(1L, 10L, null, "skipped");
service.savePointValue(10L, List.of(ok, missingTenant, missingPoint));
Map<Long, PointValueBO> hits = service.selectLatestPointValue(1L, 10L, List.of(20L, 21L));
assertThat(hits).containsOnlyKeys(20L);
}
@Test
void selectLatestReturnsEmptyForBlankInputs() {
assertThat(service.selectLatestPointValue(null, 10L, List.of(20L))).isEmpty();
assertThat(service.selectLatestPointValue(1L, null, List.of(20L))).isEmpty();
assertThat(service.selectLatestPointValue(1L, 10L, List.of())).isEmpty();
assertThat(service.selectLatestPointValue(1L, 10L, null)).isEmpty();
}
@Test
void selectLatestReturnsOnlyHitsForRequestedPointIds() {
service.savePointValue(pv(1L, 10L, 20L, "a"));
// 21L is intentionally not seeded — should be missing from result.
Map<Long, PointValueBO> hits = service.selectLatestPointValue(1L, 10L, List.of(20L, 21L));
assertThat(hits).hasSize(1).containsKey(20L);
}
}
@@ -18,81 +18,115 @@
package io.github.pnoker.common.data.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.data.buffer.PointValueIngestBuffer;
import io.github.pnoker.common.data.biz.PointValueService;
import io.github.pnoker.common.entity.bo.PointValueBO;
import io.github.pnoker.common.utils.JsonUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import static org.mockito.ArgumentMatchers.eq;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doThrow;
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 PointValueIngestBuffer buffer;
private PointValueService pointValueService;
@Mock
private Channel channel;
private PointValueReceiver receiver;
private Message message;
@BeforeEach
void setUp() {
receiver = new PointValueReceiver(buffer);
MessageProperties props = new MessageProperties();
props.setDeliveryTag(7L);
message = new Message(new byte[0], props);
receiver = new PointValueReceiver(pointValueService);
}
@Test
void rejectsNullPayload() throws Exception {
receiver.pointValueReceive(channel, message, null);
verifyNoInteractions(buffer);
verify(channel).basicReject(eq(7L), eq(false));
void persistsCompleteBatchBeforeAcknowledging() throws Exception {
Message first = message(validValue("m-1", 1L), 7L);
Message second = message(validValue("m-2", 2L), 8L);
receiver.pointValueReceive(List.of(first, second), channel);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<PointValueBO>> captor = ArgumentCaptor.forClass(List.class);
verify(pointValueService).save(captor.capture());
assertThat(captor.getValue()).extracting(PointValueBO::getMessageId)
.containsExactly("m-1", "m-2");
verify(channel).basicAck(8L, true);
}
@Test
void rejectsPayloadWithoutDeviceId() throws Exception {
PointValueBO bo = PointValueBO.builder().pointId(20L).build();
receiver.pointValueReceive(channel, message, bo);
verifyNoInteractions(buffer);
verify(channel).basicReject(eq(7L), eq(false));
void doesNotAcknowledgeWhenPersistenceFails() throws Exception {
doThrow(new IllegalStateException("database unavailable"))
.when(pointValueService).save(anyList());
Message message = message(validValue("m-1", 1L), 7L);
assertThatThrownBy(() -> receiver.pointValueReceive(List.of(message), channel))
.isInstanceOf(IllegalStateException.class);
verify(channel, never()).basicAck(7L, true);
}
@Test
void offersAndAcks() throws Exception {
PointValueBO bo = PointValueBO.builder().deviceId(10L).pointId(20L).rawValue("v").build();
when(buffer.offer(bo)).thenReturn(true);
receiver.pointValueReceive(channel, message, bo);
verify(buffer).offer(bo);
verify(channel).basicAck(eq(7L), eq(false));
void rejectsEntireBatchWhenWireContractIsInvalid() {
PointValueBO invalid = validValue("m-1", 1L);
invalid.setDriverNode(null);
assertThatThrownBy(() -> receiver.pointValueReceive(List.of(message(invalid, 7L)), channel))
.isInstanceOf(AmqpRejectAndDontRequeueException.class);
verifyNoInteractions(pointValueService);
}
@Test
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(buffer).offer(bo);
verify(channel).basicNack(eq(7L), eq(false), eq(true));
verify(channel, never()).basicAck(eq(7L), eq(false));
void rejectsMalformedJson() {
MessageProperties properties = new MessageProperties();
properties.setDeliveryTag(7L);
Message malformed = new Message("{".getBytes(StandardCharsets.UTF_8), properties);
assertThatThrownBy(() -> receiver.pointValueReceive(List.of(malformed), channel))
.isInstanceOf(AmqpRejectAndDontRequeueException.class);
verifyNoInteractions(pointValueService);
}
private PointValueBO validValue(String messageId, long sequence) {
return PointValueBO.builder()
.messageId(messageId)
.schemaVersion(1)
.driverNode("node-a")
.sequence(sequence)
.fencingToken(77L)
.tenantId(100L)
.driverId(200L)
.deviceId(10L)
.pointId(20L)
.rawValue("42")
.calValue("42")
.createTime(LocalDateTime.now())
.build();
}
private Message message(PointValueBO value, long deliveryTag) {
MessageProperties properties = new MessageProperties();
properties.setDeliveryTag(deliveryTag);
return new Message(JsonUtil.toJsonString(value).getBytes(StandardCharsets.UTF_8), properties);
}
}
+51 -13
View File
@@ -3,8 +3,9 @@
## Overview
`dc3-common-driver` is the shared driver dependency module of the IoT DC3 platform. It provides the driver SDK shared by
all protocol drivers, including auto-registration with the Manager Center, metadata sync, RabbitMQ command handling, and
scheduled data collection.
all protocol drivers, including auto-registration with Manager Center, PostgreSQL-backed runtime ownership, metadata
sync, RabbitMQ command handling, durable telemetry publication, and scheduled data collection. Redis is not part of the
driver coordination path.
## Module Information
@@ -15,12 +16,13 @@ scheduled data collection.
| Component | Purpose |
|----------------------------------------------|---------------------------------------------------------------|
| `DriverInitRunner` | Registers the driver with Manager Center via gRPC on startup |
| `DriverEnvironmentConfig` | Binds driver YAML config (name, attributes, point attributes) |
| gRPC Clients (`PointClient`, `DeviceClient`) | Fetches point/device config from Manager Center |
| RabbitMQ Consumers | Receives metadata update events and device commands |
| Scheduled Jobs | Periodic read jobs triggering driver's data collection loop |
| `DriverTopicConfig` | Configures driver-specific RabbitMQ queues/bindings |
| `DriverInitRunner` | Registers logical metadata and starts the runtime lease |
| `DriverLeaseRenewScheduleJob` | Renews membership and installs streamed ownership snapshots |
| `DriverEnvironmentConfig` | Derives immutable node, service, host, and client identities |
| `BufferServiceImpl` | SQLite outbox deleted only after broker ACK and routability |
| RabbitMQ Consumers | Validates node/fencing before executing directed commands |
| Scheduled Jobs | Reads only devices currently owned by the runtime node |
| `DriverTopicConfig` | Creates expiring per-node command queues and bindings |
## Driver Registration Flow
@@ -28,24 +30,60 @@ scheduled data collection.
Driver startup
→ DriverInitRunner
→ gRPC: dc3-center-manager / DriverApi.DriverRegister
← Returns: driver ID, driver attributes, point attributes, device IDs
← Returns: logical driver and protocol metadata
→ gRPC stream: DriverApi.RenewLease
← Returns: bounded device-lease pages, assignment version, fencing tokens
→ Atomically install the ownership snapshot after stream completion
→ Subscribe to metadata queue: dc3.q.metadata.driver.{serviceName}
→ Subscribe to point-command queue: dc3.q.point_command.{serviceName}
→ Subscribe to custom-command queue: dc3.q.command.{serviceName}
→ Subscribe to point-command queue: dc3.q.point_command.{serviceName}.{node}
→ Subscribe to custom-command queue: dc3.q.command.{serviceName}.{node}
```
Manager Center stores runtime membership, device assignments, assignment revisions, and fencing tokens in PostgreSQL.
Rendezvous hashing assigns each active device to exactly one live node. Stable heartbeats read only membership and one
device-revision row; full device scans happen only after membership or device-set changes. Both reconciliation and gRPC
delivery use keyset pages, so no manager request materializes every device in memory.
## RabbitMQ Integration
| Exchange | Queue | Purpose |
|-----------------------|-------------------------------------|--------------------------------------|
| `dc3.e.metadata` | `dc3.q.metadata.driver.{service}` | Receive configuration changes |
| `dc3.e.point_command` | `dc3.q.point_command.{service}` | Receive point read/write commands |
| `dc3.e.command` | `dc3.q.command.{service}` | Receive custom device commands |
| `dc3.e.point_command` | `dc3.q.point_command.{service}.{node}` | Receive owner-directed point commands |
| `dc3.e.command` | `dc3.q.command.{service}.{node}` | Receive owner-directed custom commands |
| `dc3.e.value` | — | Publish point values to Data Center |
The optional `dc3.rabbit.tag` system property prefixes runtime names; use `RabbitConstant` and `DriverTopicConfig` as
the authoritative definitions.
## Delivery and Failure Semantics
- A point value receives an immutable message ID, schema version, node sequence, owner node, and fencing token.
- Every point value is written to the mandatory SQLite outbox using WAL with full synchronous durability before publish. A row is removed only after RabbitMQ publisher
confirm ACK and no mandatory-return signal. NACK, unroutable, timeout, and synchronous failures remain durable and use
capped exponential backoff; there is no retry-count or size-based data eviction.
- List-based reports are inserted in one SQLite transaction before the first RabbitMQ publish, preserving durability
while amortizing FULL-synchronous fsync cost for high-volume protocol frames.
- Every driver runtime must own an exclusive persistent volume for its outbox directory. Do not mount the same SQLite
file into multiple driver processes or replicas. The supplied Compose stacks use a separate named volume per driver
service; an orchestrator must provide equivalent per-pod persistent storage.
- A driver stops reads, writes, and telemetry immediately when its local lease expires. Manager and Data Center also
reject stale owners by node and fencing token, so a paused or partitioned process cannot resume as an owner.
- Command execution is at-least-once across process crashes. Protocol implementations should use the command ID when the
physical device supports idempotency; no platform can guarantee exactly-once physical I/O after a crash without
device-side idempotency.
## Runtime Settings
| Property | Default | Meaning |
|---|---:|---|
| `dc3.driver.lease.seconds` | `30` | Manager-issued runtime lease; valid range is 10120 seconds |
| `dc3.driver.lease.renew-cron` | `0/10 * * * * ?` | Lease renewal schedule; keep comfortably below the lease |
| `dc3.driver.lease.queue-expires-millis` | `300000` | Removes unused per-node command queues after pod churn |
| `dc3.driver.buffer.db-path` | `dc3/data/driver/buffer.db` | Mandatory SQLite outbox path on runtime-exclusive persistent storage |
| `dc3.driver.buffer.batch-size` | `200` | Maximum outbox rows attempted per republish tick |
| `dc3.driver.buffer.max-backoff-seconds` | `600` | Maximum per-message republish backoff |
## Build Instructions
```bash
@@ -19,11 +19,13 @@ package io.github.pnoker.common.driver.buffer;
import io.github.pnoker.common.driver.entity.bean.PointValue;
import java.util.List;
/**
* Local SQLite-backed buffer for point values that could not be delivered to RabbitMQ.
* Local SQLite-backed transactional outbox for point values 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.
* <p>Readings are persisted before the first publish and removed only after RabbitMQ
* confirms that the message was accepted and routed.
*
* @author pnoker
* @version 2026.5.22
@@ -33,34 +35,34 @@ 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.
* pool, validate WAL/FULL durability, and create the buffer table. Idempotent.
*/
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).
* Persist and publish one point value using its message id as the outbox identity.
*
* @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
* @param pointValue point value with a stable message id
* @param routingKey RabbitMQ routing key
*/
void offer(PointValue pointValue, String routingKey, String correlationId, int attempt);
void publish(PointValue pointValue, String routingKey);
/**
* Republish up to {@code batchSize} due buffered point values, deleting the ones that
* leave the channel cleanly and back-offing the ones that throw.
* Persist an entire group in one transaction before publishing any value.
*
* @param pointValues values with stable message ids
* @param routingKey RabbitMQ routing key
*/
void publishBatch(List<PointValue> pointValues, String routingKey);
/**
* Republish due outbox records. A record is deleted only after a positive publisher
* confirmation with no returned message.
*/
void republishBatch();
/**
* @return whether the buffer is enabled in configuration
*/
boolean isEnabled();
/**
* @return current number of records awaiting republish (0 when disabled)
* @return current number of records awaiting republish
*/
long pendingCount();
}
@@ -25,6 +25,7 @@ import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
@@ -32,16 +33,12 @@ 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.
* SQLite-backed durable point-value outbox. Values are persisted before the first
* RabbitMQ publish and deleted only after a positive publisher confirm with no return.
* Every failure path retains the same message identity for idempotent downstream retry.
*
* @author pnoker
* @version 2026.5.22
* @version 2026.8.12
* @since 2026.6.2
*/
@Slf4j
@@ -61,33 +58,48 @@ public class BufferServiceImpl implements BufferService {
@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;
if (Objects.isNull(config)) {
throw new IllegalStateException("Driver point-value outbox configuration is required");
}
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);
private PointValueBuffer requireBuffer() {
if (Objects.isNull(buffer)) {
throw new IllegalStateException("Driver point-value outbox is not initialized");
}
return buffer;
}
@Override
public long pendingCount() {
return Objects.nonNull(buffer) ? buffer.count() : 0;
return requireBuffer().count();
}
@Override
public void offer(PointValue pointValue, String routingKey, String correlationId, int attempt) {
if (!isEnabled()) {
public void publish(PointValue pointValue, String routingKey) {
publishBatch(List.of(pointValue), routingKey);
}
@Override
public void publishBatch(List<PointValue> pointValues, String routingKey) {
if (pointValues == null || pointValues.isEmpty()) {
return;
}
DriverProperties.BufferProperties config = driverProperties.getBuffer();
long now = epochSecond();
BufferedPointValue record = new BufferedPointValue(
List<BufferedPointValue> records = pointValues.stream()
.map(pointValue -> toRecord(pointValue, routingKey, pointValue.getMessageId(), 0, now))
.toList();
requireBuffer().upsertBatch(records);
pointValues.forEach(pointValue -> publishPersisted(
pointValue, routingKey, pointValue.getMessageId(), 1));
}
private BufferedPointValue toRecord(PointValue pointValue, String routingKey, String correlationId,
int attempt, long now) {
DriverProperties.BufferProperties config = driverProperties.getBuffer();
return new BufferedPointValue(
correlationId,
pointValue.getDeviceId(),
pointValue.getPointId(),
@@ -97,92 +109,72 @@ public class BufferServiceImpl implements BufferService {
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);
now);
}
@Override
public void republishBatch() {
if (!isEnabled()) {
return;
}
DriverProperties.BufferProperties config = driverProperties.getBuffer();
List<BufferedPointValue> records = buffer.selectPending(config.getBatchSize(), epochSecond());
List<BufferedPointValue> records = requireBuffer().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);
log.debug("Republishing {} point values from outbox", records.size());
records.forEach(record -> republishOne(record, 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={}",
buffer.markRetry(record.id(), record.attempt(), epochSecond() + config.getMaxBackoffSeconds());
log.error("Outbox payload corrupted and retained, id={}, deviceId={}, pointId={}",
record.id(), record.deviceId(), record.pointId(), e);
buffer.delete(record.id());
return;
}
int nextAttempt = record.attempt() + 1;
int nextAttempt = record.attempt() == Integer.MAX_VALUE ? Integer.MAX_VALUE : record.attempt() + 1;
long backoff = backoffSeconds(nextAttempt, config);
// Claim the row before publishing so overlapping scheduler runs do not resend it.
buffer.markRetry(record.id(), nextAttempt, epochSecond() + backoff);
publishPersisted(pointValue, record.routingKey(), record.id(), nextAttempt);
}
private void publishPersisted(PointValue pointValue, String routingKey, String correlationId, int attempt) {
PointValueCorrelation correlation = new PointValueCorrelation(
record.id(), record.deviceId(), record.pointId(), nextAttempt,
record.payloadJson(), record.routingKey());
correlationId, pointValue.getDeviceId(), pointValue.getPointId(), attempt,
JsonUtil.toJsonString(pointValue), 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());
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_VALUE, routingKey, pointValue, correlation);
correlation.getFuture().whenComplete((confirm, failure) -> {
if (Objects.isNull(failure) && Objects.nonNull(confirm) && confirm.isAck()
&& Objects.isNull(correlation.getReturned())) {
buffer.delete(correlationId);
return;
}
markPublishFailure(correlationId, attempt, confirm, correlation, failure);
});
} 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);
markPublishFailure(correlationId, attempt, null, correlation, e);
}
}
/**
* 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);
private void markPublishFailure(String correlationId, int attempt, CorrelationData.Confirm confirm,
PointValueCorrelation correlation, Throwable failure) {
DriverProperties.BufferProperties config = driverProperties.getBuffer();
long backoff = backoffSeconds(attempt, config);
buffer.markRetry(correlationId, attempt, epochSecond() + backoff);
log.warn("Point value publish unconfirmed, id={}, attempt={}, retryInSeconds={}, ack={}, returned={}",
correlationId, attempt, backoff, Objects.nonNull(confirm) && confirm.isAck(),
Objects.nonNull(correlation.getReturned()), failure);
}
/**
* 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));
int exponent = Math.max(0, Math.min(attempt - 1, 30));
long multiplier = 1L << exponent;
long initial = config.getBackoffSeconds();
long delay = initial > Long.MAX_VALUE / multiplier ? Long.MAX_VALUE : initial * multiplier;
return Math.min(delay, config.getMaxBackoffSeconds());
}
@@ -82,11 +82,6 @@ public class PointValueBuffer {
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;
@@ -122,13 +117,32 @@ public class PointValueBuffer {
config.setDriverClassName("org.sqlite.JDBC");
config.setMaximumPoolSize(1);
config.setMinimumIdle(1);
config.setConnectionInitSql("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
config.setConnectionInitSql("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;");
config.setPoolName("dc3-driver-buffer");
this.dataSource = new HikariDataSource(config);
validateDurability();
createTableIfNotExists();
log.info("Point value buffer initialized, dbPath={}", dbPath);
}
private void validateDurability() {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
String journalMode;
try (ResultSet rs = stmt.executeQuery("PRAGMA journal_mode")) {
journalMode = rs.next() ? rs.getString(1) : null;
}
int synchronous;
try (ResultSet rs = stmt.executeQuery("PRAGMA synchronous")) {
synchronous = rs.next() ? rs.getInt(1) : -1;
}
if (!"wal".equalsIgnoreCase(journalMode) || synchronous < 2) {
throw new ServiceException("Point-value outbox requires SQLite WAL with synchronous FULL");
}
} catch (SQLException e) {
throw new ServiceException("Failed to validate point-value outbox durability", e);
}
}
private void createTableIfNotExists() {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(CREATE_TABLE_SQL);
@@ -143,24 +157,53 @@ public class PointValueBuffer {
* Insert or replace a buffered record keyed by the correlation id.
*/
public void upsert(BufferedPointValue record) {
upsertBatch(List.of(record));
}
/**
* Commit a group of records in one FULL-synchronous transaction. The transaction
* boundary preserves power-loss durability without paying one fsync per value.
*/
public void upsertBatch(List<BufferedPointValue> records) {
if (records == null || records.isEmpty()) {
return;
}
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();
conn.setAutoCommit(false);
try {
for (BufferedPointValue record : records) {
bindUpsert(ps, record);
ps.addBatch();
}
ps.executeBatch();
conn.commit();
} catch (SQLException e) {
try {
conn.rollback();
} catch (SQLException rollbackFailure) {
e.addSuppressed(rollbackFailure);
}
throw e;
}
} catch (SQLException e) {
log.error("Buffer upsert failed, id={}, attempt={}", record.id(), record.attempt(), e);
throw new ServiceException("Point-value outbox batch upsert failed, size=" + records.size(), e);
}
}
private void bindUpsert(PreparedStatement ps, BufferedPointValue record) throws SQLException {
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());
}
/**
* Return up to {@code batchSize} records due for republish (next_attempt_at &lt;= now),
* oldest-first.
@@ -202,7 +245,7 @@ public class PointValueBuffer {
ps.setString(1, id);
ps.executeUpdate();
} catch (SQLException e) {
log.error("Buffer delete failed, id={}", id, e);
throw new ServiceException("Point-value outbox delete failed: " + id, e);
}
}
@@ -217,23 +260,7 @@ public class PointValueBuffer {
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;
throw new ServiceException("Point-value outbox retry update failed: " + id, e);
}
}
@@ -251,14 +278,6 @@ public class PointValueBuffer {
}
}
/**
* @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.
*/
@@ -27,10 +27,8 @@ import org.springframework.boot.SpringApplication;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import java.text.MessageFormat;
import java.util.HashMap;
@@ -49,16 +47,13 @@ import java.util.Map;
public class DriverEnvironmentConfig implements EnvironmentPostProcessor {
/**
* Registers legacy {@code driver.*} → {@code dc3.*} property aliases and adds
* the driver node/service/host/client identifiers to the {@link ConfigurableEnvironment}.
* Adds the driver node/service/host/client identifiers to the environment.
*
* @param environment the Spring environment being customized
* @param application the current Spring application
*/
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
addLegacyDriverAliases(environment);
String node = environment.getProperty(EnvironmentConstant.DRIVER_NODE, String.class);
if (StringUtils.isEmpty(node)) {
node = EnvironmentUtil.getNodeId();
@@ -78,23 +73,4 @@ public class DriverEnvironmentConfig implements EnvironmentPostProcessor {
propertySources.addFirst(new MapPropertySource("driver", source));
}
private void addLegacyDriverAliases(ConfigurableEnvironment environment) {
Map<String, Object> aliases = new HashMap<>();
for (PropertySource<?> propertySource : environment.getPropertySources()) {
if (propertySource instanceof EnumerablePropertySource<?> enumerablePropertySource) {
for (String propertyName : enumerablePropertySource.getPropertyNames()) {
if (propertyName.startsWith("driver.")) {
String aliasName = "dc3." + propertyName;
if (!environment.containsProperty(aliasName)) {
aliases.put(aliasName, enumerablePropertySource.getProperty(propertyName));
}
}
}
}
}
if (!aliases.isEmpty()) {
environment.getPropertySources().addLast(new MapPropertySource("legacyDriverAliases", aliases));
}
}
}
@@ -101,8 +101,9 @@ public class DriverTopicConfig {
*/
@Bean
Queue pointCommandQueue() {
return QueueBuilder.durable(RabbitConstant.QUEUE_POINT_COMMAND_PREFIX + driverProperties.getService())
return QueueBuilder.durable(RabbitConstant.QUEUE_POINT_COMMAND_PREFIX + driverProperties.getClient())
.ttl(30000)
.expires(driverProperties.getLease().getQueueExpiresMillis())
.deadLetterExchange(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND_DEAD)
.deadLetterRoutingKey(SymbolConstant.HASHTAG)
.build();
@@ -118,7 +119,8 @@ public class DriverTopicConfig {
Binding pointCommandBinding(Queue pointCommandQueue) {
Binding binding = BindingBuilder.bind(pointCommandQueue)
.to(pointCommandExchange)
.with(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + driverProperties.getService());
.with(RabbitConstant.ROUTING_POINT_COMMAND_PREFIX + driverProperties.getService()
+ SymbolConstant.DOT + driverProperties.getNode());
binding.addArgument(RabbitConstant.AUTO_DELETE, false);
return binding;
}
@@ -130,8 +132,9 @@ public class DriverTopicConfig {
*/
@Bean
Queue commandQueue() {
return QueueBuilder.durable(RabbitConstant.QUEUE_COMMAND_PREFIX + driverProperties.getService())
return QueueBuilder.durable(RabbitConstant.QUEUE_COMMAND_PREFIX + driverProperties.getClient())
.ttl(30000)
.expires(driverProperties.getLease().getQueueExpiresMillis())
.deadLetterExchange(RabbitConstant.TOPIC_EXCHANGE_COMMAND_DEAD)
.deadLetterRoutingKey(SymbolConstant.HASHTAG)
.build();
@@ -147,7 +150,8 @@ public class DriverTopicConfig {
Binding commandBinding(Queue commandQueue) {
Binding binding = BindingBuilder.bind(commandQueue)
.to(commandExchange)
.with(RabbitConstant.ROUTING_COMMAND_PREFIX + driverProperties.getService());
.with(RabbitConstant.ROUTING_COMMAND_PREFIX + driverProperties.getService()
+ SymbolConstant.DOT + driverProperties.getNode());
binding.addArgument(RabbitConstant.AUTO_DELETE, false);
return binding;
}
@@ -54,6 +54,30 @@ public class PointValue implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* Immutable event identity used for end-to-end idempotency.
*/
private String messageId;
/**
* Wire schema version. Consumers reject unsupported versions instead of silently
* interpreting an incompatible payload.
*/
private Integer schemaVersion;
/**
* Unique runtime node that produced this reading.
*/
private String driverNode;
/**
* Monotonically increasing sequence within {@link #driverNode}.
*/
private Long sequence;
/** Manager-issued device ownership fencing token. */
private Long fencingToken;
/**
* Driver ID that collected the data. Populated by the sender before the message is
* published.
@@ -61,6 +61,12 @@ public class RegisterBO implements Serializable {
*/
private String client;
/** Runtime node identity. */
private String node;
/** Requested runtime lease duration in seconds. */
private Integer leaseSeconds;
/**
* Driver definition to register.
*/
@@ -23,6 +23,7 @@ import io.github.pnoker.common.driver.entity.dto.EventAttributeDTO;
import io.github.pnoker.common.driver.entity.dto.PointAttributeDTO;
import io.github.pnoker.common.enums.DriverTypeEnum;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -86,26 +87,37 @@ public class DriverProperties {
* Schedule configuration for periodic driver tasks.
*/
@Valid
@NotNull(message = "Driver schedule configuration can't be empty")
private ScheduleProperties schedule = new ScheduleProperties();
/**
* Health configuration for driver-side runtime checks.
*/
@Valid
@NotNull(message = "Driver health configuration can't be empty")
private HealthProperties health = new HealthProperties();
/**
* Local buffer configuration for point-value resume on broker outage.
*/
@Valid
@NotNull(message = "Driver buffer configuration can't be empty")
private BufferProperties buffer = new BufferProperties();
/**
* Metadata cache tuning for the driver runtime.
*/
@Valid
@NotNull(message = "Driver metadata configuration can't be empty")
private MetadataProperties metadata = new MetadataProperties();
/**
* Distributed runtime lease and heartbeat configuration.
*/
@Valid
@NotNull(message = "Driver lease configuration can't be empty")
private LeaseProperties lease = new LeaseProperties();
/**
* Driver-level attribute definitions declared in configuration.
*/
@@ -129,16 +141,19 @@ public class DriverProperties {
/**
* Generated or configured driver node identifier.
*/
@NotBlank(message = "Driver node can't be empty")
private String node;
/**
* Driver service name, typically composed from tenant and application name.
*/
@NotBlank(message = "Driver service can't be empty")
private String service;
/**
* Host address exposed by the driver process.
*/
@NotBlank(message = "Driver host can't be empty")
private String host;
/**
@@ -149,6 +164,7 @@ public class DriverProperties {
/**
* Driver client identifier used for queue and registration routing.
*/
@NotBlank(message = "Driver client can't be empty")
private String client;
/**
@@ -298,12 +314,6 @@ public class DriverProperties {
@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.
@@ -311,13 +321,6 @@ public class DriverProperties {
@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.
*/
@@ -330,12 +333,6 @@ public class DriverProperties {
@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.
*/
@@ -350,4 +347,24 @@ public class DriverProperties {
}
/**
* Runtime membership lease. A node stops processing devices as soon as this lease
* expires locally, even if Manager Center is unavailable.
*/
@Getter
@Setter
public static class LeaseProperties {
@Min(10)
@Max(120)
private int seconds = 30;
@NotBlank(message = "Lease renewal cron can't be empty")
private String renewCron = "0/10 * * * * ?";
/** Delete per-instance command queues after a departed node remains unused. */
@Min(60000)
private int queueExpiresMillis = 300000;
}
}
@@ -25,6 +25,8 @@ import io.github.pnoker.api.common.GrpcEventAttributeDTO;
import io.github.pnoker.api.common.GrpcPointAttributeDTO;
import io.github.pnoker.api.common.driver.DriverApiGrpc;
import io.github.pnoker.api.common.driver.GrpcDriverRegisterDTO;
import io.github.pnoker.api.common.driver.GrpcDriverLeaseRequest;
import io.github.pnoker.api.common.driver.GrpcRDriverLeaseDTO;
import io.github.pnoker.api.common.driver.GrpcRDriverRegisterDTO;
import io.github.pnoker.common.driver.entity.bo.DriverBO;
import io.github.pnoker.common.driver.entity.bo.RegisterBO;
@@ -37,6 +39,7 @@ import io.github.pnoker.common.driver.entity.dto.CommandAttributeDTO;
import io.github.pnoker.common.driver.entity.dto.DriverAttributeDTO;
import io.github.pnoker.common.driver.entity.dto.EventAttributeDTO;
import io.github.pnoker.common.driver.entity.dto.PointAttributeDTO;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.exception.RegisterException;
@@ -46,7 +49,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -70,6 +74,8 @@ public class DriverClient {
private final DriverMetadata driverMetadata;
private final DriverProperties driverProperties;
private final DriverBuilder driverBuilder;
private final GrpcDriverAttributeBuilder grpcDriverAttributeBuilder;
@@ -90,7 +96,11 @@ public class DriverClient {
// Build driver registration information
GrpcDriverRegisterDTO.Builder builder = GrpcDriverRegisterDTO.newBuilder();
GrpcDriverDTO grpcDriverDTO = driverBuilder.buildGrpcDTOByDTO(entityBO.getDriver());
builder.setTenant(entityBO.getTenant()).setClient(entityBO.getClient()).setDriver(grpcDriverDTO);
builder.setTenant(entityBO.getTenant())
.setClient(entityBO.getClient())
.setNode(entityBO.getNode())
.setLeaseSeconds(entityBO.getLeaseSeconds())
.setDriver(grpcDriverDTO);
CollectionOptional.ofNullable(entityBO.getDriverAttributes()).ifPresent(value -> {
List<GrpcDriverAttributeDTO> grpcDriverAttributeDTOList = value.stream()
@@ -124,6 +134,7 @@ public class DriverClient {
}
applyMetadata(rDriverRegisterDTO);
renewLease();
}
/**
@@ -148,12 +159,72 @@ public class DriverClient {
applyMetadata(rDriverRegisterDTO);
}
/** Renew runtime membership and replace the locally owned device set. */
public void renewLease() {
DriverBO driver = driverMetadata.getDriver();
if (Objects.isNull(driver)) {
throw new ServiceException("Failed to renew driver lease: driver is not registered");
}
GrpcDriverLeaseRequest request = GrpcDriverLeaseRequest.newBuilder()
.setTenantId(driver.getTenantId())
.setDriverId(driver.getId())
.setNode(driverProperties.getNode())
.setClient(driverProperties.getClient())
.setHost(driverProperties.getHost())
.setLeaseSeconds(driverProperties.getLease().getSeconds())
.setAssignmentVersion(driverMetadata.getAssignmentVersion())
.build();
Iterator<GrpcRDriverLeaseDTO> responses = driverApiBlockingStub.renewLease(request);
Map<Long, Long> owned = new HashMap<>();
Long assignmentVersion = null;
Long leaseUntilEpochMillis = null;
Boolean assignmentsChanged = null;
boolean snapshotComplete = false;
int batches = 0;
while (responses.hasNext()) {
GrpcRDriverLeaseDTO response = responses.next();
if (!response.getResult().getOk()) {
throw new ServiceException(response.getResult().getMessage());
}
if (snapshotComplete) {
throw new ServiceException("Driver lease stream continued after snapshot completion");
}
if (assignmentVersion == null) {
assignmentVersion = response.getAssignmentVersion();
leaseUntilEpochMillis = response.getLeaseUntilEpochMillis();
assignmentsChanged = response.getAssignmentsChanged();
} else if (assignmentVersion != response.getAssignmentVersion()
|| leaseUntilEpochMillis != response.getLeaseUntilEpochMillis()
|| assignmentsChanged != response.getAssignmentsChanged()) {
throw new ServiceException("Driver lease stream metadata changed between batches");
}
response.getDeviceLeasesList().forEach(lease -> {
Long previous = owned.put(lease.getDeviceId(), lease.getFencingToken());
if (previous != null) {
throw new ServiceException("Driver lease stream contains duplicate device {}", lease.getDeviceId());
}
});
snapshotComplete = response.getSnapshotComplete();
batches++;
}
if (batches == 0 || !snapshotComplete || assignmentVersion == null
|| leaseUntilEpochMillis == null || assignmentsChanged == null) {
throw new ServiceException("Driver lease stream ended before snapshot completion");
}
if (assignmentsChanged) {
driverMetadata.setDeviceLeases(owned, leaseUntilEpochMillis, assignmentVersion);
} else {
if (!owned.isEmpty()) {
throw new ServiceException("Unchanged driver lease stream contains device assignments");
}
driverMetadata.renewLeaseDeadline(leaseUntilEpochMillis);
}
}
private void applyMetadata(GrpcRDriverRegisterDTO rDriverRegisterDTO) {
DriverBO driverBO = driverBuilder.buildDTOByGrpcDTO(rDriverRegisterDTO.getDriver());
driverMetadata.setDriver(driverBO);
driverMetadata.setDeviceIds(new HashSet<>(rDriverRegisterDTO.getDeviceIdsList()));
List<GrpcDriverAttributeDTO> driverAttributesList = rDriverRegisterDTO.getDriverAttributesList();
Map<Long, DriverAttributeDTO> driverAttributeIdMap = driverAttributesList.stream()
.collect(Collectors.toMap(entity -> entity.getBase().getId(),
@@ -0,0 +1,48 @@
/*
* 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.grpc.client.DriverClient;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
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;
/** Renews runtime membership. Expired local leases automatically stop device work. */
@Slf4j
@Component
@RequiredArgsConstructor
@DisallowConcurrentExecution
public class DriverLeaseRenewScheduleJob extends QuartzJobBean {
private final DriverClient driverClient;
private final DriverMetadata driverMetadata;
@Override
protected void executeInternal(JobExecutionContext context) {
try {
driverClient.renewLease();
} catch (Exception e) {
log.error("Driver lease renewal failed, leaseValid={}, leaseUntilEpochMillis={}",
driverMetadata.leaseValid(), driverMetadata.getLeaseUntilEpochMillis(), e);
}
}
}
@@ -101,6 +101,9 @@ public class DriverReadScheduleJob extends QuartzJobBean {
}
private void readDevice(DeviceBO device) {
if (!driverMetadata.ownsDevice(device.getId())) {
return;
}
device.getPointIds().forEach(pointId -> readPoint(device.getId(), pointId));
}
@@ -38,10 +38,9 @@ import java.util.concurrent.ConcurrentHashMap;
* In-memory holder for driver registration state and shared metadata used across the
* driver runtime.
*
* <p>The {@code deviceIds} set and the four attribute maps are mutated from multiple
* threads at the same time RabbitMQ consumer threads add/remove entries as
* metadata events arrive while Quartz worker threads iterate the same collections
* during read scans. Attribute maps are also mutated during driver metadata refresh.
* <p>The leased device set and the four attribute maps are read from multiple threads
* at the same time while Quartz worker threads iterate the current ownership snapshot.
* Attribute maps are also mutated during driver metadata refresh.
* The fields therefore use thread-safe implementations and the setters copy contents
* into the existing collection instead of swapping the reference, so callers that
* already hold a reference (e.g. via
@@ -60,6 +59,15 @@ public final class DriverMetadata {
* Identifiers of devices owned by the driver.
*/
private final Set<Long> deviceIds = ConcurrentHashMap.newKeySet();
/** Fencing tokens for devices currently owned by this runtime node. */
private final Map<Long, Long> deviceFencingTokens = new ConcurrentHashMap<>();
/** Manager-issued instance lease deadline. */
private volatile long leaseUntilEpochMillis;
/** Manager assignment generation currently installed in this runtime. */
private volatile long assignmentVersion;
/**
* Driver attributes keyed by attribute identifier.
*/
@@ -120,32 +128,42 @@ public final class DriverMetadata {
/**
* Unmodifiable view of the device ids so callers cannot mutate the internal set
* through the getter. The underlying set is still live reads observe the most
* recent state. Use {@link #addDeviceId(Long)} / {@link #removeDeviceId(Long)} to
* mutate, or {@link #setDeviceIds(Set)} to replace the contents in place.
* recent state. Ownership can only be replaced with a Manager-issued lease snapshot.
*
* @return unmodifiable live view of the device ids
*/
public Set<Long> getDeviceIds() {
return Collections.unmodifiableSet(deviceIds);
return leaseValid() ? Collections.unmodifiableSet(deviceIds) : Collections.emptySet();
}
/**
* Replaces the contents of the device id set in place so existing references stay valid.
*
* @param deviceIds device identifiers to publish; {@code null} clears the set
*/
public void setDeviceIds(Set<Long> deviceIds) {
replaceContents(this.deviceIds, deviceIds);
/** Atomically replace owned devices and publish the new lease deadline. */
public synchronized void setDeviceLeases(Map<Long, Long> leases, long leaseUntilEpochMillis,
long assignmentVersion) {
deviceFencingTokens.clear();
deviceIds.clear();
if (Objects.nonNull(leases)) {
deviceFencingTokens.putAll(leases);
deviceIds.addAll(leases.keySet());
}
this.leaseUntilEpochMillis = leaseUntilEpochMillis;
this.assignmentVersion = assignmentVersion;
}
/**
* Add a device id to the live set.
*
* @param id device id to add
* @return {@code true} if the set did not already contain the id
*/
public boolean addDeviceId(Long id) {
return deviceIds.add(id);
/** Extend the instance deadline without retransmitting an unchanged assignment. */
public void renewLeaseDeadline(long leaseUntilEpochMillis) {
this.leaseUntilEpochMillis = leaseUntilEpochMillis;
}
public boolean ownsDevice(Long deviceId) {
return leaseValid() && deviceFencingTokens.containsKey(deviceId);
}
public Long getFencingToken(Long deviceId) {
return ownsDevice(deviceId) ? deviceFencingTokens.get(deviceId) : null;
}
public boolean leaseValid() {
return System.currentTimeMillis() < leaseUntilEpochMillis;
}
/**
@@ -155,6 +173,7 @@ public final class DriverMetadata {
* @return {@code true} if the set contained the id
*/
public boolean removeDeviceId(Long id) {
deviceFencingTokens.remove(id);
return deviceIds.remove(id);
}
@@ -236,6 +255,9 @@ public final class DriverMetadata {
*/
public void clear() {
deviceIds.clear();
deviceFencingTokens.clear();
leaseUntilEpochMillis = 0;
assignmentVersion = 0;
driverAttributeIdMap.clear();
driverAttributeNameMap.clear();
pointAttributeIdMap.clear();
@@ -23,6 +23,8 @@ import io.github.pnoker.common.driver.command.DeviceLockManager;
import io.github.pnoker.common.driver.entity.bo.AttributeBO;
import io.github.pnoker.common.driver.entity.bo.DeviceBO;
import io.github.pnoker.common.driver.metadata.DeviceMetadata;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.service.DriverCustomService;
import io.github.pnoker.common.driver.service.DriverSenderService;
import io.github.pnoker.common.entity.dto.CommandCallDTO;
@@ -60,7 +62,7 @@ import java.util.Objects;
public class CommandReceiver {
/**
* Message schema version stamped on outbound command results for forward/backward compatibility.
* Message schema version stamped on outbound command results.
*/
private static final int SCHEMA_VERSION = 1;
private final DriverCustomService driverCustomService;
@@ -69,6 +71,8 @@ public class CommandReceiver {
private final DeviceMetadata deviceMetadata;
private final CommandDedupCache dedupCache;
private final DeviceLockManager deviceLockManager;
private final DriverMetadata driverMetadata;
private final DriverProperties driverProperties;
/**
* Dispatch a custom command call to the driver: validate the payload, drop
@@ -93,6 +97,7 @@ public class CommandReceiver {
// otherwise fall through to the nack(requeue) path and requeue garbage.
if (Objects.isNull(entityDTO) || Objects.isNull(entityDTO.recordId())
|| Objects.isNull(entityDTO.tenantId())
|| Objects.isNull(entityDTO.ownerNode()) || Objects.isNull(entityDTO.fencingToken())
|| Objects.isNull(entityDTO.deviceId()) || Objects.isNull(entityDTO.commandId())) {
log.error("Invalid custom command: {}", entityDTO);
RabbitAckUtil.reject(channel, deliveryTag);
@@ -106,6 +111,15 @@ public class CommandReceiver {
Long deviceId = entityDTO.deviceId();
Long commandId = entityDTO.commandId();
if (!Objects.equals(driverProperties.getNode(), entityDTO.ownerNode())
|| !Objects.equals(driverMetadata.getFencingToken(deviceId), entityDTO.fencingToken())) {
log.warn("Reject stale-owner custom command, recordId={}, deviceId={}, fencingToken={}",
recordId, deviceId, entityDTO.fencingToken());
sendResult(recordId, tenantId, PointCommandStatusEnum.FAILED,
null, null, "STALE_OWNER", "Device ownership lease changed", channel, deliveryTag);
return;
}
// Expire-at pre-check
if (Objects.nonNull(entityDTO.expireAt()) && Instant.now().isAfter(entityDTO.expireAt())) {
log.warn("Command already expired: recordId={}, expireAt={}", recordId, entityDTO.expireAt());
@@ -184,6 +198,11 @@ public class CommandReceiver {
}
} catch (Exception e) {
log.error("Failed to send command result, recordId={}", recordId, e);
if (Objects.nonNull(recordId)) {
dedupCache.release(recordId);
}
RabbitAckUtil.nack(channel, deliveryTag, true);
return;
}
RabbitAckUtil.ack(channel, deliveryTag);
}
@@ -94,10 +94,8 @@ public class MetadataReceiver {
if (MetadataOperateTypeEnum.ADD.equals(entityDTO.getOperateType())
|| MetadataOperateTypeEnum.UPDATE.equals(entityDTO.getOperateType())) {
log.debug("Upsert device: {}", entityDTO.getId());
// Add the id first so a refresh that races with a Quartz scan does
// not bypass the just-loaded entry; loadCache below either fills
// the cache or, on a null upstream, removes the orphan id again.
driverMetadata.addDeviceId(entityDTO.getId());
// Metadata events invalidate/load data only. Ownership is assigned by
// the Manager lease service and is never inferred from an ADD event.
deviceMetadata.loadCache(entityDTO.getId());
} else if (MetadataOperateTypeEnum.DELETE.equals(entityDTO.getOperateType())) {
log.debug("Delete device: {}", entityDTO.getId());
@@ -23,6 +23,8 @@ import io.github.pnoker.common.driver.command.DeviceLockManager;
import io.github.pnoker.common.driver.service.DriverReadService;
import io.github.pnoker.common.driver.service.DriverSenderService;
import io.github.pnoker.common.driver.service.DriverWriteService;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.entity.dto.PointCommandDTO;
import io.github.pnoker.common.entity.dto.PointCommandPayload;
import io.github.pnoker.common.entity.dto.PointCommandResultDTO;
@@ -53,7 +55,7 @@ import java.util.Objects;
public class PointCommandReceiver {
/**
* Message schema version stamped on outgoing command results, used by the data center to drive compatibility handling.
* Message schema version stamped on outgoing command results.
*/
private static final int SCHEMA_VERSION = 1;
private final DriverReadService driverReadService;
@@ -61,6 +63,8 @@ public class PointCommandReceiver {
private final DriverSenderService driverSenderService;
private final CommandDedupCache dedupCache;
private final DeviceLockManager deviceLockManager;
private final DriverMetadata driverMetadata;
private final DriverProperties driverProperties;
/**
* Handles an incoming point command by validating the payload, rejecting duplicates,
@@ -81,7 +85,8 @@ public class PointCommandReceiver {
// payload must be rejected before logging to avoid an NPE that would
// otherwise fall through to the nack(requeue) path and requeue garbage.
if (Objects.isNull(entityDTO) || Objects.isNull(entityDTO.commandId())
|| Objects.isNull(entityDTO.tenantId()) || Objects.isNull(entityDTO.type())
|| Objects.isNull(entityDTO.tenantId()) || Objects.isNull(entityDTO.ownerNode())
|| Objects.isNull(entityDTO.fencingToken()) || Objects.isNull(entityDTO.type())
|| Objects.isNull(entityDTO.payload())) {
log.error("Invalid point command: {}", entityDTO);
RabbitAckUtil.reject(channel, deliveryTag);
@@ -120,6 +125,15 @@ public class PointCommandReceiver {
case PointCommandPayload.WritePayload w -> w.deviceId();
};
if (!Objects.equals(driverProperties.getNode(), entityDTO.ownerNode())
|| !Objects.equals(driverMetadata.getFencingToken(lockDeviceId), entityDTO.fencingToken())) {
log.warn("Reject stale-owner point command, commandId={}, deviceId={}, fencingToken={}",
commandId, lockDeviceId, entityDTO.fencingToken());
sendResult(commandId, tenantId, PointCommandStatusEnum.FAILED,
null, "STALE_OWNER", "Device ownership lease changed", channel, deliveryTag);
return;
}
// Dispatch under per-device lock to prevent protocol interleaving
String responseValue = deviceLockManager.runExclusive(lockDeviceId, () -> {
String rv = null;
@@ -195,6 +209,11 @@ public class PointCommandReceiver {
}
} catch (Exception e) {
log.error("Failed to send command result, commandId={}", commandId, e);
if (Objects.nonNull(commandId)) {
dedupCache.release(commandId);
}
RabbitAckUtil.nack(channel, deliveryTag, true);
return;
}
RabbitAckUtil.ack(channel, deliveryTag);
}
@@ -93,6 +93,8 @@ public class DriverRegisterServiceImpl implements DriverRegisterService {
entityBO.setDriver(driverBO);
entityBO.setTenant(driverProperties.getTenant());
entityBO.setClient(driverProperties.getClient());
entityBO.setNode(driverProperties.getNode());
entityBO.setLeaseSeconds(driverProperties.getLease().getSeconds());
entityBO.setDriverAttributes(driverProperties.getDriverAttribute());
entityBO.setPointAttributes(driverProperties.getPointAttribute());
entityBO.setCommandAttributes(driverProperties.getCommandAttribute());
@@ -23,6 +23,7 @@ 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;
import io.github.pnoker.common.driver.job.DriverLeaseRenewScheduleJob;
import io.github.pnoker.common.driver.job.DriverReadScheduleJob;
import io.github.pnoker.common.driver.service.DriverScheduleService;
import io.github.pnoker.common.exception.CronException;
@@ -66,7 +67,7 @@ public class DriverScheduleServiceImpl implements DriverScheduleService {
// Get schedule properties from driver configuration
DriverProperties.ScheduleProperties property = driverProperties.getSchedule();
if (Objects.isNull(property)) {
return;
throw new IllegalStateException("Driver schedule configuration is required");
}
try {
@@ -75,6 +76,13 @@ public class DriverScheduleServiceImpl implements DriverScheduleService {
ScheduleConstant.DRIVER_HEALTH_SCHEDULE_JOB, ScheduleConstant.DRIVER_HEALTH_SCHEDULE_CRON,
DriverHealthScheduleJob.class);
if (!CronExpression.isValidExpression(driverProperties.getLease().getRenewCron())) {
throw new CronException("Driver lease renewal cron expression is invalid");
}
quartzService.createJobWithCron(ScheduleConstant.DRIVER_SCHEDULE_GROUP,
ScheduleConstant.DRIVER_LEASE_RENEW_SCHEDULE_JOB,
driverProperties.getLease().getRenewCron(), DriverLeaseRenewScheduleJob.class);
// Create and schedule the device health job if enabled
DriverProperties.DeviceHealthProperties deviceHealth = driverProperties.getHealth().getDevice();
if (Objects.nonNull(deviceHealth) && Boolean.TRUE.equals(deviceHealth.getEnabled())) {
@@ -108,16 +116,17 @@ public class DriverScheduleServiceImpl implements DriverScheduleService {
DriverCustomScheduleJob.class);
}
// Create and schedule the buffer republish job if enabled
// The durable outbox is mandatory, so its republish job is mandatory too.
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);
if (Objects.isNull(buffer)) {
throw new IllegalStateException("Driver point-value outbox configuration is required");
}
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();
@@ -19,7 +19,6 @@ 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;
@@ -34,17 +33,19 @@ import io.github.pnoker.common.entity.dto.EventReportDTO;
import io.github.pnoker.common.entity.dto.PointCommandResultDTO;
import io.github.pnoker.common.enums.EntityStatusEnum;
import io.github.pnoker.common.utils.JsonUtil;
import jakarta.annotation.PostConstruct;
import io.github.pnoker.common.utils.RabbitPublishConfirm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/**
@@ -59,6 +60,10 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
public class DriverSenderServiceImpl implements DriverSenderService {
private static final int POINT_VALUE_SCHEMA_VERSION = 1;
private final AtomicLong pointValueSequence = new AtomicLong();
/**
* Tenant-scoped driver configuration (service name, health timeouts, etc.).
*/
@@ -75,33 +80,11 @@ public class DriverSenderServiceImpl implements DriverSenderService {
private final RabbitTemplate rabbitTemplate;
/**
* Local buffer for point values that fail to reach RabbitMQ, republished once the broker recovers.
* Durable point-value outbox. Values are committed locally before RabbitMQ publication and are
* republished until broker confirmation removes them.
*/
private final BufferService bufferService;
@PostConstruct
void init() {
rabbitTemplate.setConfirmCallback((correlation, ack, 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);
}
});
}
/**
* Publish the driver's lifecycle state to the state exchange.
*
@@ -214,50 +197,67 @@ public class DriverSenderServiceImpl implements DriverSenderService {
return;
}
DriverBO driver = driverMetadata.getDriver();
if (Objects.nonNull(driver)) {
if (Objects.isNull(entityDTO.getDriverId())) {
entityDTO.setDriverId(driver.getId());
}
if (Objects.isNull(entityDTO.getTenantId())) {
entityDTO.setTenantId(driver.getTenantId());
}
} else {
log.warn(
"DriverMetadata has no registered driver yet; point value will be published without driverId/tenantId");
if (Objects.isNull(driver)) {
log.error("Reject point value before driver registration, deviceId={}, pointId={}",
entityDTO.getDeviceId(), entityDTO.getPointId());
return;
}
if (log.isDebugEnabled()) {
log.debug("Send point value: {}", JsonUtil.toJsonString(entityDTO));
if (!stampPointValue(entityDTO, driver)) {
return;
}
String routingKey = RabbitConstant.ROUTING_POINT_VALUE_PREFIX + driverProperties.getService();
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) {
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);
}
bufferService.publish(entityDTO, routingKey);
}
private boolean stampPointValue(PointValue pointValue, DriverBO driver) {
if (Objects.isNull(pointValue.getDriverId())) {
pointValue.setDriverId(driver.getId());
}
if (Objects.isNull(pointValue.getTenantId())) {
pointValue.setTenantId(driver.getTenantId());
}
Long fencingToken = driverMetadata.getFencingToken(pointValue.getDeviceId());
if (Objects.isNull(fencingToken)) {
log.error("Reject point value without active device lease, deviceId={}, pointId={}, node={}",
pointValue.getDeviceId(), pointValue.getPointId(), driverProperties.getNode());
return false;
}
pointValue.setMessageId(UUID.randomUUID().toString());
pointValue.setSchemaVersion(POINT_VALUE_SCHEMA_VERSION);
pointValue.setDriverNode(driverProperties.getNode());
pointValue.setSequence(pointValueSequence.incrementAndGet());
pointValue.setFencingToken(fencingToken);
if (log.isDebugEnabled()) {
log.debug("Send point value: {}", JsonUtil.toJsonString(pointValue));
}
return true;
}
/**
* Publish each point value in the supplied list individually.
* Persist the supplied point values in one outbox transaction, then publish them.
*
* @param entityDTOList point value payloads, may be null
*/
@Override
public void pointValueSender(List<PointValue> entityDTOList) {
if (Objects.nonNull(entityDTOList)) {
entityDTOList.forEach(this::pointValueSender);
if (Objects.isNull(entityDTOList) || entityDTOList.isEmpty()) {
return;
}
DriverBO driver = driverMetadata.getDriver();
if (Objects.isNull(driver)) {
log.error("Reject point-value batch before driver registration, size={}", entityDTOList.size());
return;
}
List<PointValue> pointValues = new ArrayList<>(entityDTOList.size());
for (PointValue pointValue : entityDTOList) {
if (Objects.nonNull(pointValue) && stampPointValue(pointValue, driver)) {
pointValues.add(pointValue);
}
}
if (!pointValues.isEmpty()) {
bufferService.publishBatch(pointValues,
RabbitConstant.ROUTING_POINT_VALUE_PREFIX + driverProperties.getService());
}
}
@@ -271,8 +271,9 @@ public class DriverSenderServiceImpl implements DriverSenderService {
if (Objects.isNull(resultDTO)) {
return;
}
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND_RESULT,
RabbitConstant.ROUTING_POINT_COMMAND_RESULT_PREFIX + driverProperties.getService(), resultDTO);
sendConfirmed(RabbitConstant.TOPIC_EXCHANGE_POINT_COMMAND_RESULT,
RabbitConstant.ROUTING_POINT_COMMAND_RESULT_PREFIX + driverProperties.getService(),
resultDTO, resultDTO.commandId());
}
/**
@@ -285,8 +286,9 @@ public class DriverSenderServiceImpl implements DriverSenderService {
if (Objects.isNull(resultDTO)) {
return;
}
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGE_COMMAND_RESULT,
RabbitConstant.ROUTING_COMMAND_RESULT_PREFIX + driverProperties.getService(), resultDTO);
sendConfirmed(RabbitConstant.TOPIC_EXCHANGE_COMMAND_RESULT,
RabbitConstant.ROUTING_COMMAND_RESULT_PREFIX + driverProperties.getService(),
resultDTO, resultDTO.recordId());
}
/**
@@ -331,4 +333,11 @@ public class DriverSenderServiceImpl implements DriverSenderService {
deviceStateSender(deviceState);
}
private void sendConfirmed(String exchange, String routingKey, Object payload, String correlationId) {
CorrelationData correlationData = new CorrelationData(
Objects.nonNull(correlationId) ? correlationId : UUID.randomUUID().toString());
rabbitTemplate.convertAndSend(exchange, routingKey, payload, correlationData);
RabbitPublishConfirm.awaitRouted(correlationData, Duration.ofSeconds(5));
}
}
@@ -19,6 +19,7 @@ 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.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -26,26 +27,26 @@ 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.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* 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 {
@@ -55,67 +56,105 @@ class BufferServiceImplTest {
@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());
DriverProperties properties = new DriverProperties();
properties.getBuffer().setDbPath(tmp.resolve("outbox.db").toString());
properties.getBuffer().setBatchSize(10);
properties.getBuffer().setMaxRetry(3);
properties.getBuffer().setBackoffSeconds(0);
properties.getBuffer().setBackoffSeconds(1);
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));
@AfterEach
void tearDown() {
service.destroy();
}
@Test
void republishRequeuesOnAmqpExceptionThenResends() {
service.offer(pointValue(), "rk", "id-1", 1);
void publishPersistsBeforeSendAndDeletesOnlyAfterRoutedConfirm() {
doAnswer(invocation -> {
assertThat(service.pendingCount()).isEqualTo(1);
CorrelationData correlation = invocation.getArgument(3);
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
return null;
}).when(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
service.publish(pointValue("id-1"), "rk");
assertThat(service.pendingCount()).isZero();
}
@Test
void nackRetainsRecordForRetry() {
doAnswer(invocation -> {
CorrelationData correlation = invocation.getArgument(3);
correlation.getFuture().complete(new CorrelationData.Confirm(false, "broker nack"));
return null;
}).when(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
service.publish(pointValue("id-2"), "rk");
assertThat(service.pendingCount()).isEqualTo(1);
}
@Test
void returnedMessageRetainsRecordDespiteAck() {
doAnswer(invocation -> {
CorrelationData correlation = invocation.getArgument(3);
correlation.setReturned(new ReturnedMessage(new Message(new byte[0], new MessageProperties()),
312, "NO_ROUTE", "exchange", "rk"));
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
return null;
}).when(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
service.publish(pointValue("id-3"), "rk");
assertThat(service.pendingCount()).isEqualTo(1);
}
@Test
void synchronousFailureRetainsRecord() {
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));
service.publish(pointValue("id-4"), "rk");
reset(rabbitTemplate);
service.republishBatch();
verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
assertThat(service.pendingCount()).isEqualTo(1);
}
@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();
void batchPersistsEverythingBeforeFirstPublish() {
AtomicInteger sends = new AtomicInteger();
doAnswer(invocation -> {
if (sends.getAndIncrement() == 0) {
assertThat(service.pendingCount()).isEqualTo(2);
}
CorrelationData correlation = invocation.getArgument(3);
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
return null;
}).when(rabbitTemplate).convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
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));
service.publishBatch(List.of(pointValue("batch-1"), pointValue("batch-2")), "rk");
assertThat(service.pendingCount()).isZero();
verify(rabbitTemplate, times(2))
.convertAndSend(anyString(), anyString(), any(PointValue.class), any(CorrelationData.class));
}
private PointValue pointValue() {
return PointValue.builder().deviceId(1L).pointId(2L).rawValue("42").build();
@Test
void publishFailsClosedBeforeOutboxInitialization() {
BufferServiceImpl uninitialized = new BufferServiceImpl(new DriverProperties(), rabbitTemplate);
assertThatThrownBy(() -> uninitialized.publish(pointValue("id-5"), "rk"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("outbox is not initialized");
verifyNoInteractions(rabbitTemplate);
}
private PointValue pointValue(String messageId) {
return PointValue.builder().messageId(messageId).deviceId(1L).pointId(2L).rawValue("42").build();
}
}
@@ -77,19 +77,6 @@ class PointValueBufferTest {
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);
@@ -102,6 +89,39 @@ class PointValueBufferTest {
buffer.close();
}
@Test
void upsertBatchCommitsEveryRow(@TempDir Path tmp) {
PointValueBuffer buffer = newBuffer(tmp);
long now = epoch();
buffer.upsertBatch(List.of(
rec("batch-1", 10L, 20L, 0, now, now),
rec("batch-2", 11L, 21L, 0, now, now)));
assertThat(buffer.selectPending(10, now))
.extracting(BufferedPointValue::id)
.containsExactly("batch-1", "batch-2");
buffer.close();
}
@Test
void committedRowsSurviveReopen(@TempDir Path tmp) {
Path db = tmp.resolve("buffer.db");
long now = epoch();
PointValueBuffer first = new PointValueBuffer(db.toString());
first.initialize();
first.upsert(rec("durable", 10L, 20L, 0, now, now));
first.close();
PointValueBuffer reopened = new PointValueBuffer(db.toString());
reopened.initialize();
assertThat(reopened.selectPending(10, now))
.singleElement()
.extracting(BufferedPointValue::id)
.isEqualTo("durable");
reopened.close();
}
private PointValueBuffer newBuffer(Path tmp) {
PointValueBuffer buffer = new PointValueBuffer(tmp.resolve("buffer.db").toString());
buffer.initialize();
@@ -0,0 +1,127 @@
/*
* 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.grpc.client;
import io.github.pnoker.api.common.GrpcRFactory;
import io.github.pnoker.api.common.driver.DriverApiGrpc;
import io.github.pnoker.api.common.driver.GrpcDeviceLeaseDTO;
import io.github.pnoker.api.common.driver.GrpcRDriverLeaseDTO;
import io.github.pnoker.common.driver.entity.bo.DriverBO;
import io.github.pnoker.common.driver.entity.builder.DriverBuilder;
import io.github.pnoker.common.driver.entity.builder.GrpcCommandAttributeBuilder;
import io.github.pnoker.common.driver.entity.builder.GrpcDriverAttributeBuilder;
import io.github.pnoker.common.driver.entity.builder.GrpcEventAttributeBuilder;
import io.github.pnoker.common.driver.entity.builder.GrpcPointAttributeBuilder;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.exception.ServiceException;
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.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DriverClientLeaseTest {
@Mock
private DriverApiGrpc.DriverApiBlockingStub stub;
@Mock
private DriverBuilder driverBuilder;
@Mock
private GrpcDriverAttributeBuilder driverAttributeBuilder;
@Mock
private GrpcPointAttributeBuilder pointAttributeBuilder;
@Mock
private GrpcCommandAttributeBuilder commandAttributeBuilder;
@Mock
private GrpcEventAttributeBuilder eventAttributeBuilder;
private DriverMetadata metadata;
private DriverClient client;
@BeforeEach
void setUp() {
DriverProperties properties = new DriverProperties();
properties.setNode("node-a");
properties.setClient("client-a");
properties.setHost("host-a");
metadata = new DriverMetadata();
DriverBO driver = new DriverBO();
driver.setId(7L);
driver.setTenantId(100L);
metadata.setDriver(driver);
metadata.setDeviceLeases(Map.of(99L, 400L), System.currentTimeMillis() + 60_000, 4L);
client = new DriverClient(stub, metadata, properties, driverBuilder, driverAttributeBuilder,
pointAttributeBuilder, commandAttributeBuilder, eventAttributeBuilder);
}
@Test
void changedLeaseSnapshotIsInstalledOnlyAfterAllBatchesComplete() {
long deadline = System.currentTimeMillis() + 60_000;
when(stub.renewLease(any())).thenReturn(List.of(
response(deadline, 5L, false, lease(1L, 501L)),
response(deadline, 5L, true, lease(2L, 502L))).iterator());
client.renewLease();
assertThat(metadata.getDeviceIds()).containsExactlyInAnyOrder(1L, 2L);
assertThat(metadata.getFencingToken(1L)).isEqualTo(501L);
assertThat(metadata.getAssignmentVersion()).isEqualTo(5L);
}
@Test
void incompleteLeaseSnapshotDoesNotReplaceCurrentOwnership() {
long deadline = System.currentTimeMillis() + 60_000;
when(stub.renewLease(any())).thenReturn(List.of(
response(deadline, 5L, false, lease(1L, 501L))).iterator());
assertThatThrownBy(client::renewLease)
.isInstanceOf(ServiceException.class)
.hasMessageContaining("before snapshot completion");
assertThat(metadata.getDeviceIds()).containsExactly(99L);
assertThat(metadata.getAssignmentVersion()).isEqualTo(4L);
}
private GrpcRDriverLeaseDTO response(long deadline, long version, boolean complete,
GrpcDeviceLeaseDTO lease) {
return GrpcRDriverLeaseDTO.newBuilder()
.setResult(GrpcRFactory.ok())
.setLeaseUntilEpochMillis(deadline)
.setAssignmentVersion(version)
.setAssignmentsChanged(true)
.setSnapshotComplete(complete)
.addDeviceLeases(lease)
.build();
}
private GrpcDeviceLeaseDTO lease(long deviceId, long fencingToken) {
return GrpcDeviceLeaseDTO.newBuilder()
.setDeviceId(deviceId)
.setFencingToken(fencingToken)
.build();
}
}
@@ -35,7 +35,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.quartz.JobExecutionContext;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.mockito.ArgumentMatchers.any;
@@ -86,7 +85,7 @@ class DeviceHealthScheduleJobTest {
@Test
void reportsOnlineWhenDeviceHealthReturnsOnline() {
DeviceBO device = enabledDevice(10L);
driverMetadata.setDeviceIds(Set.of(10L));
installDeviceLease(10L);
when(deviceMetadata.getCache(10L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(10L)).thenReturn(Map.of());
when(driverCustomService.health(Map.of(), device)).thenReturn(DeviceHealthState.online());
@@ -100,7 +99,7 @@ class DeviceHealthScheduleJobTest {
@Test
void reportsOfflineWhenDeviceHealthReturnsOffline() {
DeviceBO device = enabledDevice(11L);
driverMetadata.setDeviceIds(Set.of(11L));
installDeviceLease(11L);
when(deviceMetadata.getCache(11L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(11L)).thenReturn(Map.of());
when(driverCustomService.health(Map.of(), device)).thenReturn(DeviceHealthState.offline());
@@ -114,7 +113,7 @@ class DeviceHealthScheduleJobTest {
@Test
void reportsFaultWhenDeviceHealthReturnsFault() {
DeviceBO device = enabledDevice(16L);
driverMetadata.setDeviceIds(Set.of(16L));
installDeviceLease(16L);
when(deviceMetadata.getCache(16L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(16L)).thenReturn(Map.of());
when(driverCustomService.health(Map.of(), device)).thenReturn(DeviceHealthState.fault());
@@ -128,7 +127,7 @@ class DeviceHealthScheduleJobTest {
@Test
void reportsOfflineWhenDeviceHealthThrows() {
DeviceBO device = enabledDevice(12L);
driverMetadata.setDeviceIds(Set.of(12L));
installDeviceLease(12L);
when(deviceMetadata.getCache(12L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(12L)).thenReturn(Map.of());
doThrow(new IllegalStateException("session down")).when(driverCustomService).health(Map.of(), device);
@@ -142,7 +141,7 @@ class DeviceHealthScheduleJobTest {
@Test
void reportsDeviceSpecificTimeoutWhenProvidedByHealthHook() {
DeviceBO device = enabledDevice(15L);
driverMetadata.setDeviceIds(Set.of(15L));
installDeviceLease(15L);
when(deviceMetadata.getCache(15L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(15L)).thenReturn(Map.of());
when(driverCustomService.health(Map.of(), device))
@@ -158,7 +157,7 @@ class DeviceHealthScheduleJobTest {
void skipsDisabledDevice() {
DeviceBO device = enabledDevice(13L);
device.setEnableFlag(EnableFlagEnum.DISABLE);
driverMetadata.setDeviceIds(Set.of(13L));
installDeviceLease(13L);
when(deviceMetadata.getCache(13L)).thenReturn(device);
job.executeInternal(jobContext);
@@ -170,7 +169,7 @@ class DeviceHealthScheduleJobTest {
void skipsDeviceWhenRequiredDriverConfigIsIncomplete() {
driverMetadata.getDriverAttributeIdMap().put(1L, new DriverAttributeDTO());
DeviceBO device = enabledDevice(14L);
driverMetadata.setDeviceIds(Set.of(14L));
installDeviceLease(14L);
when(deviceMetadata.getCache(14L)).thenReturn(device);
when(deviceMetadata.getDriverConfig(14L)).thenReturn(Map.of());
@@ -180,4 +179,8 @@ class DeviceHealthScheduleJobTest {
verifyNoInteractions(driverSenderService);
}
private void installDeviceLease(long deviceId) {
driverMetadata.setDeviceLeases(Map.of(deviceId, 1L), System.currentTimeMillis() + 60_000, 1L);
}
}
@@ -30,6 +30,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
@@ -66,7 +67,7 @@ class DriverReadScheduleJobTest {
@Test
void enabledPointsAreSubmittedToExecutor() {
driverMetadata.setDeviceIds(Set.of(10L));
driverMetadata.setDeviceLeases(Map.of(10L, 1L), System.currentTimeMillis() + 60_000, 1L);
when(deviceMetadata.getCache(10L)).thenReturn(readableDevice(10L, Set.of(20L, 21L)));
job.executeInternal(null);
@@ -77,7 +78,7 @@ class DriverReadScheduleJobTest {
@Test
void disabledDeviceIsSkipped() {
driverMetadata.setDeviceIds(Set.of(10L));
driverMetadata.setDeviceLeases(Map.of(10L, 1L), System.currentTimeMillis() + 60_000, 1L);
DeviceBO device = readableDevice(10L, Set.of(20L));
device.setEnableFlag(EnableFlagEnum.DISABLE);
when(deviceMetadata.getCache(10L)).thenReturn(device);
@@ -27,7 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -51,7 +51,8 @@ class DeviceMetadataTest {
driverProperties = new DriverProperties();
driverProperties.getMetadata().getCache().setRecordStats(true);
driverMetadata = new DriverMetadata();
driverMetadata.setDeviceIds(new HashSet<>(Set.of(10L, 11L)));
driverMetadata.setDeviceLeases(Map.of(10L, 1L, 11L, 2L),
System.currentTimeMillis() + 60_000, 1L);
deviceMetadata = new DeviceMetadata(driverProperties, driverMetadata, deviceClient);
}
@@ -0,0 +1,43 @@
/*
* 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.metadata;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DriverMetadataLeaseTest {
@Test
void exposesOwnedDevicesOnlyWhileLeaseIsValid() {
DriverMetadata metadata = new DriverMetadata();
metadata.setDeviceLeases(Map.of(10L, 77L), System.currentTimeMillis() + 10_000, 5L);
assertThat(metadata.getDeviceIds()).containsExactly(10L);
assertThat(metadata.getFencingToken(10L)).isEqualTo(77L);
assertThat(metadata.getAssignmentVersion()).isEqualTo(5L);
metadata.renewLeaseDeadline(System.currentTimeMillis() - 1);
assertThat(metadata.getDeviceIds()).isEmpty();
assertThat(metadata.getFencingToken(10L)).isNull();
assertThat(metadata.getAssignmentVersion()).isEqualTo(5L);
}
}
@@ -23,6 +23,8 @@ import io.github.pnoker.common.driver.command.DeviceLockManager;
import io.github.pnoker.common.driver.entity.bo.AttributeBO;
import io.github.pnoker.common.driver.entity.bo.DeviceBO;
import io.github.pnoker.common.driver.metadata.DeviceMetadata;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.service.DriverCustomService;
import io.github.pnoker.common.driver.service.DriverSenderService;
import io.github.pnoker.common.entity.dto.CommandCallDTO;
@@ -46,6 +48,7 @@ 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.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -68,6 +71,9 @@ class CommandReceiverTest {
@Mock
private CommandDedupCache dedupCache;
@Mock
private DriverMetadata driverMetadata;
@Mock
private Channel channel;
@@ -76,8 +82,11 @@ class CommandReceiverTest {
@BeforeEach
void setUp() {
DriverProperties properties = new DriverProperties();
properties.setNode("node-a");
receiver = new CommandReceiver(driverCustomService, driverSenderService, commandFacade,
deviceMetadata, dedupCache, new DeviceLockManager());
deviceMetadata, dedupCache, new DeviceLockManager(), driverMetadata, properties);
lenient().when(driverMetadata.getFencingToken(10L)).thenReturn(77L);
MessageProperties props = new MessageProperties();
props.setDeliveryTag(9L);
@@ -182,6 +191,8 @@ class CommandReceiverTest {
return CommandCallDTO.builder()
.recordId(recordId)
.tenantId(100L)
.ownerNode("node-a")
.fencingToken(77L)
.deviceId(10L)
.commandId(20L)
.paramValues(Map.of("setpoint", "42"))
@@ -38,8 +38,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import java.util.HashSet;
import java.util.Set;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
@@ -84,7 +83,7 @@ class MetadataReceiverTest {
@BeforeEach
void setUp() {
driverMetadata = new DriverMetadata();
driverMetadata.setDeviceIds(new HashSet<>(Set.of(99L)));
driverMetadata.setDeviceLeases(Map.of(99L, 1L), System.currentTimeMillis() + 60_000, 1L);
receiver = new MetadataReceiver(pointMetadata, driverMetadata, deviceMetadata, driverClient, metadataEventPublisher);
@@ -110,11 +109,11 @@ class MetadataReceiverTest {
}
@Test
void deviceAddTriggersLoadCacheAndAddsToDriverDeviceIds() throws Exception {
void deviceAddRefreshesCacheWithoutBypassingLeaseOwnership() throws Exception {
MetadataEventDTO dto = event(MetadataTypeEnum.DEVICE, MetadataOperateTypeEnum.ADD, 10L);
receiver.metadataReceive(channel, message, dto);
verify(deviceMetadata).loadCache(10L);
assertThat(driverMetadata.getDeviceIds()).contains(10L);
assertThat(driverMetadata.getDeviceIds()).containsExactly(99L);
verify(metadataEventPublisher).publishEvent(org.mockito.ArgumentMatchers.any(MetadataEvent.class));
verify(channel).basicAck(eq(7L), eq(false));
}
@@ -129,7 +128,7 @@ class MetadataReceiverTest {
@Test
void deviceDeleteRemovesCacheAndDriverDeviceIds() throws Exception {
driverMetadata.addDeviceId(99L);
driverMetadata.setDeviceLeases(Map.of(99L, 1L), System.currentTimeMillis() + 60_000, 1L);
MetadataEventDTO dto = event(MetadataTypeEnum.DEVICE, MetadataOperateTypeEnum.DELETE, 99L);
receiver.metadataReceive(channel, message, dto);
verify(deviceMetadata).removeCache(99L);
@@ -225,10 +224,9 @@ class MetadataReceiverTest {
// gRPC failure must surface as nack(requeue) rather than ack earlier the
// loader was fire-and-forget and a failure silently dropped the event.
verify(channel).basicNack(eq(7L), eq(false), eq(true));
// deviceId is added before loadCache so that a Quartz scan racing with the
// refresh sees a consistent view; on failure the id stays so the requeued
// event can retry, and a confirmed-null upstream will clean it via postLoad.
assertThat(driverMetadata.getDeviceIds()).contains(10L);
// Metadata events never create ownership; only a complete Manager lease
// snapshot can install a device and fencing token.
assertThat(driverMetadata.getDeviceIds()).containsExactly(99L);
verify(metadataEventPublisher, never()).publishEvent(any());
}
}
@@ -20,6 +20,8 @@ package io.github.pnoker.common.driver.receiver.rabbit;
import com.rabbitmq.client.Channel;
import io.github.pnoker.common.driver.command.CommandDedupCache;
import io.github.pnoker.common.driver.command.DeviceLockManager;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import io.github.pnoker.common.driver.entity.property.DriverProperties;
import io.github.pnoker.common.driver.service.DriverReadService;
import io.github.pnoker.common.driver.service.DriverSenderService;
import io.github.pnoker.common.driver.service.DriverWriteService;
@@ -70,6 +72,9 @@ class PointCommandReceiverTest {
@Mock
private DeviceLockManager deviceLockManager;
@Mock
private DriverMetadata driverMetadata;
@Mock
private Channel channel;
@@ -78,8 +83,11 @@ class PointCommandReceiverTest {
@BeforeEach
void setUp() {
DriverProperties properties = new DriverProperties();
properties.setNode("node-a");
receiver = new PointCommandReceiver(driverReadService, driverWriteService,
driverSenderService, dedupCache, deviceLockManager);
driverSenderService, dedupCache, deviceLockManager, driverMetadata, properties);
lenient().when(driverMetadata.getFencingToken(10L)).thenReturn(77L);
// DeviceLockManager executes the supplier inline
lenient().when(deviceLockManager.runExclusive(anyLong(), ArgumentMatchers.<Supplier<String>>any()))
@@ -91,14 +99,14 @@ class PointCommandReceiverTest {
}
private PointCommandDTO readCommand(String commandId) {
return new PointCommandDTO(commandId, 100L, PointCommandTypeEnum.READ,
return new PointCommandDTO(commandId, 100L, "node-a", 77L, PointCommandTypeEnum.READ,
new PointCommandPayload.ReadPayload(10L, 20L),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now(), Instant.now().plusSeconds(10), 1);
}
private PointCommandDTO writeCommand(String commandId) {
return new PointCommandDTO(commandId, 100L, PointCommandTypeEnum.WRITE,
return new PointCommandDTO(commandId, 100L, "node-a", 77L, PointCommandTypeEnum.WRITE,
new PointCommandPayload.WritePayload(10L, 20L, "42"),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now(), Instant.now().plusSeconds(10), 1);
@@ -146,7 +154,7 @@ class PointCommandReceiverTest {
@Test
void rejectsPayloadWithNullType() throws Exception {
PointCommandDTO dto = new PointCommandDTO("id", 100L, null,
PointCommandDTO dto = new PointCommandDTO("id", 100L, "node-a", 77L, null,
new PointCommandPayload.ReadPayload(10L, 20L),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now(), Instant.now().plusSeconds(10), 1);
@@ -165,7 +173,7 @@ class PointCommandReceiverTest {
@Test
void rejectsPayloadWithNullTenantId() throws Exception {
PointCommandDTO dto = new PointCommandDTO("id", null, PointCommandTypeEnum.READ,
PointCommandDTO dto = new PointCommandDTO("id", null, "node-a", 77L, PointCommandTypeEnum.READ,
new PointCommandPayload.ReadPayload(10L, 20L),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now(), Instant.now().plusSeconds(10), 1);
@@ -192,7 +200,7 @@ class PointCommandReceiverTest {
@Test
void rejectsReadPayloadWithNullDeviceId() throws Exception {
PointCommandDTO dto = new PointCommandDTO("bad-read", 100L, PointCommandTypeEnum.READ,
PointCommandDTO dto = new PointCommandDTO("bad-read", 100L, "node-a", 77L, PointCommandTypeEnum.READ,
new PointCommandPayload.ReadPayload(null, 20L),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now(), Instant.now().plusSeconds(10), 1);
@@ -220,7 +228,7 @@ class PointCommandReceiverTest {
@Test
void expiredCommandSendsExpiredResult() throws Exception {
PointCommandDTO expired = new PointCommandDTO("exp-cmd", 100L, PointCommandTypeEnum.READ,
PointCommandDTO expired = new PointCommandDTO("exp-cmd", 100L, "node-a", 77L, PointCommandTypeEnum.READ,
new PointCommandPayload.ReadPayload(10L, 20L),
io.github.pnoker.common.enums.PointCommandSourceEnum.HTTP, null,
Instant.now().minusSeconds(60), Instant.now().minusSeconds(30), 1);
@@ -19,9 +19,11 @@ 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;
import io.github.pnoker.common.driver.job.DriverLeaseRenewScheduleJob;
import io.github.pnoker.common.driver.job.DriverReadScheduleJob;
import io.github.pnoker.common.exception.CronException;
import io.github.pnoker.common.quartz.QuartzService;
@@ -32,7 +34,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.quartz.SchedulerException;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -57,9 +58,11 @@ class DriverScheduleServiceImplTest {
}
@Test
void initialNoOpsWhenScheduleConfigMissing() {
void initialFailsClosedWhenScheduleConfigMissing() {
properties.setSchedule(null);
assertThatNoException().isThrownBy(() -> service.initialize());
assertThatThrownBy(() -> service.initialize())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("schedule configuration is required");
verifyNoInteractions(quartzService);
}
@@ -73,9 +76,37 @@ class DriverScheduleServiceImplTest {
eq(ScheduleConstant.DRIVER_HEALTH_SCHEDULE_JOB),
eq(ScheduleConstant.DRIVER_HEALTH_SCHEDULE_CRON),
eq(DriverHealthScheduleJob.class));
verify(quartzService).createJobWithCron(
eq(ScheduleConstant.DRIVER_SCHEDULE_GROUP),
eq(ScheduleConstant.DRIVER_LEASE_RENEW_SCHEDULE_JOB),
eq(properties.getLease().getRenewCron()),
eq(DriverLeaseRenewScheduleJob.class));
verify(quartzService).createJobWithCron(
eq(ScheduleConstant.DRIVER_SCHEDULE_GROUP),
eq(ScheduleConstant.BUFFER_REPUBLISH_SCHEDULE_JOB),
eq(properties.getBuffer().getRepublishCron()),
eq(BufferRepublishScheduleJob.class));
verify(quartzService).startScheduler();
}
@Test
void initialRejectsMissingOutboxConfig() {
properties.setBuffer(null);
assertThatThrownBy(() -> service.initialize())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("outbox configuration is required");
}
@Test
void initialRejectsInvalidOutboxCron() {
properties.getBuffer().setRepublishCron("not-a-cron");
assertThatThrownBy(() -> service.initialize())
.isInstanceOf(CronException.class)
.hasMessageContaining("Buffer republish schedule");
}
@Test
void initialRegistersHealthJobWhenEnabled() throws Exception {
DriverProperties.ScheduleProperties s = new DriverProperties.ScheduleProperties();
@@ -0,0 +1,122 @@
/*
* 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.service.impl;
import io.github.pnoker.common.driver.buffer.BufferService;
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;
import io.github.pnoker.common.driver.metadata.DriverMetadata;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.util.Map;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class DriverSenderServiceImplTest {
@Mock
private RabbitTemplate rabbitTemplate;
@Mock
private BufferService bufferService;
private DriverMetadata metadata;
private DriverSenderServiceImpl service;
@BeforeEach
void setUp() {
DriverProperties properties = new DriverProperties();
properties.setNode("node-a");
properties.setService("tenant/driver");
metadata = new DriverMetadata();
DriverBO driver = new DriverBO();
driver.setId(20L);
driver.setTenantId(1L);
metadata.setDriver(driver);
metadata.setDeviceLeases(Map.of(10L, 77L), System.currentTimeMillis() + 10_000, 5L);
service = new DriverSenderServiceImpl(properties, metadata, rabbitTemplate, bufferService);
}
@Test
void stampsStableWireIdentityAndFenceBeforeOutboxPublish() {
PointValue value = PointValue.builder()
.deviceId(10L)
.pointId(30L)
.rawValue("42")
.calValue("42")
.build();
service.pointValueSender(value);
ArgumentCaptor<PointValue> captor = ArgumentCaptor.forClass(PointValue.class);
verify(bufferService).publish(captor.capture(),
org.mockito.ArgumentMatchers.eq("dc3.r.value.point.tenant/driver"));
PointValue sent = captor.getValue();
assertThat(sent.getMessageId()).isNotBlank();
assertThat(sent.getSchemaVersion()).isEqualTo(1);
assertThat(sent.getDriverNode()).isEqualTo("node-a");
assertThat(sent.getSequence()).isPositive();
assertThat(sent.getFencingToken()).isEqualTo(77L);
assertThat(sent.getDriverId()).isEqualTo(20L);
assertThat(sent.getTenantId()).isEqualTo(1L);
}
@Test
void rejectsTelemetryAfterLeaseExpiry() {
metadata.renewLeaseDeadline(System.currentTimeMillis() - 1);
service.pointValueSender(PointValue.builder().deviceId(10L).pointId(30L).rawValue("42").build());
verify(bufferService, never()).publish(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void stampsAndPersistsListAsOneOutboxBatch() {
PointValue first = PointValue.builder().deviceId(10L).pointId(30L).rawValue("1").build();
PointValue second = PointValue.builder().deviceId(10L).pointId(31L).rawValue("2").build();
service.pointValueSender(List.of(first, second));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<PointValue>> captor = ArgumentCaptor.forClass(List.class);
verify(bufferService).publishBatch(captor.capture(),
org.mockito.ArgumentMatchers.eq("dc3.r.value.point.tenant/driver"));
assertThat(captor.getValue()).hasSize(2);
assertThat(first.getMessageId()).isNotBlank().isNotEqualTo(second.getMessageId());
assertThat(first.getSequence()).isLessThan(second.getSequence());
assertThat(captor.getValue()).allSatisfy(value -> {
assertThat(value.getFencingToken()).isEqualTo(77L);
assertThat(value.getDriverId()).isEqualTo(20L);
assertThat(value.getTenantId()).isEqualTo(1L);
});
verify(bufferService, never()).publish(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString());
}
}
@@ -18,6 +18,7 @@
package io.github.pnoker.common.facade.api;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery;
@@ -52,6 +53,9 @@ public interface DeviceFacade {
*/
FacadeDeviceBO getById(Long tenantId, Long id);
/** Resolve an active, fenced runtime owner or return {@code null}. */
FacadeDeviceOwnerBO getActiveOwner(Long tenantId, Long deviceId);
/**
* Tenant-scoped bulk lookup. Missing or cross-tenant devices are omitted.
*/
@@ -0,0 +1,22 @@
/*
* 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.facade.entity.bo;
/** Active driver runtime owner used for targeted, fenced commands. */
public record FacadeDeviceOwnerBO(Long driverId, String ownerNode, Long fencingToken) {
}
@@ -25,6 +25,7 @@ import io.github.pnoker.api.center.manager.GrpcPageDeviceQuery;
import io.github.pnoker.api.center.manager.GrpcProfileQuery;
import io.github.pnoker.api.center.manager.GrpcRDeviceDTO;
import io.github.pnoker.api.center.manager.GrpcRDeviceListDTO;
import io.github.pnoker.api.center.manager.GrpcRDeviceOwnerDTO;
import io.github.pnoker.api.center.manager.GrpcRPageDeviceDTO;
import io.github.pnoker.api.common.GrpcDriverQuery;
import io.github.pnoker.api.common.GrpcR;
@@ -32,6 +33,7 @@ import io.github.pnoker.common.enums.ErrorCode;
import io.github.pnoker.common.exception.ServiceException;
import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery;
import io.github.pnoker.common.facade.grpc.builder.FacadeGrpcDeviceBuilder;
@@ -78,6 +80,20 @@ public class DeviceGrpcFacade implements DeviceFacade {
return facadeGrpcDeviceBuilder.toFacadeBO(response.getData());
}
@Override
public FacadeDeviceOwnerBO getActiveOwner(Long tenantId, Long deviceId) {
GrpcDeviceQuery request = GrpcDeviceQuery.newBuilder()
.setDeviceId(deviceId).setTenantId(tenantId).build();
GrpcRDeviceOwnerDTO response = grpcFacadeSupport.call("DeviceFacade.getActiveOwner",
deviceApiBlockingStub, stub -> stub.getActiveOwner(request));
if (!response.getResult().getOk()) {
guardOrThrow(response.getResult(), "getActiveOwner");
return null;
}
return new FacadeDeviceOwnerBO(response.getDriverId(), response.getOwnerNode(),
response.getFencingToken());
}
@Override
public List<FacadeDeviceBO> listByIds(Long tenantId, Collection<Long> ids) {
if (Objects.isNull(ids) || ids.isEmpty()) {
@@ -20,10 +20,13 @@ package io.github.pnoker.common.facade.local;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.pnoker.common.facade.api.DeviceFacade;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO;
import io.github.pnoker.common.facade.entity.bo.FacadeDeviceOwnerBO;
import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery;
import io.github.pnoker.common.facade.local.builder.FacadeDeviceBuilder;
import io.github.pnoker.common.manager.entity.bo.DeviceBO;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.entity.query.DeviceQuery;
import io.github.pnoker.common.manager.service.DeviceService;
import io.github.pnoker.common.tenant.TenantContextHolder;
@@ -56,6 +59,8 @@ public class DeviceLocalFacade implements DeviceFacade {
private final FacadeDeviceBuilder facadeDeviceBuilder;
private final DriverLeaseService driverLeaseService;
@Override
public FacadeDeviceBO getById(Long tenantId, Long id) {
TenantContextHolder.setTenantId(tenantId);
@@ -67,6 +72,18 @@ public class DeviceLocalFacade implements DeviceFacade {
}
}
@Override
public FacadeDeviceOwnerBO getActiveOwner(Long tenantId, Long deviceId) {
TenantContextHolder.setTenantId(tenantId);
try {
DeviceLeaseBO owner = driverLeaseService.getActiveOwner(tenantId, deviceId);
return owner == null ? null
: new FacadeDeviceOwnerBO(owner.driverId(), owner.ownerNode(), owner.fencingToken());
} finally {
TenantContextHolder.clear();
}
}
@Override
public List<FacadeDeviceBO> listByIds(Long tenantId, Collection<Long> ids) {
TenantContextHolder.setTenantId(tenantId);
@@ -23,6 +23,7 @@ import io.github.pnoker.common.facade.entity.common.FacadePage;
import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery;
import io.github.pnoker.common.facade.local.builder.FacadeDeviceBuilder;
import io.github.pnoker.common.manager.entity.bo.DeviceBO;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.entity.query.DeviceQuery;
import io.github.pnoker.common.manager.service.DeviceService;
import io.github.pnoker.common.tenant.TenantContextHolder;
@@ -53,6 +54,9 @@ class DeviceLocalFacadeTest {
@Mock
private FacadeDeviceBuilder facadeDeviceBuilder;
@Mock
private DriverLeaseService driverLeaseService;
private DeviceLocalFacade facade;
private static <T> T any() {
@@ -65,7 +69,7 @@ class DeviceLocalFacadeTest {
@BeforeEach
void setUp() {
facade = new DeviceLocalFacade(deviceService, facadeDeviceBuilder);
facade = new DeviceLocalFacade(deviceService, facadeDeviceBuilder, driverLeaseService);
}
@AfterEach
@@ -0,0 +1,35 @@
/*
* 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.manager.biz;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.entity.bo.DriverLeaseGrantBO;
import java.util.List;
public interface DriverLeaseService {
DriverLeaseGrantBO renew(Long tenantId, Long driverId, String node, String client,
String host, int leaseSeconds, long knownAssignmentVersion);
List<DeviceLeaseBO> listOwnedLeases(Long tenantId, Long driverId, String node,
long afterDeviceId, int limit);
long getAssignmentVersion(Long tenantId, Long driverId);
DeviceLeaseBO getActiveOwner(Long tenantId, Long deviceId);
}
@@ -0,0 +1,202 @@
/*
* 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.manager.biz.impl;
import io.github.pnoker.common.exception.ServiceException;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.dal.DriverLeaseManager;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.entity.bo.DriverLeaseGrantBO;
import io.github.pnoker.common.manager.entity.model.DeviceLeaseDO;
import io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class DriverLeaseServiceImpl implements DriverLeaseService {
private static final int MIN_LEASE_SECONDS = 10;
private static final int MAX_LEASE_SECONDS = 120;
private static final long EXPIRED_INSTANCE_RETENTION_SECONDS = 86_400;
private static final int RECONCILE_PAGE_SIZE = 5_000;
private static final int MAX_ASSIGNMENT_PAGE_SIZE = 2_000;
private static final ThreadLocal<MessageDigest> SHA_256 = ThreadLocal.withInitial(() -> {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
});
private final DriverLeaseManager driverLeaseManager;
@Override
@Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class)
public DriverLeaseGrantBO renew(Long tenantId, Long driverId, String node, String client,
String host, int leaseSeconds, long knownAssignmentVersion) {
validate(tenantId, driverId, node, client, host, leaseSeconds);
Instant leaseUntil = Instant.now().plusSeconds(leaseSeconds);
// Serialize membership and assignment changes per logical driver. This makes
// fencing-token increments deterministic even when every replica heartbeats at once.
driverLeaseManager.acquireDriverLock(driverId);
driverLeaseManager.renewInstance(tenantId, driverId, node, client, host, leaseUntil);
driverLeaseManager.deleteExpiredInstances(tenantId, driverId,
Instant.now().minusSeconds(EXPIRED_INSTANCE_RETENTION_SECONDS));
List<String> activeNodes = driverLeaseManager.listActiveNodes(tenantId, driverId);
if (activeNodes.isEmpty()) {
throw new ServiceException("No active driver instance after lease renewal");
}
String membershipHash = membershipHash(activeNodes);
long deviceRevision = driverLeaseManager.getDeviceRevision(tenantId, driverId);
DriverLeaseStateDO state = driverLeaseManager.getLeaseState(tenantId, driverId);
boolean reconcile = state == null || !Objects.equals(state.getMembershipHash(), membershipHash)
|| !Objects.equals(state.getDeviceRevision(), deviceRevision);
long assignmentVersion;
if (reconcile) {
reconcileDeviceLeases(tenantId, driverId, activeNodes);
driverLeaseManager.deleteOrphanedLeases(tenantId, driverId);
assignmentVersion = driverLeaseManager.advanceAssignmentVersion(
tenantId, driverId, membershipHash, deviceRevision);
} else {
assignmentVersion = state.getAssignmentVersion();
}
boolean assignmentsChanged = knownAssignmentVersion != assignmentVersion;
return new DriverLeaseGrantBO(leaseUntil.toEpochMilli(), assignmentVersion, assignmentsChanged);
}
@Override
public List<DeviceLeaseBO> listOwnedLeases(Long tenantId, Long driverId, String node,
long afterDeviceId, int limit) {
if (tenantId == null || tenantId <= 0 || driverId == null || driverId <= 0
|| node == null || node.isBlank() || afterDeviceId < 0
|| limit < 1 || limit > MAX_ASSIGNMENT_PAGE_SIZE) {
throw new ServiceException("Invalid driver assignment page request");
}
return driverLeaseManager.listOwnedLeases(
tenantId, driverId, node, afterDeviceId, limit).stream()
.map(value -> new DeviceLeaseBO(value.getDriverId(), value.getDeviceId(), value.getOwnerNode(),
value.getFencingToken()))
.toList();
}
@Override
public long getAssignmentVersion(Long tenantId, Long driverId) {
if (tenantId == null || tenantId <= 0 || driverId == null || driverId <= 0) {
throw new ServiceException("Invalid driver assignment identity");
}
DriverLeaseStateDO state = driverLeaseManager.getLeaseState(tenantId, driverId);
if (state == null || state.getAssignmentVersion() == null) {
throw new ServiceException("Driver assignment state does not exist");
}
return state.getAssignmentVersion();
}
@Override
public DeviceLeaseBO getActiveOwner(Long tenantId, Long deviceId) {
if (tenantId == null || tenantId <= 0 || deviceId == null || deviceId <= 0) {
return null;
}
DeviceLeaseDO lease = driverLeaseManager.getActiveLease(tenantId, deviceId);
return lease == null ? null
: new DeviceLeaseBO(lease.getDriverId(), lease.getDeviceId(), lease.getOwnerNode(),
lease.getFencingToken());
}
private String selectOwner(Long deviceId, List<String> activeNodes) {
String selected = null;
byte[] highest = null;
MessageDigest digest = SHA_256.get();
for (String node : activeNodes) {
digest.reset();
byte[] score = digest.digest((deviceId + "|" + node).getBytes(StandardCharsets.UTF_8));
if (highest == null || compareUnsigned(score, highest) > 0) {
highest = score;
selected = node;
}
}
return selected;
}
private void reconcileDeviceLeases(Long tenantId, Long driverId, List<String> activeNodes) {
long afterDeviceId = 0;
while (true) {
List<Long> deviceIds = driverLeaseManager.listDriverDeviceIds(
tenantId, driverId, afterDeviceId, RECONCILE_PAGE_SIZE);
if (deviceIds.isEmpty()) {
return;
}
List<DeviceLeaseDO> assignments = new ArrayList<>(deviceIds.size());
for (Long deviceId : deviceIds) {
assignments.add(new DeviceLeaseDO(tenantId, driverId, deviceId,
selectOwner(deviceId, activeNodes), null));
}
driverLeaseManager.reconcileDeviceLeases(assignments);
if (deviceIds.size() < RECONCILE_PAGE_SIZE) {
return;
}
afterDeviceId = deviceIds.getLast();
}
}
private String membershipHash(List<String> activeNodes) {
MessageDigest digest = SHA_256.get();
digest.reset();
return HexFormat.of().formatHex(digest.digest(String.join("\u0000", activeNodes)
.getBytes(StandardCharsets.UTF_8)));
}
private int compareUnsigned(byte[] left, byte[] right) {
for (int i = 0; i < left.length; i++) {
int comparison = Integer.compare(Byte.toUnsignedInt(left[i]), Byte.toUnsignedInt(right[i]));
if (comparison != 0) {
return comparison;
}
}
return 0;
}
private void validate(Long tenantId, Long driverId, String node, String client,
String host, int leaseSeconds) {
if (tenantId == null || tenantId <= 0 || driverId == null || driverId <= 0
|| node == null || node.isBlank() || client == null || client.isBlank()
|| host == null || host.isBlank()) {
throw new ServiceException("Invalid driver lease identity");
}
if (leaseSeconds < MIN_LEASE_SECONDS || leaseSeconds > MAX_LEASE_SECONDS) {
throw new ServiceException("Driver lease seconds must be between {} and {}",
MIN_LEASE_SECONDS, MAX_LEASE_SECONDS);
}
}
}
@@ -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.manager.dal;
import io.github.pnoker.common.manager.entity.model.DeviceLeaseDO;
import io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO;
import java.time.Instant;
import java.util.List;
public interface DriverLeaseManager {
void acquireDriverLock(Long driverId);
void renewInstance(Long tenantId, Long driverId, String node, String client,
String host, Instant leaseUntil);
List<String> listActiveNodes(Long tenantId, Long driverId);
List<Long> listDriverDeviceIds(Long tenantId, Long driverId, Long afterDeviceId, int limit);
DriverLeaseStateDO getLeaseState(Long tenantId, Long driverId);
long getDeviceRevision(Long tenantId, Long driverId);
long advanceAssignmentVersion(Long tenantId, Long driverId, String membershipHash, long deviceRevision);
void deleteExpiredInstances(Long tenantId, Long driverId, Instant expiredBefore);
void reconcileDeviceLeases(List<DeviceLeaseDO> leases);
void deleteOrphanedLeases(Long tenantId, Long driverId);
List<DeviceLeaseDO> listOwnedLeases(Long tenantId, Long driverId, String node,
Long afterDeviceId, int limit);
DeviceLeaseDO getActiveLease(Long tenantId, Long deviceId);
}
@@ -0,0 +1,103 @@
/*
* 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.manager.dal.impl;
import io.github.pnoker.common.manager.dal.DriverLeaseManager;
import io.github.pnoker.common.manager.entity.model.DeviceLeaseDO;
import io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO;
import io.github.pnoker.common.manager.mapper.DriverLeaseMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiredArgsConstructor
public class DriverLeaseManagerImpl implements DriverLeaseManager {
private static final int WRITE_BATCH_SIZE = 1000;
private final DriverLeaseMapper driverLeaseMapper;
@Override
public void acquireDriverLock(Long driverId) {
driverLeaseMapper.acquireDriverLock(driverId);
}
@Override
public void renewInstance(Long tenantId, Long driverId, String node, String client,
String host, Instant leaseUntil) {
driverLeaseMapper.upsertInstance(tenantId, driverId, node, client, host, leaseUntil);
}
@Override
public List<String> listActiveNodes(Long tenantId, Long driverId) {
return driverLeaseMapper.listActiveNodes(tenantId, driverId);
}
@Override
public List<Long> listDriverDeviceIds(Long tenantId, Long driverId, Long afterDeviceId, int limit) {
return driverLeaseMapper.listDriverDeviceIds(tenantId, driverId, afterDeviceId, limit);
}
@Override
public DriverLeaseStateDO getLeaseState(Long tenantId, Long driverId) {
return driverLeaseMapper.selectLeaseState(tenantId, driverId);
}
@Override
public long getDeviceRevision(Long tenantId, Long driverId) {
Long revision = driverLeaseMapper.selectDeviceRevision(tenantId, driverId);
return revision == null ? 0L : revision;
}
@Override
public long advanceAssignmentVersion(Long tenantId, Long driverId, String membershipHash, long deviceRevision) {
return driverLeaseMapper.upsertLeaseState(tenantId, driverId, membershipHash, deviceRevision);
}
@Override
public void deleteExpiredInstances(Long tenantId, Long driverId, Instant expiredBefore) {
driverLeaseMapper.deleteExpiredInstances(tenantId, driverId, expiredBefore);
}
@Override
public void reconcileDeviceLeases(List<DeviceLeaseDO> leases) {
for (int from = 0; from < leases.size(); from += WRITE_BATCH_SIZE) {
int to = Math.min(from + WRITE_BATCH_SIZE, leases.size());
driverLeaseMapper.upsertDeviceLeases(leases.subList(from, to));
}
}
@Override
public void deleteOrphanedLeases(Long tenantId, Long driverId) {
driverLeaseMapper.deleteOrphanedLeases(tenantId, driverId);
}
@Override
public List<DeviceLeaseDO> listOwnedLeases(Long tenantId, Long driverId, String node,
Long afterDeviceId, int limit) {
return driverLeaseMapper.listOwnedLeases(tenantId, driverId, node, afterDeviceId, limit);
}
@Override
public DeviceLeaseDO getActiveLease(Long tenantId, Long deviceId) {
return driverLeaseMapper.selectActiveLease(tenantId, deviceId);
}
}
@@ -0,0 +1,22 @@
/*
* 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.manager.entity.bo;
/** A device owner and its monotonic fencing token. */
public record DeviceLeaseBO(Long driverId, Long deviceId, String ownerNode, Long fencingToken) {
}
@@ -0,0 +1,23 @@
/*
* 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.manager.entity.bo;
/** Lease response returned to a driver runtime instance. */
public record DriverLeaseGrantBO(long leaseUntilEpochMillis,
long assignmentVersion, boolean assignmentsChanged) {
}
@@ -0,0 +1,36 @@
/*
* 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.manager.entity.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Database projection for a single-owner device assignment. */
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DeviceLeaseDO {
private Long tenantId;
private Long driverId;
private Long deviceId;
private String ownerNode;
private Long fencingToken;
}
@@ -0,0 +1,32 @@
/*
* 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.manager.entity.model;
import lombok.Getter;
import lombok.Setter;
/** Persisted driver assignment generation. */
@Getter
@Setter
public class DriverLeaseStateDO {
private Long tenantId;
private Long driverId;
private String membershipHash;
private Long deviceRevision;
private Long assignmentVersion;
}
@@ -27,12 +27,18 @@ import io.github.pnoker.api.common.GrpcR;
import io.github.pnoker.api.common.GrpcRFactory;
import io.github.pnoker.api.common.driver.DriverApiGrpc;
import io.github.pnoker.api.common.driver.GrpcDriverRegisterDTO;
import io.github.pnoker.api.common.driver.GrpcDriverLeaseRequest;
import io.github.pnoker.api.common.driver.GrpcDeviceLeaseDTO;
import io.github.pnoker.api.common.driver.GrpcRDriverRegisterDTO;
import io.github.pnoker.api.common.driver.GrpcRDriverLeaseDTO;
import io.github.pnoker.common.enums.ErrorCode;
import io.github.pnoker.common.manager.biz.DriverRegisterService;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.entity.bo.CommandAttributeBO;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.entity.bo.DriverAttributeBO;
import io.github.pnoker.common.manager.entity.bo.DriverBO;
import io.github.pnoker.common.manager.entity.bo.DriverLeaseGrantBO;
import io.github.pnoker.common.manager.entity.bo.EventAttributeBO;
import io.github.pnoker.common.manager.entity.bo.PointAttributeBO;
import io.github.pnoker.common.manager.grpc.builder.GrpcCommandAttributeBuilder;
@@ -41,13 +47,13 @@ import io.github.pnoker.common.manager.grpc.builder.GrpcDriverBuilder;
import io.github.pnoker.common.manager.grpc.builder.GrpcEventAttributeBuilder;
import io.github.pnoker.common.manager.grpc.builder.GrpcPointAttributeBuilder;
import io.github.pnoker.common.manager.service.CommandAttributeService;
import io.github.pnoker.common.manager.service.DeviceService;
import io.github.pnoker.common.manager.service.DriverAttributeService;
import io.github.pnoker.common.manager.service.DriverService;
import io.github.pnoker.common.manager.service.EventAttributeService;
import io.github.pnoker.common.manager.service.PointAttributeService;
import io.github.pnoker.common.tenant.TenantContextHolder;
import io.grpc.stub.StreamObserver;
import io.grpc.Status;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -68,6 +74,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase {
private static final int ASSIGNMENT_BATCH_SIZE = 1_000;
private final GrpcDriverBuilder grpcDriverBuilder;
private final GrpcDriverAttributeBuilder grpcDriverAttributeBuilder;
@@ -90,7 +98,7 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase {
private final EventAttributeService eventAttributeService;
private final DeviceService deviceService;
private final DriverLeaseService driverLeaseService;
@Override
public void driverRegister(GrpcDriverRegisterDTO request, StreamObserver<GrpcRDriverRegisterDTO> responseObserver) {
@@ -135,10 +143,6 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase {
.toList();
builder.addAllEventAttributes(grpcEventAttributeDTOList);
// Attach the device ids bound to this driver
List<Long> idList = deviceService.listIdsByDriverId(entityBO.getId(), entityBO.getTenantId());
builder.addAllDeviceIds(idList);
result = GrpcRFactory.ok();
} catch (Exception e) {
result = GrpcRFactory.fail(ErrorCode.FAILURE, e.getMessage());
@@ -152,6 +156,29 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase {
responseObserver.onCompleted();
}
@Override
public void renewLease(GrpcDriverLeaseRequest request,
StreamObserver<GrpcRDriverLeaseDTO> responseObserver) {
TenantContextHolder.setTenantId(request.getTenantId());
try {
DriverLeaseGrantBO grant = driverLeaseService.renew(request.getTenantId(), request.getDriverId(),
request.getNode(), request.getClient(), request.getHost(), request.getLeaseSeconds(),
request.getAssignmentVersion());
if (!grant.assignmentsChanged()) {
responseObserver.onNext(leaseResponse(grant, List.of(), true));
} else {
streamAssignmentSnapshot(request, grant, responseObserver);
}
responseObserver.onCompleted();
} catch (Exception e) {
log.error("Driver lease renewal failed, tenantId={}, driverId={}, node={}",
request.getTenantId(), request.getDriverId(), request.getNode(), e);
responseObserver.onError(Status.ABORTED.withDescription(e.getMessage()).withCause(e).asRuntimeException());
} finally {
TenantContextHolder.clear();
}
}
@Override
public void getById(GrpcDriverQuery request, StreamObserver<GrpcRDriverRegisterDTO> responseObserver) {
TenantContextHolder.setTenantId(request.getTenantId());
@@ -221,8 +248,58 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase {
.toList();
builder.addAllEventAttributes(eventAttributeDTOList);
List<Long> idList = Optional.ofNullable(deviceService.listIdsByDriverId(entityBO.getId(), entityBO.getTenantId())).orElseGet(List::of);
builder.addAllDeviceIds(idList);
}
private void streamAssignmentSnapshot(GrpcDriverLeaseRequest request, DriverLeaseGrantBO grant,
StreamObserver<GrpcRDriverLeaseDTO> responseObserver) {
long afterDeviceId = 0;
while (true) {
assertAssignmentVersion(request, grant.assignmentVersion());
List<DeviceLeaseBO> page = driverLeaseService.listOwnedLeases(
request.getTenantId(), request.getDriverId(), request.getNode(), afterDeviceId,
ASSIGNMENT_BATCH_SIZE + 1);
boolean complete = page.size() <= ASSIGNMENT_BATCH_SIZE;
List<DeviceLeaseBO> batch = complete ? page : page.subList(0, ASSIGNMENT_BATCH_SIZE);
if (!batch.isEmpty()) {
afterDeviceId = batch.getLast().deviceId();
}
if (complete) {
assertAssignmentVersion(request, grant.assignmentVersion());
}
responseObserver.onNext(leaseResponse(grant, batch, complete));
if (complete) {
return;
}
}
}
private void assertAssignmentVersion(GrpcDriverLeaseRequest request, long expectedVersion) {
long currentVersion = driverLeaseService.getAssignmentVersion(
request.getTenantId(), request.getDriverId());
if (currentVersion != expectedVersion) {
throw new IllegalStateException("Driver assignment changed while streaming snapshot");
}
}
private GrpcRDriverLeaseDTO leaseResponse(DriverLeaseGrantBO grant, List<DeviceLeaseBO> leases,
boolean complete) {
return GrpcRDriverLeaseDTO.newBuilder()
.setResult(GrpcRFactory.ok())
.addAllDeviceLeases(toGrpcLeases(leases))
.setLeaseUntilEpochMillis(grant.leaseUntilEpochMillis())
.setAssignmentVersion(grant.assignmentVersion())
.setAssignmentsChanged(grant.assignmentsChanged())
.setSnapshotComplete(complete)
.build();
}
private List<GrpcDeviceLeaseDTO> toGrpcLeases(List<DeviceLeaseBO> leases) {
return leases.stream()
.map(lease -> GrpcDeviceLeaseDTO.newBuilder()
.setDeviceId(lease.deviceId())
.setFencingToken(lease.fencingToken())
.build())
.toList();
}
}
@@ -26,6 +26,7 @@ import io.github.pnoker.api.center.manager.GrpcPageDeviceQuery;
import io.github.pnoker.api.center.manager.GrpcProfileQuery;
import io.github.pnoker.api.center.manager.GrpcRDeviceDTO;
import io.github.pnoker.api.center.manager.GrpcRDeviceListDTO;
import io.github.pnoker.api.center.manager.GrpcRDeviceOwnerDTO;
import io.github.pnoker.api.center.manager.GrpcRPageDeviceDTO;
import io.github.pnoker.api.common.GrpcDeviceDTO;
import io.github.pnoker.api.common.GrpcDriverQuery;
@@ -33,6 +34,8 @@ import io.github.pnoker.api.common.GrpcPage;
import io.github.pnoker.api.common.GrpcR;
import io.github.pnoker.api.common.GrpcRFactory;
import io.github.pnoker.common.exception.NotFoundException;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.entity.bo.DeviceBO;
import io.github.pnoker.common.manager.entity.query.DeviceQuery;
import io.github.pnoker.common.manager.grpc.builder.GrpcDeviceBuilder;
@@ -63,6 +66,30 @@ public class ManagerDeviceServer extends DeviceApiGrpc.DeviceApiImplBase {
private final DeviceService deviceService;
private final DriverLeaseService driverLeaseService;
@Override
public void getActiveOwner(GrpcDeviceQuery request,
StreamObserver<GrpcRDeviceOwnerDTO> responseObserver) {
TenantContextHolder.setTenantId(request.getTenantId());
try {
GrpcRDeviceOwnerDTO.Builder builder = GrpcRDeviceOwnerDTO.newBuilder();
DeviceLeaseBO owner = driverLeaseService.getActiveOwner(request.getTenantId(), request.getDeviceId());
if (owner == null) {
builder.setResult(GrpcRFactory.notFound());
} else {
builder.setDriverId(owner.driverId())
.setOwnerNode(owner.ownerNode())
.setFencingToken(owner.fencingToken())
.setResult(GrpcRFactory.ok());
}
responseObserver.onNext(builder.build());
responseObserver.onCompleted();
} finally {
TenantContextHolder.clear();
}
}
@Override
public void listByPage(GrpcPageDeviceQuery request, StreamObserver<GrpcRPageDeviceDTO> responseObserver) {
TenantContextHolder.setTenantId(request.getTenantId());
@@ -0,0 +1,75 @@
/*
* 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.manager.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import io.github.pnoker.common.manager.entity.model.DeviceLeaseDO;
import io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO;
import org.apache.ibatis.annotations.Param;
import java.time.Instant;
import java.util.List;
@InterceptorIgnore(tenantLine = "true")
public interface DriverLeaseMapper {
void acquireDriverLock(@Param("driverId") Long driverId);
int upsertInstance(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId,
@Param("node") String node,
@Param("client") String client,
@Param("host") String host,
@Param("leaseUntil") Instant leaseUntil);
List<String> listActiveNodes(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId);
List<Long> listDriverDeviceIds(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId,
@Param("afterDeviceId") Long afterDeviceId,
@Param("limit") Integer limit);
DriverLeaseStateDO selectLeaseState(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId);
Long selectDeviceRevision(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId);
Long upsertLeaseState(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId,
@Param("membershipHash") String membershipHash,
@Param("deviceRevision") Long deviceRevision);
int deleteExpiredInstances(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId,
@Param("expiredBefore") Instant expiredBefore);
int upsertDeviceLeases(@Param("leases") List<DeviceLeaseDO> leases);
int deleteOrphanedLeases(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId);
List<DeviceLeaseDO> listOwnedLeases(@Param("tenantId") Long tenantId,
@Param("driverId") Long driverId,
@Param("node") String node,
@Param("afterDeviceId") Long afterDeviceId,
@Param("limit") Integer limit);
DeviceLeaseDO selectActiveLease(@Param("tenantId") Long tenantId,
@Param("deviceId") Long deviceId);
}
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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/>.
-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.pnoker.common.manager.mapper.DriverLeaseMapper">
<select id="acquireDriverLock" resultType="long">
SELECT pg_advisory_xact_lock(#{driverId})
</select>
<insert id="upsertInstance">
INSERT INTO dc3_driver_instance
(tenant_id, driver_id, node_id, client_id, service_host, lease_until)
VALUES
(#{tenantId}, #{driverId}, #{node}, #{client}, #{host}, #{leaseUntil})
ON CONFLICT (tenant_id, driver_id, node_id) DO UPDATE SET
client_id = EXCLUDED.client_id,
service_host = EXCLUDED.service_host,
last_heartbeat = CURRENT_TIMESTAMP,
lease_until = EXCLUDED.lease_until
</insert>
<select id="listActiveNodes" resultType="string">
SELECT node_id
FROM dc3_driver_instance
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
AND lease_until &gt; CURRENT_TIMESTAMP
ORDER BY node_id
</select>
<select id="listDriverDeviceIds" resultType="long">
SELECT id
FROM dc3_device
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
AND deleted = 0
AND enable_flag = 0
AND id &gt; #{afterDeviceId}
ORDER BY id
LIMIT #{limit}
</select>
<select id="selectLeaseState"
resultType="io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO">
SELECT tenant_id, driver_id, membership_hash, device_revision, assignment_version
FROM dc3_driver_lease_state
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
</select>
<select id="selectDeviceRevision" resultType="long">
SELECT revision
FROM dc3_driver_device_revision
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
</select>
<select id="upsertLeaseState" resultType="long" affectData="true" flushCache="true">
INSERT INTO dc3_driver_lease_state
(tenant_id, driver_id, membership_hash, device_revision)
VALUES
(#{tenantId}, #{driverId}, #{membershipHash}, #{deviceRevision})
ON CONFLICT (tenant_id, driver_id) DO UPDATE SET
membership_hash = EXCLUDED.membership_hash,
device_revision = EXCLUDED.device_revision,
assignment_version = nextval('dc3_driver_assignment_version_seq'),
operate_time = CURRENT_TIMESTAMP
RETURNING assignment_version
</select>
<delete id="deleteExpiredInstances">
DELETE FROM dc3_driver_instance
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
AND lease_until &lt; #{expiredBefore}
</delete>
<insert id="upsertDeviceLeases">
INSERT INTO dc3_device_lease (tenant_id, driver_id, device_id, owner_node)
VALUES
<foreach collection="leases" item="lease" separator=",">
(#{lease.tenantId}, #{lease.driverId}, #{lease.deviceId}, #{lease.ownerNode})
</foreach>
ON CONFLICT (tenant_id, device_id) DO UPDATE SET
driver_id = EXCLUDED.driver_id,
owner_node = EXCLUDED.owner_node,
fencing_token = nextval('dc3_device_lease_fencing_seq'),
operate_time = CURRENT_TIMESTAMP
WHERE dc3_device_lease.driver_id != EXCLUDED.driver_id
OR dc3_device_lease.owner_node != EXCLUDED.owner_node
</insert>
<delete id="deleteOrphanedLeases">
DELETE FROM dc3_device_lease lease
WHERE lease.tenant_id = #{tenantId}
AND lease.driver_id = #{driverId}
AND NOT EXISTS (
SELECT 1 FROM dc3_device device
WHERE device.tenant_id = lease.tenant_id
AND device.id = lease.device_id
AND device.driver_id = lease.driver_id
AND device.deleted = 0
AND device.enable_flag = 0
)
</delete>
<select id="listOwnedLeases" resultType="io.github.pnoker.common.manager.entity.model.DeviceLeaseDO">
SELECT tenant_id, driver_id, device_id, owner_node, fencing_token
FROM dc3_device_lease
WHERE tenant_id = #{tenantId}
AND driver_id = #{driverId}
AND owner_node = #{node}
AND device_id &gt; #{afterDeviceId}
ORDER BY device_id
LIMIT #{limit}
</select>
<select id="selectActiveLease" resultType="io.github.pnoker.common.manager.entity.model.DeviceLeaseDO">
SELECT lease.tenant_id, lease.driver_id, lease.device_id,
lease.owner_node, lease.fencing_token
FROM dc3_device_lease lease
JOIN dc3_driver_instance instance
ON instance.tenant_id = lease.tenant_id
AND instance.driver_id = lease.driver_id
AND instance.node_id = lease.owner_node
WHERE lease.tenant_id = #{tenantId}
AND lease.device_id = #{deviceId}
AND instance.lease_until &gt; CURRENT_TIMESTAMP
</select>
</mapper>
@@ -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.manager.biz.impl;
import io.github.pnoker.common.exception.ServiceException;
import io.github.pnoker.common.manager.dal.DriverLeaseManager;
import io.github.pnoker.common.manager.entity.bo.DriverLeaseGrantBO;
import io.github.pnoker.common.manager.entity.model.DeviceLeaseDO;
import io.github.pnoker.common.manager.entity.model.DriverLeaseStateDO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Set;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DriverLeaseServiceImplTest {
@Mock
private DriverLeaseManager manager;
private DriverLeaseServiceImpl service;
@BeforeEach
void setUp() {
service = new DriverLeaseServiceImpl(manager);
}
@Test
void initialRenewReconcilesAllDevicesAndReturnsOwnedAssignment() {
List<Long> deviceIds = LongStream.rangeClosed(1, 100).boxed().toList();
when(manager.listActiveNodes(1L, 2L)).thenReturn(List.of("node-a", "node-b"));
when(manager.getLeaseState(1L, 2L)).thenReturn(null);
when(manager.listDriverDeviceIds(1L, 2L, 0L, 5000)).thenReturn(deviceIds);
when(manager.advanceAssignmentVersion(eq(1L), eq(2L), anyString(), eq(0L))).thenReturn(8L);
DriverLeaseGrantBO grant = service.renew(1L, 2L, "node-a", "client-a", "host-a", 30, 0);
ArgumentCaptor<List<DeviceLeaseDO>> assignments = ArgumentCaptor.forClass(List.class);
verify(manager).reconcileDeviceLeases(assignments.capture());
assertThat(assignments.getValue()).hasSize(100);
assertThat(assignments.getValue()).extracting(DeviceLeaseDO::getOwnerNode)
.containsOnlyElementsOf(Set.of("node-a", "node-b"))
.contains("node-a", "node-b");
assertThat(grant.assignmentVersion()).isEqualTo(8L);
assertThat(grant.assignmentsChanged()).isTrue();
}
@Test
void stableHeartbeatDoesNotScanDevicesOrReturnAssignmentAgain() {
when(manager.listActiveNodes(1L, 2L)).thenReturn(List.of("node-a", "node-b"));
when(manager.getLeaseState(1L, 2L)).thenReturn(null);
when(manager.listDriverDeviceIds(1L, 2L, 0L, 5000)).thenReturn(List.of(10L));
when(manager.advanceAssignmentVersion(eq(1L), eq(2L), anyString(), eq(0L))).thenReturn(9L);
service.renew(1L, 2L, "node-a", "client-a", "host-a", 30, 0);
ArgumentCaptor<String> membershipHash = ArgumentCaptor.forClass(String.class);
verify(manager).advanceAssignmentVersion(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(2L), membershipHash.capture(), eq(0L));
DriverLeaseStateDO state = new DriverLeaseStateDO();
state.setMembershipHash(membershipHash.getValue());
state.setDeviceRevision(0L);
state.setAssignmentVersion(9L);
when(manager.getLeaseState(1L, 2L)).thenReturn(state);
DriverLeaseGrantBO heartbeat = service.renew(
1L, 2L, "node-a", "client-a", "host-a", 30, 9L);
verify(manager, times(1)).listDriverDeviceIds(1L, 2L, 0L, 5000);
verify(manager, times(2)).getDeviceRevision(1L, 2L);
verify(manager, times(1)).reconcileDeviceLeases(anyList());
assertThat(heartbeat.assignmentsChanged()).isFalse();
}
@Test
void ownedAssignmentsAreReadWithBoundedKeysetPages() {
when(manager.listOwnedLeases(1L, 2L, "node-a", 100L, 1000))
.thenReturn(List.of(new DeviceLeaseDO(1L, 2L, 101L, "node-a", 501L)));
assertThat(service.listOwnedLeases(1L, 2L, "node-a", 100L, 1000))
.containsExactly(new io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO(
2L, 101L, "node-a", 501L));
}
@Test
void invalidLeaseIdentityFailsBeforeDatabaseWork() {
assertThatThrownBy(() -> service.renew(1L, 2L, "", "client", "host", 30, 0))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("identity");
verify(manager, never()).acquireDriverLock(2L);
}
}
@@ -24,13 +24,18 @@ import io.github.pnoker.api.common.GrpcDriverQuery;
import io.github.pnoker.api.common.GrpcEventAttributeDTO;
import io.github.pnoker.api.common.GrpcPointAttributeDTO;
import io.github.pnoker.api.common.driver.DriverApiGrpc;
import io.github.pnoker.api.common.driver.GrpcDriverLeaseRequest;
import io.github.pnoker.api.common.driver.GrpcRDriverLeaseDTO;
import io.github.pnoker.api.common.driver.GrpcRDriverRegisterDTO;
import io.github.pnoker.common.enums.ErrorCode;
import io.github.pnoker.common.enums.SuccessCode;
import io.github.pnoker.common.manager.biz.DriverRegisterService;
import io.github.pnoker.common.manager.biz.DriverLeaseService;
import io.github.pnoker.common.manager.entity.bo.CommandAttributeBO;
import io.github.pnoker.common.manager.entity.bo.DeviceLeaseBO;
import io.github.pnoker.common.manager.entity.bo.DriverAttributeBO;
import io.github.pnoker.common.manager.entity.bo.DriverBO;
import io.github.pnoker.common.manager.entity.bo.DriverLeaseGrantBO;
import io.github.pnoker.common.manager.entity.bo.EventAttributeBO;
import io.github.pnoker.common.manager.entity.bo.PointAttributeBO;
import io.github.pnoker.common.manager.grpc.builder.GrpcCommandAttributeBuilder;
@@ -39,7 +44,6 @@ import io.github.pnoker.common.manager.grpc.builder.GrpcDriverBuilder;
import io.github.pnoker.common.manager.grpc.builder.GrpcEventAttributeBuilder;
import io.github.pnoker.common.manager.grpc.builder.GrpcPointAttributeBuilder;
import io.github.pnoker.common.manager.service.CommandAttributeService;
import io.github.pnoker.common.manager.service.DeviceService;
import io.github.pnoker.common.manager.service.DriverAttributeService;
import io.github.pnoker.common.manager.service.DriverService;
import io.github.pnoker.common.manager.service.EventAttributeService;
@@ -55,6 +59,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;
@@ -98,7 +103,7 @@ class DriverDriverServerTest {
private EventAttributeService eventAttributeService;
@Mock
private DeviceService deviceService;
private DriverLeaseService driverLeaseService;
private Server server;
private ManagedChannel channel;
@@ -109,7 +114,7 @@ class DriverDriverServerTest {
DriverDriverServer driverServer = new DriverDriverServer(grpcDriverBuilder, grpcDriverAttributeBuilder,
grpcPointAttributeBuilder, grpcCommandAttributeBuilder, grpcEventAttributeBuilder,
driverRegisterService, driverService, driverAttributeService, pointAttributeService,
commandAttributeService, eventAttributeService, deviceService);
commandAttributeService, eventAttributeService, driverLeaseService);
String name = "dc3-driver-metadata-" + UUID.randomUUID();
server = InProcessServerBuilder.forName(name).directExecutor().addService(driverServer).build().start();
@@ -156,7 +161,6 @@ class DriverDriverServerTest {
.thenReturn(GrpcCommandAttributeDTO.newBuilder().build());
when(grpcEventAttributeBuilder.buildGrpcDTOByBO(eventAttribute))
.thenReturn(GrpcEventAttributeDTO.newBuilder().build());
when(deviceService.listIdsByDriverId(7L, 100L)).thenReturn(List.of(1L, 2L));
GrpcRDriverRegisterDTO response = stub.getById(GrpcDriverQuery.newBuilder().setDriverId(7L).build());
@@ -166,7 +170,31 @@ class DriverDriverServerTest {
assertThat(response.getPointAttributesCount()).isEqualTo(1);
assertThat(response.getCommandAttributesCount()).isEqualTo(1);
assertThat(response.getEventAttributesCount()).isEqualTo(1);
assertThat(response.getDeviceIdsList()).containsExactly(1L, 2L);
}
@Test
void renewLeaseStreamsBoundedAssignmentPages() {
GrpcDriverLeaseRequest request = GrpcDriverLeaseRequest.newBuilder()
.setTenantId(100L).setDriverId(7L).setNode("node-a")
.setClient("client-a").setHost("host-a").setLeaseSeconds(30).build();
when(driverLeaseService.renew(100L, 7L, "node-a", "client-a", "host-a", 30, 0))
.thenReturn(new DriverLeaseGrantBO(123_456L, 9L, true));
List<DeviceLeaseBO> first = java.util.stream.LongStream.rangeClosed(1, 1001)
.mapToObj(id -> new DeviceLeaseBO(7L, id, "node-a", id + 1000)).toList();
when(driverLeaseService.getAssignmentVersion(100L, 7L)).thenReturn(9L);
when(driverLeaseService.listOwnedLeases(100L, 7L, "node-a", 0L, 1001)).thenReturn(first);
when(driverLeaseService.listOwnedLeases(100L, 7L, "node-a", 1000L, 1001))
.thenReturn(List.of(first.getLast()));
Iterator<GrpcRDriverLeaseDTO> responses = stub.renewLease(request);
GrpcRDriverLeaseDTO pageOne = responses.next();
GrpcRDriverLeaseDTO pageTwo = responses.next();
assertThat(pageOne.getDeviceLeasesCount()).isEqualTo(1000);
assertThat(pageOne.getSnapshotComplete()).isFalse();
assertThat(pageTwo.getDeviceLeasesCount()).isEqualTo(1);
assertThat(pageTwo.getSnapshotComplete()).isTrue();
assertThat(responses.hasNext()).isFalse();
}
@Test
@@ -32,6 +32,8 @@ import java.util.Map;
public record CommandCallDTO(
String recordId,
Long tenantId,
String ownerNode,
Long fencingToken,
Long deviceId,
Long commandId,
String commandCode,
@@ -50,6 +52,8 @@ public record CommandCallDTO(
public static class Builder {
private String recordId;
private Long tenantId;
private String ownerNode;
private Long fencingToken;
private Long deviceId;
private Long commandId;
private String commandCode;
@@ -70,6 +74,16 @@ public record CommandCallDTO(
return this;
}
public Builder ownerNode(String ownerNode) {
this.ownerNode = ownerNode;
return this;
}
public Builder fencingToken(Long fencingToken) {
this.fencingToken = fencingToken;
return this;
}
public Builder deviceId(Long deviceId) {
this.deviceId = deviceId;
return this;
@@ -116,7 +130,7 @@ public record CommandCallDTO(
}
public CommandCallDTO build() {
return new CommandCallDTO(recordId, tenantId, deviceId, commandId, commandCode,
return new CommandCallDTO(recordId, tenantId, ownerNode, fencingToken, deviceId, commandId, commandCode,
paramValues, source, sourceUserId, occurredAt, expireAt, schemaVersion);
}
}
@@ -36,6 +36,8 @@ import java.time.Instant;
public record PointCommandDTO(
String commandId,
Long tenantId,
String ownerNode,
Long fencingToken,
PointCommandTypeEnum type,
PointCommandPayload payload,
PointCommandSourceEnum source,
@@ -48,10 +50,13 @@ public record PointCommandDTO(
/**
* Create a read command DTO with default source and timing.
*/
public static PointCommandDTO ofRead(String commandId, Long tenantId, Long deviceId, Long pointId) {
public static PointCommandDTO ofRead(String commandId, Long tenantId, String ownerNode,
Long fencingToken, Long deviceId, Long pointId) {
return new PointCommandDTO(
commandId,
tenantId,
ownerNode,
fencingToken,
PointCommandTypeEnum.READ,
new PointCommandPayload.ReadPayload(deviceId, pointId),
PointCommandSourceEnum.HTTP,
@@ -65,10 +70,13 @@ public record PointCommandDTO(
/**
* Create a write command DTO with default source and timing.
*/
public static PointCommandDTO ofWrite(String commandId, Long tenantId, Long deviceId, Long pointId, String value) {
public static PointCommandDTO ofWrite(String commandId, Long tenantId, String ownerNode,
Long fencingToken, Long deviceId, Long pointId, String value) {
return new PointCommandDTO(
commandId,
tenantId,
ownerNode,
fencingToken,
PointCommandTypeEnum.WRITE,
new PointCommandPayload.WritePayload(deviceId, pointId, value),
PointCommandSourceEnum.HTTP,
@@ -25,10 +25,8 @@ import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import java.text.MessageFormat;
import java.util.HashMap;
@@ -52,8 +50,6 @@ public class MqttEnvironmentConfig implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
addLegacyMqttAliases(environment);
String node = environment.getProperty(EnvironmentConstant.DRIVER_NODE, String.class);
if (StringUtils.isEmpty(node)) {
node = EnvironmentUtil.getNodeId();
@@ -76,23 +72,4 @@ public class MqttEnvironmentConfig implements EnvironmentPostProcessor {
propertySources.addFirst(new MapPropertySource("mqtt", source));
}
private void addLegacyMqttAliases(ConfigurableEnvironment environment) {
Map<String, Object> aliases = new HashMap<>();
for (PropertySource<?> propertySource : environment.getPropertySources()) {
if (propertySource instanceof EnumerablePropertySource<?> enumerablePropertySource) {
for (String propertyName : enumerablePropertySource.getPropertyNames()) {
if (propertyName.startsWith("driver.mqtt.")) {
String aliasName = "dc3." + propertyName;
if (!environment.containsProperty(aliasName)) {
aliases.put(aliasName, enumerablePropertySource.getProperty(propertyName));
}
}
}
}
}
if (!aliases.isEmpty()) {
environment.getPropertySources().addLast(new MapPropertySource("legacyMqttAliases", aliases));
}
}
}
@@ -0,0 +1,54 @@
/*
* 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.utils;
import io.github.pnoker.common.constant.common.ExceptionConstant;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/** Publisher-confirm guard for low-volume messages whose state machine requires proof of routing. */
public final class RabbitPublishConfirm {
private RabbitPublishConfirm() {
throw new IllegalStateException(ExceptionConstant.UTILITY_CLASS);
}
public static void awaitRouted(CorrelationData correlationData, Duration timeout) {
try {
CorrelationData.Confirm confirm = correlationData.getFuture()
.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!confirm.ack()) {
throw new AmqpException("RabbitMQ publish NACK: " + confirm.reason());
}
if (correlationData.getReturned() != null) {
throw new AmqpException("RabbitMQ publish was unroutable: "
+ correlationData.getReturned().getReplyText());
}
} catch (AmqpException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AmqpException("Interrupted while waiting for RabbitMQ publisher confirm", e);
} catch (Exception e) {
throw new AmqpException("RabbitMQ publisher confirm timed out or failed", e);
}
}
}
@@ -0,0 +1,64 @@
/*
* 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.utils;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RabbitPublishConfirmTest {
@Test
void acceptsOnlyAckedAndRoutedPublish() {
CorrelationData correlation = new CorrelationData("ok");
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
assertThatCode(() -> RabbitPublishConfirm.awaitRouted(correlation, Duration.ofSeconds(1)))
.doesNotThrowAnyException();
}
@Test
void rejectsPublisherNack() {
CorrelationData correlation = new CorrelationData("nack");
correlation.getFuture().complete(new CorrelationData.Confirm(false, "broker rejected"));
assertThatThrownBy(() -> RabbitPublishConfirm.awaitRouted(correlation, Duration.ofSeconds(1)))
.isInstanceOf(AmqpException.class)
.hasMessageContaining("NACK");
}
@Test
void rejectsUnroutableAck() {
CorrelationData correlation = new CorrelationData("returned");
correlation.setReturned(new ReturnedMessage(new Message(new byte[0], new MessageProperties()),
312, "NO_ROUTE", "exchange", "routing"));
correlation.getFuture().complete(new CorrelationData.Confirm(true, null));
assertThatThrownBy(() -> RabbitPublishConfirm.awaitRouted(correlation, Duration.ofSeconds(1)))
.isInstanceOf(AmqpException.class)
.hasMessageContaining("unroutable");
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ backends without coupling business logic to a specific storage implementation.
|------------------------------------|-----------------------------------------------------------------------------------------------|
| `RepositoryService` | Storage interface: save point values and query latest/history/page data |
| `RepositoryStrategyFactory` | Runtime registry for available `RepositoryService` implementations |
| `PointValueBO` | Business object representing a point value with timestamp, device ID, point ID, and raw value |
| `PointValueBO` | Versioned telemetry event with immutable identity, ownership fence, timestamps, and values |
| `PointQueryBO` / `PointValueQuery` | Query objects for paginated and filter-based retrieval |
| `ActiveRepositoryProfileConfig` | Activates the `repository` profile unless `dc3.repository.auto-profile=false` is set |
@@ -64,4 +64,4 @@ mvn -s .mvn/settings.xml -pl dc3-common/dc3-common-repository -am test
## Related Modules
- `dc3-common-data` — Uses `RepositoryStrategyFactory` to route point-value persistence operations
- `dc3-common-data`Caches latest point values with `PointValueLocalCache` alongside repository storage
- `dc3-common-data`Persists history and the shared PostgreSQL latest-value projection in one transaction
@@ -51,6 +51,29 @@ public class PointValueBO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* Immutable event identity used for end-to-end idempotency.
*/
private String messageId;
/**
* Wire schema version.
*/
private Integer schemaVersion;
/**
* Unique runtime node that produced this reading.
*/
private String driverNode;
/**
* Monotonically increasing sequence within {@link #driverNode}.
*/
private Long sequence;
/** Manager-issued device ownership fencing token. */
private Long fencingToken;
/**
* Device ID associated with the point value
*/
@@ -49,7 +49,7 @@ public interface RepositoryService {
* @param entityBO point value to persist
* @throws IOException on write failure
*/
void savePointValue(PointValueBO entityBO) throws IOException;
boolean savePointValue(PointValueBO entityBO) throws IOException;
/**
* Persist a batch of point values to the time-series store.
@@ -57,7 +57,7 @@ public interface RepositoryService {
* @param entityBOList point values to persist
* @throws IOException on write failure
*/
void savePointValues(List<PointValueBO> entityBOList) throws IOException;
List<PointValueBO> savePointValues(List<PointValueBO> entityBOList) throws IOException;
/**
* Get historical point values within the tenant scope.
@@ -137,12 +137,14 @@ class RepositoryStrategyFactoryTest {
}
@Override
public void savePointValue(PointValueBO entityBO) throws IOException {
public boolean savePointValue(PointValueBO entityBO) throws IOException {
return true;
// no-op
}
@Override
public void savePointValues(List<PointValueBO> entityBOList) throws IOException {
public List<PointValueBO> savePointValues(List<PointValueBO> entityBOList) throws IOException {
return entityBOList;
// no-op
}
@@ -106,7 +106,6 @@ dc3:
remark: Property identifier
buffer:
enable: true
db-path: dc3/data/driver/bacnet-ip/buffer.db
spring:
@@ -86,7 +86,6 @@ dc3:
remark: GATT Characteristic UUID for writing
buffer:
enable: true
db-path: dc3/data/driver/ble/buffer.db
spring:
@@ -90,7 +90,6 @@ dc3:
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/can/buffer.db
spring:
@@ -65,7 +65,6 @@ dc3:
Content format: json, text, cbor, octet-stream
buffer:
enable: true
db-path: dc3/data/driver/coap/buffer.db
spring:
@@ -101,7 +101,6 @@ dc3:
command-attribute: [ ]
buffer:
enable: true
db-path: dc3/data/driver/dlms/buffer.db
spring:
@@ -104,7 +104,6 @@ dc3:
remark: Data format for encoding the value: HEX, BCD, INT, FLOAT, ASCII
buffer:
enable: true
db-path: dc3/data/driver/dlt645/buffer.db
spring:
@@ -79,7 +79,6 @@ dc3:
remark: BINARY_OUTPUT or ANALOG_OUTPUT for commands
buffer:
enable: true
db-path: dc3/data/driver/dnp3/buffer.db
spring:
@@ -72,7 +72,6 @@ dc3:
default-value: '${value}'
buffer:
enable: true
db-path: dc3/data/driver/ethernet-ip/buffer.db
spring:

Some files were not shown because too many files have changed in this diff Show More