mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-09-17 16:26:23 +08:00
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:
+14
-2
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+5
-6
@@ -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>
|
||||
|
||||
+3
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
-43
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+41
-32
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+46
-74
@@ -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.
|
||||
*
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+42
-19
@@ -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
|
||||
|
||||
-191
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
-3
@@ -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
|
||||
|
||||
-88
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+78
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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
-11
@@ -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;
|
||||
}
|
||||
|
||||
+16
-3
@@ -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,
|
||||
|
||||
-62
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+65
-35
@@ -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 > 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)
|
||||
> (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
-25
@@ -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);
|
||||
}
|
||||
|
||||
+18
-14
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-30
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+61
-83
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-130
@@ -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();
|
||||
}
|
||||
}
|
||||
-108
@@ -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);
|
||||
}
|
||||
}
|
||||
+73
-39
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 10–120 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
|
||||
|
||||
+21
-19
@@ -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();
|
||||
}
|
||||
|
||||
+71
-79
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+63
-44
@@ -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 <= 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.
|
||||
*/
|
||||
|
||||
+1
-25
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-4
@@ -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;
|
||||
}
|
||||
|
||||
+24
@@ -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.
|
||||
|
||||
+6
@@ -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.
|
||||
*/
|
||||
|
||||
+36
-19
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+75
-4
@@ -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(),
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+44
-22
@@ -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();
|
||||
|
||||
+20
-1
@@ -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);
|
||||
}
|
||||
|
||||
+2
-4
@@ -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());
|
||||
|
||||
+21
-2
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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());
|
||||
|
||||
+18
-9
@@ -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();
|
||||
|
||||
+71
-62
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+87
-48
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-13
@@ -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();
|
||||
|
||||
+127
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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"))
|
||||
|
||||
+8
-10
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -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);
|
||||
|
||||
+34
-3
@@ -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();
|
||||
|
||||
+122
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
@@ -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.
|
||||
*/
|
||||
|
||||
+22
@@ -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) {
|
||||
}
|
||||
+16
@@ -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()) {
|
||||
|
||||
+17
@@ -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);
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
+202
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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) {
|
||||
}
|
||||
+23
@@ -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) {
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+85
-8
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+27
@@ -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());
|
||||
|
||||
+75
@@ -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 > 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 > #{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 < #{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 > #{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 > CURRENT_TIMESTAMP
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+121
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -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
|
||||
|
||||
+15
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
-23
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+23
@@ -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
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+4
-2
@@ -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
Reference in New Issue
Block a user