diff --git a/.env.example b/.env.example index 35fc04bab..ff1175f01 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_driver.proto b/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_driver.proto index de180cc23..d8bb366de 100644 --- a/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_driver.proto +++ b/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_driver.proto @@ -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; } diff --git a/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_entity.proto b/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_entity.proto index 1b1ba531e..4ccb92509 100644 --- a/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_entity.proto +++ b/dc3-api/dc3-api-driver/src/main/protobuf/api/common/driver/driver_entity.proto @@ -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; } diff --git a/dc3-api/dc3-api-manager/src/main/protobuf/api/common/manager/manager_device.proto b/dc3-api/dc3-api-manager/src/main/protobuf/api/common/manager/manager_device.proto index c950f7575..c53fa7973 100644 --- a/dc3-api/dc3-api-manager/src/main/protobuf/api/common/manager/manager_device.proto +++ b/dc3-api/dc3-api-manager/src/main/protobuf/api/common/manager/manager_device.proto @@ -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; +} diff --git a/dc3-center/dc3-center-data/src/main/resources/application.yml b/dc3-center/dc3-center-data/src/main/resources/application.yml index 219653621..fc4667ca7 100644 --- a/dc3-center/dc3-center-data/src/main/resources/application.yml +++ b/dc3-center/dc3-center-data/src/main/resources/application.yml @@ -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} diff --git a/dc3-center/dc3-center-single/src/main/resources/application.yml b/dc3-center/dc3-center-single/src/main/resources/application.yml index b7fd4d086..bf9373ab8 100644 --- a/dc3-center/dc3-center-single/src/main/resources/application.yml +++ b/dc3-center/dc3-center-single/src/main/resources/application.yml @@ -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} diff --git a/dc3-common/dc3-common-auth/pom.xml b/dc3-common/dc3-common-auth/pom.xml index e93409efc..afb6e65cc 100644 --- a/dc3-common/dc3-common-auth/pom.xml +++ b/dc3-common/dc3-common-auth/pom.xml @@ -38,7 +38,7 @@ - + com.github.ben-manes.caffeine caffeine diff --git a/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/cache/TokenDenylistCache.java b/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/cache/TokenDenylistCache.java index 0e6085173..f337fd827 100644 --- a/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/cache/TokenDenylistCache.java +++ b/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/cache/TokenDenylistCache.java @@ -31,12 +31,11 @@ import java.util.concurrent.TimeUnit; * In-memory denylist of cancelled tokens, keyed by (loginName, tenantCode). * *

- * 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. *

* *

diff --git a/dc3-common/dc3-common-constant/src/main/java/io/github/pnoker/common/constant/driver/ScheduleConstant.java b/dc3-common/dc3-common-constant/src/main/java/io/github/pnoker/common/constant/driver/ScheduleConstant.java index b18d3300b..dfbb96de1 100644 --- a/dc3-common/dc3-common-constant/src/main/java/io/github/pnoker/common/constant/driver/ScheduleConstant.java +++ b/dc3-common/dc3-common-constant/src/main/java/io/github/pnoker/common/constant/driver/ScheduleConstant.java @@ -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 */ diff --git a/dc3-common/dc3-common-data/README.md b/dc3-common/dc3-common-data/README.md index fc3fae900..7df391ab3 100644 --- a/dc3-common/dc3-common-data/README.md +++ b/dc3-common/dc3-common-data/README.md @@ -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 diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImpl.java index 867fedc58..561affa4f 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImpl.java @@ -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() - .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; } } diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImpl.java index 9d55b81e6..2f78207af 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImpl.java @@ -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() - .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; } } diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImpl.java index b368f962b..313e5275e 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImpl.java @@ -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> 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 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 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 missingIds = pointIds.stream() - .filter(id -> !pointValueBOMap.containsKey(id)) - .toList(); - if (!missingIds.isEmpty()) { - List 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 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 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 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> splitPointValueBOList = ListUtils.partition(pointValueBOList, 100); - for (List 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 persistPointValues(List pointValueBOList) { + try { + return getFirstRepositoryService().savePointValues(pointValueBOList); + } catch (Exception e) { + throw new RepositoryException(e); + } + } + /** * Build a placeholder point value indicating no latest value is available. * diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImpl.java index 495e188b5..493098e9e 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImpl.java @@ -29,9 +29,8 @@ import org.springframework.stereotype.Service; /** * Business service implementation for data-center scheduled jobs. * - *

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. + *

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 diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImpl.java index 61e4e0122..e223b6665 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImpl.java @@ -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 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 entityBOList) { - List entityDOList = pointValueBuilder.buildDOListByBOList(entityBOList); - if (!pointValueManager.saveBatch(entityDOList)) { - throw new AddException("Failed to create point value list"); + @Transactional(rollbackFor = Exception.class) + public List savePointValues(List 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 entityDOList = pointValueBuilder.buildDOListByBOList(entityBOList).stream() + .sorted(INGEST_ORDER) + .toList(); + Set insertedIds = new java.util.HashSet<>(pointValueMapper.insertHistoryBatch(entityDOList)); + if (insertedIds.isEmpty()) { + return List.of(); + } + Map 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 acceptedDOs = List.copyOf(acceptedById.values()); + pointValueMapper.upsertLatestBatch(acceptedDOs); + Set 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 wrapper = Wrappers.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 values = listLatestPointValues(tenantId, deviceId, List.of(pointId)); + return values.isEmpty() ? null : values.getFirst(); } @Override diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/buffer/PointValueIngestBuffer.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/buffer/PointValueIngestBuffer.java deleted file mode 100644 index 97b41b3ba..000000000 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/buffer/PointValueIngestBuffer.java +++ /dev/null @@ -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 . - */ - -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. - * - *

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 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 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 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 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 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); - } - } - } -} diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/LocalCacheImpl.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/LocalCacheImpl.java index 65f4c55c7..6458aefbf 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/LocalCacheImpl.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/LocalCacheImpl.java @@ -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}. * *

* Exposes {@link #onExpire(ExpireListener)} so callers can react when an entry is evicted diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/PointValueLocalCache.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/PointValueLocalCache.java deleted file mode 100644 index 8324e3fb4..000000000 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/cache/PointValueLocalCache.java +++ /dev/null @@ -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 . - */ - -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. - *

- * 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. - *

- * - * @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 entityBOList) { - if (Objects.isNull(deviceId) || CollectionUtils.isEmpty(entityBOList)) { - return; - } - Map 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 selectLatestPointValue(Long tenantId, Long deviceId, List pointIds) { - if (Objects.isNull(tenantId) || Objects.isNull(deviceId) || CollectionUtils.isEmpty(pointIds)) { - return Collections.emptyMap(); - } - List keys = pointIds.stream().map(pointId -> buildKey(tenantId, deviceId, pointId)).toList(); - List 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; - } - -} diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/config/PointValueRabbitConfig.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/config/PointValueRabbitConfig.java new file mode 100644 index 000000000..81565f7c9 --- /dev/null +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/config/PointValueRabbitConfig.java @@ -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 . + */ + +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; + } +} diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/model/PointValueDO.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/model/PointValueDO.java index fcc3557c9..f20c67589 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/model/PointValueDO.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/model/PointValueDO.java @@ -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 */ diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/property/PointBatchProperties.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/property/PointBatchProperties.java index 0f2daa39c..ddcfe91cf 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/property/PointBatchProperties.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/entity/property/PointBatchProperties.java @@ -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; } diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/mapper/PointValueMapper.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/mapper/PointValueMapper.java index 28d2b5e6c..f488461f1 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/mapper/PointValueMapper.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/mapper/PointValueMapper.java @@ -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 { + /** + * Insert a telemetry batch into the Timescale history table. Replayed events are + * ignored by the event identity unique index. + */ + @InterceptorIgnore(tenantLine = "true") + List insertHistoryBatch(@Param("values") List 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 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 { /** * 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 selectLatestPointValues(@Param("tenantId") Long tenantId, @Param("deviceId") Long deviceId, diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueDeadReceiver.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueDeadReceiver.java deleted file mode 100644 index ad1700c86..000000000 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueDeadReceiver.java +++ /dev/null @@ -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 . - */ - -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); - } - } - -} diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueReceiver.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueReceiver.java index 9142042e6..7761677d1 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueReceiver.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/rabbit/PointValueReceiver.java @@ -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. * - *

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). + *

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 messages, Channel channel) throws IOException { + if (messages.isEmpty()) { + return; } + + List 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); } } diff --git a/dc3-common/dc3-common-data/src/main/resources/mapping/PointValueMapper.xml b/dc3-common/dc3-common-data/src/main/resources/mapping/PointValueMapper.xml index d2646928e..1ea89a9ad 100644 --- a/dc3-common/dc3-common-data/src/main/resources/mapping/PointValueMapper.xml +++ b/dc3-common/dc3-common-data/src/main/resources/mapping/PointValueMapper.xml @@ -19,6 +19,69 @@ + + + + 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 + + (#{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}) + + 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) + + + diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImplTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImplTest.java index 8ec50aa3f..57e6b0a0f 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImplTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/CommandHistoryServiceImplTest.java @@ -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 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 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 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); } diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImplTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImplTest.java index 9009d7d87..7e40ec2e0 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImplTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointCommandServiceImplTest.java @@ -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)); } } diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImplTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImplTest.java index 3ac4ac60f..c08261fc5 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImplTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/PointValueServiceImplTest.java @@ -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) 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 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 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 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 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 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); + } + } } diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImplTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImplTest.java index e1bcda487..bc5d97a3d 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImplTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/impl/ScheduleForDataServiceImplTest.java @@ -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 diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImplTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImplTest.java index aa01b885d..0464273d6 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImplTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/biz/repository/PostgresRepositoryServiceImplTest.java @@ -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 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 input = List.of(numericBO, stringBO); + List 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> 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 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 accepted = service.savePointValues(input); @SuppressWarnings("unchecked") - ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(java.util.List.class); - verify(pointValueManager).saveBatch(listCaptor.capture()); - java.util.List saved = listCaptor.getValue(); - assertThat(saved).hasSize(2); - assertThat(saved.get(0).getNumValue()).isEqualTo(42.5); - assertThat(saved.get(1).getNumValue()).isNull(); + ArgumentCaptor> 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 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); } } diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/buffer/PointValueIngestBufferTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/buffer/PointValueIngestBufferTest.java deleted file mode 100644 index 7ddb8c268..000000000 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/buffer/PointValueIngestBufferTest.java +++ /dev/null @@ -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 . - */ - -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(); - } -} diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/cache/PointValueLocalCacheTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/cache/PointValueLocalCacheTest.java deleted file mode 100644 index e7f99c01f..000000000 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/cache/PointValueLocalCacheTest.java +++ /dev/null @@ -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 . - */ - -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 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 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 hits = service.selectLatestPointValue(1L, 10L, List.of(20L, 21L)); - assertThat(hits).hasSize(1).containsKey(20L); - } -} diff --git a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/rabbit/PointValueReceiverTest.java b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/rabbit/PointValueReceiverTest.java index cd177fa54..d02904083 100644 --- a/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/rabbit/PointValueReceiverTest.java +++ b/dc3-common/dc3-common-data/src/test/java/io/github/pnoker/common/data/rabbit/PointValueReceiverTest.java @@ -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> 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); } } diff --git a/dc3-common/dc3-common-driver/README.md b/dc3-common/dc3-common-driver/README.md index 1705b1384..5d7f83c8f 100644 --- a/dc3-common/dc3-common-driver/README.md +++ b/dc3-common/dc3-common-driver/README.md @@ -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 diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferService.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferService.java index 1072cd58b..51fd75852 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferService.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferService.java @@ -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. * - *

Failed/NACKed readings are persisted and republished by a Quartz job once the - * broker recovers, so a RabbitMQ outage no longer loses collected data. + *

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 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(); } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferServiceImpl.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferServiceImpl.java index dfe248061..88981b9b2 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferServiceImpl.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/BufferServiceImpl.java @@ -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. - * - *

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 pointValues, String routingKey) { + if (pointValues == null || pointValues.isEmpty()) { return; } - DriverProperties.BufferProperties config = driverProperties.getBuffer(); long now = epochSecond(); - BufferedPointValue record = new BufferedPointValue( + List 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 records = buffer.selectPending(config.getBatchSize(), epochSecond()); + List 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()); } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/PointValueBuffer.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/PointValueBuffer.java index b6b3603e2..2dd46e996 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/PointValueBuffer.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/buffer/PointValueBuffer.java @@ -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 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. */ diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverEnvironmentConfig.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverEnvironmentConfig.java index 91b5a9354..fd9c15e51 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverEnvironmentConfig.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverEnvironmentConfig.java @@ -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 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)); - } - } - } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverTopicConfig.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverTopicConfig.java index d3ec40a62..6aeab3e3c 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverTopicConfig.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/config/DriverTopicConfig.java @@ -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; } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bean/PointValue.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bean/PointValue.java index f78a69c43..b5b11cb70 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bean/PointValue.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bean/PointValue.java @@ -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. diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bo/RegisterBO.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bo/RegisterBO.java index 4f2f0f331..9cbe07c78 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bo/RegisterBO.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/bo/RegisterBO.java @@ -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. */ diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/property/DriverProperties.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/property/DriverProperties.java index a12b0f5d8..120f10b07 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/property/DriverProperties.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/entity/property/DriverProperties.java @@ -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; + } + } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/grpc/client/DriverClient.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/grpc/client/DriverClient.java index 4532e2b1f..709c37865 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/grpc/client/DriverClient.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/grpc/client/DriverClient.java @@ -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 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 responses = driverApiBlockingStub.renewLease(request); + Map 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 driverAttributesList = rDriverRegisterDTO.getDriverAttributesList(); Map driverAttributeIdMap = driverAttributesList.stream() .collect(Collectors.toMap(entity -> entity.getBase().getId(), diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverLeaseRenewScheduleJob.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverLeaseRenewScheduleJob.java new file mode 100644 index 000000000..23bfd9bb2 --- /dev/null +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverLeaseRenewScheduleJob.java @@ -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 . + */ + +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); + } + } +} diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverReadScheduleJob.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverReadScheduleJob.java index 54ec129cc..f6b108050 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverReadScheduleJob.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/job/DriverReadScheduleJob.java @@ -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)); } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/metadata/DriverMetadata.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/metadata/DriverMetadata.java index 1281636a3..69bc79e0c 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/metadata/DriverMetadata.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/metadata/DriverMetadata.java @@ -38,10 +38,9 @@ import java.util.concurrent.ConcurrentHashMap; * In-memory holder for driver registration state and shared metadata used across the * driver runtime. * - *

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. + *

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 deviceIds = ConcurrentHashMap.newKeySet(); + + /** Fencing tokens for devices currently owned by this runtime node. */ + private final Map 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 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 deviceIds) { - replaceContents(this.deviceIds, deviceIds); + /** Atomically replace owned devices and publish the new lease deadline. */ + public synchronized void setDeviceLeases(Map 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(); diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiver.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiver.java index 757f0a24b..1e7afd4b3 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiver.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiver.java @@ -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); } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiver.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiver.java index f6d9d856e..2b10fedbd 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiver.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiver.java @@ -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()); diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiver.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiver.java index 17d54b73a..5639ec503 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiver.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiver.java @@ -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); } diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverRegisterServiceImpl.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverRegisterServiceImpl.java index 92facdb5b..953c1f120 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverRegisterServiceImpl.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverRegisterServiceImpl.java @@ -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()); diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImpl.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImpl.java index a06ef101e..83720aee1 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImpl.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImpl.java @@ -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(); diff --git a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImpl.java b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImpl.java index a7ddcd213..14986a907 100644 --- a/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImpl.java +++ b/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImpl.java @@ -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 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 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)); + } + } diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/BufferServiceImplTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/BufferServiceImplTest.java index ee7dac582..34c5080a6 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/BufferServiceImplTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/BufferServiceImplTest.java @@ -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(); } } diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/PointValueBufferTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/PointValueBufferTest.java index bb6d7b2c8..f42ce79c2 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/PointValueBufferTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/buffer/PointValueBufferTest.java @@ -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 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(); diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/grpc/client/DriverClientLeaseTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/grpc/client/DriverClientLeaseTest.java new file mode 100644 index 000000000..2c448a043 --- /dev/null +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/grpc/client/DriverClientLeaseTest.java @@ -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 . + */ + +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(); + } +} diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DeviceHealthScheduleJobTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DeviceHealthScheduleJobTest.java index 0f8c877de..46127650d 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DeviceHealthScheduleJobTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DeviceHealthScheduleJobTest.java @@ -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); + } + } diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DriverReadScheduleJobTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DriverReadScheduleJobTest.java index 6c4a8adae..44208e582 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DriverReadScheduleJobTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/job/DriverReadScheduleJobTest.java @@ -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); diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DeviceMetadataTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DeviceMetadataTest.java index 2b7c5d7e4..4905170d1 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DeviceMetadataTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DeviceMetadataTest.java @@ -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); } diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DriverMetadataLeaseTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DriverMetadataLeaseTest.java new file mode 100644 index 000000000..aa225b01b --- /dev/null +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/metadata/DriverMetadataLeaseTest.java @@ -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 . + */ + +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); + } +} diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiverTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiverTest.java index 9f6bd4bec..57093641d 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiverTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/CommandReceiverTest.java @@ -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")) diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiverTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiverTest.java index 0fedb021f..d19ff4d55 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiverTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/MetadataReceiverTest.java @@ -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()); } } diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiverTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiverTest.java index 59b37c179..f9a2db05a 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiverTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/receiver/rabbit/PointCommandReceiverTest.java @@ -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.>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); diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImplTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImplTest.java index 89b76f621..b8a49e7d4 100644 --- a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImplTest.java +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverScheduleServiceImplTest.java @@ -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(); diff --git a/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImplTest.java b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImplTest.java new file mode 100644 index 000000000..d22388171 --- /dev/null +++ b/dc3-common/dc3-common-driver/src/test/java/io/github/pnoker/common/driver/service/impl/DriverSenderServiceImplTest.java @@ -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 . + */ + +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 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> 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()); + } +} diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/api/DeviceFacade.java b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/api/DeviceFacade.java index bbc13615d..e4778b419 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/api/DeviceFacade.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/api/DeviceFacade.java @@ -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. */ diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadeDeviceOwnerBO.java b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadeDeviceOwnerBO.java new file mode 100644 index 000000000..82d6f0989 --- /dev/null +++ b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadeDeviceOwnerBO.java @@ -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 . + */ + +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) { +} diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/DeviceGrpcFacade.java b/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/DeviceGrpcFacade.java index 32019bc88..35ab95874 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/DeviceGrpcFacade.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/DeviceGrpcFacade.java @@ -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 listByIds(Long tenantId, Collection ids) { if (Objects.isNull(ids) || ids.isEmpty()) { diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/main/java/io/github/pnoker/common/facade/local/DeviceLocalFacade.java b/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/main/java/io/github/pnoker/common/facade/local/DeviceLocalFacade.java index 905b26394..bd37eecdb 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/main/java/io/github/pnoker/common/facade/local/DeviceLocalFacade.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/main/java/io/github/pnoker/common/facade/local/DeviceLocalFacade.java @@ -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 listByIds(Long tenantId, Collection ids) { TenantContextHolder.setTenantId(tenantId); diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/test/java/io/github/pnoker/common/facade/local/DeviceLocalFacadeTest.java b/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/test/java/io/github/pnoker/common/facade/local/DeviceLocalFacadeTest.java index 107e79fb6..98c620b31 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/test/java/io/github/pnoker/common/facade/local/DeviceLocalFacadeTest.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-local-manager/src/test/java/io/github/pnoker/common/facade/local/DeviceLocalFacadeTest.java @@ -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 any() { @@ -65,7 +69,7 @@ class DeviceLocalFacadeTest { @BeforeEach void setUp() { - facade = new DeviceLocalFacade(deviceService, facadeDeviceBuilder); + facade = new DeviceLocalFacade(deviceService, facadeDeviceBuilder, driverLeaseService); } @AfterEach diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/DriverLeaseService.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/DriverLeaseService.java new file mode 100644 index 000000000..a622a73dc --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/DriverLeaseService.java @@ -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 . + */ + +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 listOwnedLeases(Long tenantId, Long driverId, String node, + long afterDeviceId, int limit); + + long getAssignmentVersion(Long tenantId, Long driverId); + + DeviceLeaseBO getActiveOwner(Long tenantId, Long deviceId); +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImpl.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImpl.java new file mode 100644 index 000000000..086f164d4 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImpl.java @@ -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 . + */ + +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 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 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 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 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 activeNodes) { + long afterDeviceId = 0; + while (true) { + List deviceIds = driverLeaseManager.listDriverDeviceIds( + tenantId, driverId, afterDeviceId, RECONCILE_PAGE_SIZE); + if (deviceIds.isEmpty()) { + return; + } + List 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 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); + } + } +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/DriverLeaseManager.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/DriverLeaseManager.java new file mode 100644 index 000000000..405804f21 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/DriverLeaseManager.java @@ -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 . + */ + +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 listActiveNodes(Long tenantId, Long driverId); + + List 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 leases); + + void deleteOrphanedLeases(Long tenantId, Long driverId); + + List listOwnedLeases(Long tenantId, Long driverId, String node, + Long afterDeviceId, int limit); + + DeviceLeaseDO getActiveLease(Long tenantId, Long deviceId); +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/impl/DriverLeaseManagerImpl.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/impl/DriverLeaseManagerImpl.java new file mode 100644 index 000000000..0bf9345c6 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/dal/impl/DriverLeaseManagerImpl.java @@ -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 . + */ + +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 listActiveNodes(Long tenantId, Long driverId) { + return driverLeaseMapper.listActiveNodes(tenantId, driverId); + } + + @Override + public List 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 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 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); + } +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DeviceLeaseBO.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DeviceLeaseBO.java new file mode 100644 index 000000000..93163bacb --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DeviceLeaseBO.java @@ -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 . + */ + +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) { +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DriverLeaseGrantBO.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DriverLeaseGrantBO.java new file mode 100644 index 000000000..dc920a620 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/bo/DriverLeaseGrantBO.java @@ -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 . + */ + +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) { +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DeviceLeaseDO.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DeviceLeaseDO.java new file mode 100644 index 000000000..d31985539 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DeviceLeaseDO.java @@ -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 . + */ + +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; +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DriverLeaseStateDO.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DriverLeaseStateDO.java new file mode 100644 index 000000000..847028d32 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/entity/model/DriverLeaseStateDO.java @@ -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 . + */ + +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; +} diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServer.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServer.java index 04913b8a1..b579d0072 100644 --- a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServer.java +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServer.java @@ -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 responseObserver) { @@ -135,10 +143,6 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase { .toList(); builder.addAllEventAttributes(grpcEventAttributeDTOList); - // Attach the device ids bound to this driver - List 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 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 responseObserver) { TenantContextHolder.setTenantId(request.getTenantId()); @@ -221,8 +248,58 @@ public class DriverDriverServer extends DriverApiGrpc.DriverApiImplBase { .toList(); builder.addAllEventAttributes(eventAttributeDTOList); - List idList = Optional.ofNullable(deviceService.listIdsByDriverId(entityBO.getId(), entityBO.getTenantId())).orElseGet(List::of); - builder.addAllDeviceIds(idList); + } + + private void streamAssignmentSnapshot(GrpcDriverLeaseRequest request, DriverLeaseGrantBO grant, + StreamObserver responseObserver) { + long afterDeviceId = 0; + while (true) { + assertAssignmentVersion(request, grant.assignmentVersion()); + List page = driverLeaseService.listOwnedLeases( + request.getTenantId(), request.getDriverId(), request.getNode(), afterDeviceId, + ASSIGNMENT_BATCH_SIZE + 1); + boolean complete = page.size() <= ASSIGNMENT_BATCH_SIZE; + List 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 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 toGrpcLeases(List leases) { + return leases.stream() + .map(lease -> GrpcDeviceLeaseDTO.newBuilder() + .setDeviceId(lease.deviceId()) + .setFencingToken(lease.fencingToken()) + .build()) + .toList(); } } diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/manager/ManagerDeviceServer.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/manager/ManagerDeviceServer.java index 0c7046bc6..f7eb7ad9c 100644 --- a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/manager/ManagerDeviceServer.java +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/grpc/server/manager/ManagerDeviceServer.java @@ -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 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 responseObserver) { TenantContextHolder.setTenantId(request.getTenantId()); diff --git a/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/mapper/DriverLeaseMapper.java b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/mapper/DriverLeaseMapper.java new file mode 100644 index 000000000..89c156265 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/java/io/github/pnoker/common/manager/mapper/DriverLeaseMapper.java @@ -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 . + */ + +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 listActiveNodes(@Param("tenantId") Long tenantId, + @Param("driverId") Long driverId); + + List 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 leases); + + int deleteOrphanedLeases(@Param("tenantId") Long tenantId, + @Param("driverId") Long driverId); + + List 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); +} diff --git a/dc3-common/dc3-common-manager/src/main/resources/mapping/DriverLeaseMapper.xml b/dc3-common/dc3-common-manager/src/main/resources/mapping/DriverLeaseMapper.xml new file mode 100644 index 000000000..0dc0c38a6 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/main/resources/mapping/DriverLeaseMapper.xml @@ -0,0 +1,146 @@ + + + + + + + + + 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 + + + + + + + + + + + + + + DELETE FROM dc3_driver_instance + WHERE tenant_id = #{tenantId} + AND driver_id = #{driverId} + AND lease_until < #{expiredBefore} + + + + INSERT INTO dc3_device_lease (tenant_id, driver_id, device_id, owner_node) + VALUES + + (#{lease.tenantId}, #{lease.driverId}, #{lease.deviceId}, #{lease.ownerNode}) + + 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 + + + + 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 + ) + + + + + + + diff --git a/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImplTest.java b/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImplTest.java new file mode 100644 index 000000000..e00d63d69 --- /dev/null +++ b/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/biz/impl/DriverLeaseServiceImplTest.java @@ -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 . + */ + +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 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> 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 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); + } +} diff --git a/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServerTest.java b/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServerTest.java index a44f1fc9e..04b097069 100644 --- a/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServerTest.java +++ b/dc3-common/dc3-common-manager/src/test/java/io/github/pnoker/common/manager/grpc/server/driver/DriverDriverServerTest.java @@ -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 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 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 diff --git a/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/CommandCallDTO.java b/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/CommandCallDTO.java index e399cf9cc..6fc72e531 100644 --- a/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/CommandCallDTO.java +++ b/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/CommandCallDTO.java @@ -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); } } diff --git a/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/PointCommandDTO.java b/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/PointCommandDTO.java index b4c32a6b4..bc84736a5 100644 --- a/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/PointCommandDTO.java +++ b/dc3-common/dc3-common-model/src/main/java/io/github/pnoker/common/entity/dto/PointCommandDTO.java @@ -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, diff --git a/dc3-common/dc3-common-mqtt/src/main/java/io/github/pnoker/common/config/MqttEnvironmentConfig.java b/dc3-common/dc3-common-mqtt/src/main/java/io/github/pnoker/common/config/MqttEnvironmentConfig.java index f49e54992..2341640f9 100644 --- a/dc3-common/dc3-common-mqtt/src/main/java/io/github/pnoker/common/config/MqttEnvironmentConfig.java +++ b/dc3-common/dc3-common-mqtt/src/main/java/io/github/pnoker/common/config/MqttEnvironmentConfig.java @@ -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 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)); - } - } - } diff --git a/dc3-common/dc3-common-rabbitmq/src/main/java/io/github/pnoker/common/utils/RabbitPublishConfirm.java b/dc3-common/dc3-common-rabbitmq/src/main/java/io/github/pnoker/common/utils/RabbitPublishConfirm.java new file mode 100644 index 000000000..911b57619 --- /dev/null +++ b/dc3-common/dc3-common-rabbitmq/src/main/java/io/github/pnoker/common/utils/RabbitPublishConfirm.java @@ -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 . + */ + +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); + } + } +} diff --git a/dc3-common/dc3-common-rabbitmq/src/test/java/io/github/pnoker/common/utils/RabbitPublishConfirmTest.java b/dc3-common/dc3-common-rabbitmq/src/test/java/io/github/pnoker/common/utils/RabbitPublishConfirmTest.java new file mode 100644 index 000000000..bf6a7e16c --- /dev/null +++ b/dc3-common/dc3-common-rabbitmq/src/test/java/io/github/pnoker/common/utils/RabbitPublishConfirmTest.java @@ -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 . + */ + +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"); + } +} diff --git a/dc3-common/dc3-common-repository/README.md b/dc3-common/dc3-common-repository/README.md index 88415dc71..1e0029a2c 100644 --- a/dc3-common/dc3-common-repository/README.md +++ b/dc3-common/dc3-common-repository/README.md @@ -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 diff --git a/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/entity/bo/PointValueBO.java b/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/entity/bo/PointValueBO.java index 2f803b042..b88bcd753 100644 --- a/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/entity/bo/PointValueBO.java +++ b/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/entity/bo/PointValueBO.java @@ -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 */ diff --git a/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/repository/RepositoryService.java b/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/repository/RepositoryService.java index 0d209667d..266365a24 100644 --- a/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/repository/RepositoryService.java +++ b/dc3-common/dc3-common-repository/src/main/java/io/github/pnoker/common/repository/RepositoryService.java @@ -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 entityBOList) throws IOException; + List savePointValues(List entityBOList) throws IOException; /** * Get historical point values within the tenant scope. diff --git a/dc3-common/dc3-common-repository/src/test/java/io/github/pnoker/common/strategy/RepositoryStrategyFactoryTest.java b/dc3-common/dc3-common-repository/src/test/java/io/github/pnoker/common/strategy/RepositoryStrategyFactoryTest.java index 1a03c732a..1197cbc21 100644 --- a/dc3-common/dc3-common-repository/src/test/java/io/github/pnoker/common/strategy/RepositoryStrategyFactoryTest.java +++ b/dc3-common/dc3-common-repository/src/test/java/io/github/pnoker/common/strategy/RepositoryStrategyFactoryTest.java @@ -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 entityBOList) throws IOException { + public List savePointValues(List entityBOList) throws IOException { + return entityBOList; // no-op } diff --git a/dc3-driver/dc3-driver-bacnet-ip/src/main/resources/application.yml b/dc3-driver/dc3-driver-bacnet-ip/src/main/resources/application.yml index eef481c91..ce1e0d590 100644 --- a/dc3-driver/dc3-driver-bacnet-ip/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-bacnet-ip/src/main/resources/application.yml @@ -106,7 +106,6 @@ dc3: remark: Property identifier buffer: - enable: true db-path: dc3/data/driver/bacnet-ip/buffer.db spring: diff --git a/dc3-driver/dc3-driver-ble/src/main/resources/application.yml b/dc3-driver/dc3-driver-ble/src/main/resources/application.yml index 2ad9af888..8d6145770 100644 --- a/dc3-driver/dc3-driver-ble/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-ble/src/main/resources/application.yml @@ -86,7 +86,6 @@ dc3: remark: GATT Characteristic UUID for writing buffer: - enable: true db-path: dc3/data/driver/ble/buffer.db spring: diff --git a/dc3-driver/dc3-driver-can/src/main/resources/application.yml b/dc3-driver/dc3-driver-can/src/main/resources/application.yml index 7986470c0..539fc181d 100644 --- a/dc3-driver/dc3-driver-can/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-can/src/main/resources/application.yml @@ -90,7 +90,6 @@ dc3: default-value: '${value}' buffer: - enable: true db-path: dc3/data/driver/can/buffer.db spring: diff --git a/dc3-driver/dc3-driver-coap/src/main/resources/application.yml b/dc3-driver/dc3-driver-coap/src/main/resources/application.yml index a6224fd85..ac4fb6c5d 100644 --- a/dc3-driver/dc3-driver-coap/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-coap/src/main/resources/application.yml @@ -65,7 +65,6 @@ dc3: Content format: json, text, cbor, octet-stream buffer: - enable: true db-path: dc3/data/driver/coap/buffer.db spring: diff --git a/dc3-driver/dc3-driver-dlms/src/main/resources/application.yml b/dc3-driver/dc3-driver-dlms/src/main/resources/application.yml index 9fd5e0f06..e3b8d20a6 100644 --- a/dc3-driver/dc3-driver-dlms/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-dlms/src/main/resources/application.yml @@ -101,7 +101,6 @@ dc3: command-attribute: [ ] buffer: - enable: true db-path: dc3/data/driver/dlms/buffer.db spring: diff --git a/dc3-driver/dc3-driver-dlt645/src/main/resources/application.yml b/dc3-driver/dc3-driver-dlt645/src/main/resources/application.yml index 6209af491..79c721771 100644 --- a/dc3-driver/dc3-driver-dlt645/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-dlt645/src/main/resources/application.yml @@ -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: diff --git a/dc3-driver/dc3-driver-dnp3/src/main/resources/application.yml b/dc3-driver/dc3-driver-dnp3/src/main/resources/application.yml index dc2d8e039..4f787e19a 100644 --- a/dc3-driver/dc3-driver-dnp3/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-dnp3/src/main/resources/application.yml @@ -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: diff --git a/dc3-driver/dc3-driver-ethernet-ip/src/main/resources/application.yml b/dc3-driver/dc3-driver-ethernet-ip/src/main/resources/application.yml index 612ae77de..1b5696347 100644 --- a/dc3-driver/dc3-driver-ethernet-ip/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-ethernet-ip/src/main/resources/application.yml @@ -72,7 +72,6 @@ dc3: default-value: '${value}' buffer: - enable: true db-path: dc3/data/driver/ethernet-ip/buffer.db spring: diff --git a/dc3-driver/dc3-driver-fins/src/main/resources/application.yml b/dc3-driver/dc3-driver-fins/src/main/resources/application.yml index f11224159..958ce58d1 100644 --- a/dc3-driver/dc3-driver-fins/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-fins/src/main/resources/application.yml @@ -103,7 +103,6 @@ dc3: default-value: UINT16 buffer: - enable: true db-path: dc3/data/driver/fins/buffer.db spring: diff --git a/dc3-driver/dc3-driver-http/src/main/resources/application.yml b/dc3-driver/dc3-driver-http/src/main/resources/application.yml index a5874589e..8d846003f 100644 --- a/dc3-driver/dc3-driver-http/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-http/src/main/resources/application.yml @@ -91,7 +91,6 @@ dc3: remark: HTTP method for command buffer: - enable: true db-path: dc3/data/driver/http/buffer.db spring: diff --git a/dc3-driver/dc3-driver-iec104/src/main/resources/application.yml b/dc3-driver/dc3-driver-iec104/src/main/resources/application.yml index 011932883..995e19ca7 100644 --- a/dc3-driver/dc3-driver-iec104/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-iec104/src/main/resources/application.yml @@ -80,7 +80,6 @@ dc3: default-value: '${value}' buffer: - enable: true db-path: dc3/data/driver/iec104/buffer.db spring: diff --git a/dc3-driver/dc3-driver-iec61850/src/main/resources/application.yml b/dc3-driver/dc3-driver-iec61850/src/main/resources/application.yml index d65174efe..6c77c5bd6 100644 --- a/dc3-driver/dc3-driver-iec61850/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-iec61850/src/main/resources/application.yml @@ -64,7 +64,6 @@ dc3: remark: Data object reference for commands buffer: - enable: true db-path: dc3/data/driver/iec61850/buffer.db spring: diff --git a/dc3-driver/dc3-driver-kafka/src/main/resources/application.yml b/dc3-driver/dc3-driver-kafka/src/main/resources/application.yml index 5a9967a5b..420ef709a 100644 --- a/dc3-driver/dc3-driver-kafka/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-kafka/src/main/resources/application.yml @@ -62,7 +62,6 @@ dc3: remark: Override topic for this command buffer: - enable: true db-path: dc3/data/driver/kafka/buffer.db spring: diff --git a/dc3-driver/dc3-driver-knx/src/main/resources/application.yml b/dc3-driver/dc3-driver-knx/src/main/resources/application.yml index 5bf1a3fdb..182b23fa1 100644 --- a/dc3-driver/dc3-driver-knx/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-knx/src/main/resources/application.yml @@ -84,7 +84,6 @@ dc3: remark: KNX group address for commands buffer: - enable: true db-path: dc3/data/driver/knx/buffer.db spring: diff --git a/dc3-driver/dc3-driver-listening-virtual/src/main/resources/application.yml b/dc3-driver/dc3-driver-listening-virtual/src/main/resources/application.yml index ae091af45..9a3be7eb0 100644 --- a/dc3-driver/dc3-driver-listening-virtual/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-listening-virtual/src/main/resources/application.yml @@ -64,7 +64,6 @@ dc3: remark: Parse type, short, int, long, float, double, boolean, string buffer: - enable: true db-path: dc3/data/driver/listening-virtual/buffer.db spring: diff --git a/dc3-driver/dc3-driver-lorawan/src/main/resources/application.yml b/dc3-driver/dc3-driver-lorawan/src/main/resources/application.yml index d50e38d0f..3d340613b 100644 --- a/dc3-driver/dc3-driver-lorawan/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-lorawan/src/main/resources/application.yml @@ -79,7 +79,6 @@ dc3: remark: LoRaWAN device EUI for downlink buffer: - enable: true db-path: dc3/data/driver/lorawan/buffer.db spring: diff --git a/dc3-driver/dc3-driver-lwm2m/src/main/resources/application.yml b/dc3-driver/dc3-driver-lwm2m/src/main/resources/application.yml index 836f444a9..994633e9f 100644 --- a/dc3-driver/dc3-driver-lwm2m/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-lwm2m/src/main/resources/application.yml @@ -101,7 +101,6 @@ dc3: security-mode: NOSEC buffer: - enable: true db-path: dc3/data/driver/lwm2m/buffer.db spring: diff --git a/dc3-driver/dc3-driver-mbus/src/main/resources/application.yml b/dc3-driver/dc3-driver-mbus/src/main/resources/application.yml index fbf1199e7..4e9d2362f 100644 --- a/dc3-driver/dc3-driver-mbus/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-mbus/src/main/resources/application.yml @@ -89,7 +89,6 @@ dc3: remark: Data format for encoding the written value buffer: - enable: true db-path: dc3/data/driver/mbus/buffer.db spring: diff --git a/dc3-driver/dc3-driver-melsec/src/main/resources/application.yml b/dc3-driver/dc3-driver-melsec/src/main/resources/application.yml index d0713615f..01358de84 100644 --- a/dc3-driver/dc3-driver-melsec/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-melsec/src/main/resources/application.yml @@ -65,7 +65,6 @@ dc3: remark: String read length (0 for non-string types) buffer: - enable: true db-path: dc3/data/driver/melsec/buffer.db spring: diff --git a/dc3-driver/dc3-driver-modbus-rtu/src/main/resources/application.yml b/dc3-driver/dc3-driver-modbus-rtu/src/main/resources/application.yml index 1ee3cc295..20d59e73c 100644 --- a/dc3-driver/dc3-driver-modbus-rtu/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-modbus-rtu/src/main/resources/application.yml @@ -101,7 +101,6 @@ dc3: remark: Value template rendered with command params buffer: - enable: true db-path: dc3/data/driver/modbus-rtu/buffer.db spring: diff --git a/dc3-driver/dc3-driver-modbus-tcp/src/main/resources/application.yml b/dc3-driver/dc3-driver-modbus-tcp/src/main/resources/application.yml index 02911ec61..9a56316f3 100644 --- a/dc3-driver/dc3-driver-modbus-tcp/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-modbus-tcp/src/main/resources/application.yml @@ -86,7 +86,6 @@ dc3: remark: Value template rendered with command params buffer: - enable: true db-path: dc3/data/driver/modbus-tcp/buffer.db spring: diff --git a/dc3-driver/dc3-driver-mqtt/src/main/resources/application.yml b/dc3-driver/dc3-driver-mqtt/src/main/resources/application.yml index 4e8507c75..1b1ec6f6e 100644 --- a/dc3-driver/dc3-driver-mqtt/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-mqtt/src/main/resources/application.yml @@ -81,7 +81,6 @@ dc3: remark: JSON path used to resolve event payload buffer: - enable: true db-path: dc3/data/driver/mqtt/buffer.db spring: diff --git a/dc3-driver/dc3-driver-mqtt/src/test/java/io/github/pnoker/driver/mqtt/service/impl/MqttReceiveServiceImplTest.java b/dc3-driver/dc3-driver-mqtt/src/test/java/io/github/pnoker/driver/mqtt/service/impl/MqttReceiveServiceImplTest.java index 65770b98d..69e25fcc3 100644 --- a/dc3-driver/dc3-driver-mqtt/src/test/java/io/github/pnoker/driver/mqtt/service/impl/MqttReceiveServiceImplTest.java +++ b/dc3-driver/dc3-driver-mqtt/src/test/java/io/github/pnoker/driver/mqtt/service/impl/MqttReceiveServiceImplTest.java @@ -147,7 +147,7 @@ class MqttReceiveServiceImplTest { @Test void receiveEventMessageMatchesConfiguredTopicAndReportsEvent() { - driverMetadata.addDeviceId(10L); + installDeviceLease(); driverMetadata.setEventAttributeIdMap(Map.of( 1L, eventAttribute(1L, "sourceTopic"), 2L, eventAttribute(2L, "eventCodePath"), @@ -192,7 +192,7 @@ class MqttReceiveServiceImplTest { @Test void eventMessageWithPointIdentityReportsBothEventAndPointValue() { - driverMetadata.addDeviceId(10L); + installDeviceLease(); driverMetadata.setEventAttributeIdMap(Map.of( 1L, eventAttribute(1L, "sourceTopic"), 2L, eventAttribute(2L, "eventCodePath"), @@ -233,7 +233,7 @@ class MqttReceiveServiceImplTest { @Test void eventReportFailureDoesNotDropPointValue() { - driverMetadata.addDeviceId(10L); + installDeviceLease(); driverMetadata.setEventAttributeIdMap(Map.of( 1L, eventAttribute(1L, "sourceTopic"), 2L, eventAttribute(2L, "eventCodePath"), @@ -258,4 +258,8 @@ class MqttReceiveServiceImplTest { verify(driverSenderService).pointValueSender(any(PointValue.class)); } + + private void installDeviceLease() { + driverMetadata.setDeviceLeases(Map.of(10L, 1L), System.currentTimeMillis() + 60_000, 1L); + } } diff --git a/dc3-driver/dc3-driver-mysql/src/main/resources/application.yml b/dc3-driver/dc3-driver-mysql/src/main/resources/application.yml index f921790e6..7b83a5201 100644 --- a/dc3-driver/dc3-driver-mysql/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-mysql/src/main/resources/application.yml @@ -86,7 +86,6 @@ dc3: remark: SQL query to execute for command buffer: - enable: true db-path: dc3/data/driver/mysql/buffer.db spring: diff --git a/dc3-driver/dc3-driver-opc-da/src/main/resources/application.yml b/dc3-driver/dc3-driver-opc-da/src/main/resources/application.yml index 6a944e062..bc678afc2 100644 --- a/dc3-driver/dc3-driver-opc-da/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-opc-da/src/main/resources/application.yml @@ -70,7 +70,6 @@ dc3: remark: OPC DA item tag name buffer: - enable: true db-path: dc3/data/driver/opc-da/buffer.db spring: diff --git a/dc3-driver/dc3-driver-opc-ua/src/main/resources/application.yml b/dc3-driver/dc3-driver-opc-ua/src/main/resources/application.yml index c7983d79b..63db8e58f 100644 --- a/dc3-driver/dc3-driver-opc-ua/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-opc-ua/src/main/resources/application.yml @@ -65,7 +65,6 @@ dc3: remark: OPC UA node tag name buffer: - enable: true db-path: dc3/data/driver/opc-ua/buffer.db spring: diff --git a/dc3-driver/dc3-driver-oracle/src/main/resources/application.yml b/dc3-driver/dc3-driver-oracle/src/main/resources/application.yml index a9476607b..0adcfeaa3 100644 --- a/dc3-driver/dc3-driver-oracle/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-oracle/src/main/resources/application.yml @@ -101,7 +101,6 @@ dc3: remark: SQL query to execute for command buffer: - enable: true db-path: dc3/data/driver/oracle/buffer.db spring: diff --git a/dc3-driver/dc3-driver-plcs7/src/main/resources/application.yml b/dc3-driver/dc3-driver-plcs7/src/main/resources/application.yml index b4547e23b..3f5b8e743 100644 --- a/dc3-driver/dc3-driver-plcs7/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-plcs7/src/main/resources/application.yml @@ -70,7 +70,6 @@ dc3: remark: Bit offset (only used for boolean type) buffer: - enable: true db-path: dc3/data/driver/plcs7/buffer.db spring: diff --git a/dc3-driver/dc3-driver-postgresql/src/main/resources/application.yml b/dc3-driver/dc3-driver-postgresql/src/main/resources/application.yml index 990f54a1d..9e9307d02 100644 --- a/dc3-driver/dc3-driver-postgresql/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-postgresql/src/main/resources/application.yml @@ -86,7 +86,6 @@ dc3: remark: SQL query to execute for command buffer: - enable: true db-path: dc3/data/driver/postgresql/buffer.db spring: diff --git a/dc3-driver/dc3-driver-redis/src/main/resources/application.yml b/dc3-driver/dc3-driver-redis/src/main/resources/application.yml index 257facc8e..dd329ceb4 100644 --- a/dc3-driver/dc3-driver-redis/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-redis/src/main/resources/application.yml @@ -64,7 +64,6 @@ dc3: remark: Redis data type: STRING or HASH buffer: - enable: true db-path: dc3/data/driver/redis/buffer.db spring: diff --git a/dc3-driver/dc3-driver-serial/src/main/resources/application.yml b/dc3-driver/dc3-driver-serial/src/main/resources/application.yml index 5bac30ef0..c56017150 100644 --- a/dc3-driver/dc3-driver-serial/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-serial/src/main/resources/application.yml @@ -129,7 +129,6 @@ dc3: Byte order for encoding value: BIG, LITTLE buffer: - enable: true db-path: dc3/data/driver/serial/buffer.db spring: diff --git a/dc3-driver/dc3-driver-sl651/src/main/resources/application.yml b/dc3-driver/dc3-driver-sl651/src/main/resources/application.yml index ee6f5a3c5..5a27b2ec9 100644 --- a/dc3-driver/dc3-driver-sl651/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-sl651/src/main/resources/application.yml @@ -53,7 +53,6 @@ dc3: remark: Zero-based index into the telemetry body element list buffer: - enable: true db-path: dc3/data/driver/sl651/buffer.db spring: diff --git a/dc3-driver/dc3-driver-sl651/src/test/java/io/github/pnoker/driver/service/impl/Sl651DriverCustomServiceImplTest.java b/dc3-driver/dc3-driver-sl651/src/test/java/io/github/pnoker/driver/service/impl/Sl651DriverCustomServiceImplTest.java index 35dad7a0d..116ec5b39 100644 --- a/dc3-driver/dc3-driver-sl651/src/test/java/io/github/pnoker/driver/service/impl/Sl651DriverCustomServiceImplTest.java +++ b/dc3-driver/dc3-driver-sl651/src/test/java/io/github/pnoker/driver/service/impl/Sl651DriverCustomServiceImplTest.java @@ -63,7 +63,7 @@ class Sl651DriverCustomServiceImplTest { @Test void forwardTelemetryMapsStationElementsToConfiguredPoints() { - driverMetadata.addDeviceId(10L); + driverMetadata.setDeviceLeases(Map.of(10L, 1L), System.currentTimeMillis() + 60_000, 1L); DeviceBO device = new DeviceBO(); device.setId(10L); device.setDeviceCode("01020304"); diff --git a/dc3-driver/dc3-driver-snmp/src/main/resources/application.yml b/dc3-driver/dc3-driver-snmp/src/main/resources/application.yml index b2840868f..cb2734913 100644 --- a/dc3-driver/dc3-driver-snmp/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-snmp/src/main/resources/application.yml @@ -98,7 +98,6 @@ dc3: default-value: OCTET_STRING buffer: - enable: true db-path: dc3/data/driver/snmp/buffer.db spring: diff --git a/dc3-driver/dc3-driver-sqlserver/src/main/resources/application.yml b/dc3-driver/dc3-driver-sqlserver/src/main/resources/application.yml index 9fefa8d35..79a49b0ce 100644 --- a/dc3-driver/dc3-driver-sqlserver/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-sqlserver/src/main/resources/application.yml @@ -96,7 +96,6 @@ dc3: remark: SQL query to execute for command buffer: - enable: true db-path: dc3/data/driver/sqlserver/buffer.db spring: diff --git a/dc3-driver/dc3-driver-tcp-udp/src/main/resources/application.yml b/dc3-driver/dc3-driver-tcp-udp/src/main/resources/application.yml index 8f4ab0fc6..e56b4baef 100644 --- a/dc3-driver/dc3-driver-tcp-udp/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-tcp-udp/src/main/resources/application.yml @@ -104,7 +104,6 @@ dc3: default-value: '${value}' buffer: - enable: true db-path: dc3/data/driver/tcp-udp/buffer.db spring: diff --git a/dc3-driver/dc3-driver-virtual/src/main/resources/application.yml b/dc3-driver/dc3-driver-virtual/src/main/resources/application.yml index f4b1f05ee..0aa32d0ce 100644 --- a/dc3-driver/dc3-driver-virtual/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-virtual/src/main/resources/application.yml @@ -77,7 +77,6 @@ dc3: remark: JSON path used to resolve event payload buffer: - enable: true db-path: dc3/data/driver/virtual/buffer.db spring: diff --git a/dc3-driver/dc3-driver-zigbee/src/main/resources/application.yml b/dc3-driver/dc3-driver-zigbee/src/main/resources/application.yml index f830a7c0e..44977118e 100644 --- a/dc3-driver/dc3-driver-zigbee/src/main/resources/application.yml +++ b/dc3-driver/dc3-driver-zigbee/src/main/resources/application.yml @@ -106,7 +106,6 @@ dc3: remark: Attribute ID for writing buffer: - enable: true db-path: dc3/data/driver/zigbee/buffer.db spring: diff --git a/dc3/dependencies/postgres/initdb/04-iot-dc3-manager.sql b/dc3/dependencies/postgres/initdb/04-iot-dc3-manager.sql index b4206317f..294c1a9cb 100644 --- a/dc3/dependencies/postgres/initdb/04-iot-dc3-manager.sql +++ b/dc3/dependencies/postgres/initdb/04-iot-dc3-manager.sql @@ -110,6 +110,73 @@ ON COLUMN dc3_driver.operate_time IS 'Operation time'; COMMENT ON COLUMN dc3_driver.deleted IS 'Logical delete flag, 0: not deleted, 1: deleted'; +-- ---------------------------- +-- Driver runtime instances and device ownership +-- ---------------------------- +-- dc3_driver is the logical protocol definition. Runtime replicas are recorded +-- separately so registering a second pod never overwrites the first pod's identity. +CREATE TABLE dc3_driver_instance +( + tenant_id BIGINT NOT NULL, + driver_id BIGINT NOT NULL, + node_id TEXT NOT NULL, + client_id TEXT NOT NULL, + service_host TEXT NOT NULL, + started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_heartbeat TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + lease_until TIMESTAMPTZ NOT NULL, + PRIMARY KEY (tenant_id, driver_id, node_id), + UNIQUE (tenant_id, client_id) +); + +CREATE INDEX idx_driver_instance_active + ON dc3_driver_instance (tenant_id, driver_id, lease_until DESC); + +CREATE SEQUENCE dc3_device_lease_fencing_seq; + +CREATE TABLE dc3_device_lease +( + tenant_id BIGINT NOT NULL, + driver_id BIGINT NOT NULL, + device_id BIGINT NOT NULL, + owner_node TEXT NOT NULL, + fencing_token BIGINT NOT NULL DEFAULT nextval('dc3_device_lease_fencing_seq'), + operate_time TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (tenant_id, device_id) +); + +CREATE INDEX idx_device_lease_owner + ON dc3_device_lease (tenant_id, driver_id, owner_node, device_id) + INCLUDE (fencing_token); + +CREATE SEQUENCE dc3_driver_device_revision_seq; + +CREATE TABLE dc3_driver_device_revision +( + tenant_id BIGINT NOT NULL, + driver_id BIGINT NOT NULL, + revision BIGINT NOT NULL, + PRIMARY KEY (tenant_id, driver_id) +); + +CREATE SEQUENCE dc3_driver_assignment_version_seq; + +CREATE TABLE dc3_driver_lease_state +( + tenant_id BIGINT NOT NULL, + driver_id BIGINT NOT NULL, + membership_hash VARCHAR(64) NOT NULL, + device_revision BIGINT NOT NULL, + assignment_version BIGINT NOT NULL DEFAULT nextval('dc3_driver_assignment_version_seq'), + operate_time TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (tenant_id, driver_id) +); + +COMMENT ON TABLE dc3_driver_instance IS 'Leased runtime replicas of a logical driver definition'; +COMMENT ON TABLE dc3_device_lease IS 'Single-owner device assignments with monotonic fencing tokens'; +COMMENT ON TABLE dc3_driver_device_revision IS 'O(1) change detector for each logical driver device set'; +COMMENT ON TABLE dc3_driver_lease_state IS 'Membership fingerprint and assignment version for incremental driver heartbeats'; + -- ---------------------------- -- Table structure for dc3_driver_attribute -- ---------------------------- @@ -628,6 +695,8 @@ CREATE INDEX idx_device_driver_id ON dc3_device (driver_id) WHERE deleted = 0; CREATE INDEX idx_device_profile ON dc3_device (tenant_id, profile_id) WHERE deleted = 0; -- Supports listByDriverId queries with tenant scoping. CREATE INDEX idx_device_tenant_driver ON dc3_device (tenant_id, driver_id) WHERE deleted = 0; +CREATE INDEX idx_device_active_driver_assignment + ON dc3_device (tenant_id, driver_id, id) WHERE deleted = 0 AND enable_flag = 0; CREATE TRIGGER update_operate_time_trigger BEFORE UPDATE @@ -635,6 +704,100 @@ CREATE TRIGGER update_operate_time_trigger FOR EACH ROW EXECUTE FUNCTION update_operate_time(); +-- Device ownership is recomputed only when the active device set changes. The +-- trigger turns that change into a single-row revision lookup on every driver +-- heartbeat instead of scanning all devices and leases. +CREATE OR REPLACE FUNCTION track_driver_device_revision_insert() + RETURNS TRIGGER AS +$$ +BEGIN + INSERT INTO dc3_driver_device_revision (tenant_id, driver_id, revision) + SELECT changed.tenant_id, changed.driver_id, nextval('dc3_driver_device_revision_seq') + FROM ( + SELECT DISTINCT tenant_id, driver_id + FROM new_device_rows + WHERE deleted = 0 AND enable_flag = 0 + ) changed + ON CONFLICT (tenant_id, driver_id) DO UPDATE SET + revision = EXCLUDED.revision; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION track_driver_device_revision_delete() + RETURNS TRIGGER AS +$$ +BEGIN + INSERT INTO dc3_driver_device_revision (tenant_id, driver_id, revision) + SELECT changed.tenant_id, changed.driver_id, nextval('dc3_driver_device_revision_seq') + FROM ( + SELECT DISTINCT tenant_id, driver_id + FROM old_device_rows + WHERE deleted = 0 AND enable_flag = 0 + ) changed + ON CONFLICT (tenant_id, driver_id) DO UPDATE SET + revision = EXCLUDED.revision; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION track_driver_device_revision_update() + RETURNS TRIGGER AS +$$ +BEGIN + INSERT INTO dc3_driver_device_revision (tenant_id, driver_id, revision) + SELECT changed.tenant_id, changed.driver_id, nextval('dc3_driver_device_revision_seq') + FROM ( + SELECT DISTINCT old_rows.tenant_id, old_rows.driver_id + FROM old_device_rows old_rows + FULL JOIN new_device_rows new_rows USING (id) + WHERE old_rows.deleted = 0 AND old_rows.enable_flag = 0 + AND (new_rows.id IS NULL + OR old_rows.tenant_id IS DISTINCT FROM new_rows.tenant_id + OR old_rows.driver_id IS DISTINCT FROM new_rows.driver_id + OR old_rows.deleted IS DISTINCT FROM new_rows.deleted + OR old_rows.enable_flag IS DISTINCT FROM new_rows.enable_flag) + UNION + SELECT DISTINCT new_rows.tenant_id, new_rows.driver_id + FROM old_device_rows old_rows + FULL JOIN new_device_rows new_rows USING (id) + WHERE new_rows.deleted = 0 AND new_rows.enable_flag = 0 + AND (old_rows.id IS NULL + OR old_rows.tenant_id IS DISTINCT FROM new_rows.tenant_id + OR old_rows.driver_id IS DISTINCT FROM new_rows.driver_id + OR old_rows.deleted IS DISTINCT FROM new_rows.deleted + OR old_rows.enable_flag IS DISTINCT FROM new_rows.enable_flag) + ) changed + ON CONFLICT (tenant_id, driver_id) DO UPDATE SET + revision = EXCLUDED.revision; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER track_driver_device_revision_insert_trigger + AFTER INSERT + ON dc3_device + REFERENCING NEW TABLE AS new_device_rows + FOR EACH STATEMENT + EXECUTE FUNCTION track_driver_device_revision_insert(); + +CREATE TRIGGER track_driver_device_revision_delete_trigger + AFTER DELETE + ON dc3_device + REFERENCING OLD TABLE AS old_device_rows + FOR EACH STATEMENT + EXECUTE FUNCTION track_driver_device_revision_delete(); + +CREATE TRIGGER track_driver_device_revision_update_trigger + AFTER UPDATE + ON dc3_device + REFERENCING OLD TABLE AS old_device_rows NEW TABLE AS new_device_rows + FOR EACH STATEMENT + EXECUTE FUNCTION track_driver_device_revision_update(); + COMMENT ON TABLE dc3_device IS 'Device table'; COMMENT diff --git a/dc3/dependencies/postgres/initdb/05-iot-dc3-history.sql b/dc3/dependencies/postgres/initdb/05-iot-dc3-history.sql index d871231b8..cd7da70fd 100644 --- a/dc3/dependencies/postgres/initdb/05-iot-dc3-history.sql +++ b/dc3/dependencies/postgres/initdb/05-iot-dc3-history.sql @@ -18,8 +18,9 @@ CREATE SCHEMA IF NOT EXISTS dc3_history; SET search_path TO dc3_history, public; --- History hypertables are treated as append-only data here. --- Keep operate_time for compatibility, but do not maintain UPDATE triggers. +-- History hypertables are append-only. create_time is the device acquisition +-- timestamp; operate_time is the platform persistence timestamp. Writers set +-- both explicitly, so history tables do not need UPDATE triggers. -- -- Storage model: a single dc3_point_value hypertable holds every sample -- regardless of declared point type. The textual raw_value / cal_value @@ -34,6 +35,11 @@ SET search_path TO dc3_history, public; -- ---------------------------- CREATE TABLE dc3_point_value ( + message_id VARCHAR(36) NOT NULL, -- Immutable event identity + schema_version INTEGER NOT NULL, -- Point-value wire schema version + driver_node VARCHAR(128) NOT NULL, -- Producing driver runtime node + sequence BIGINT NOT NULL, -- Monotonic sequence within driver_node + fencing_token BIGINT NOT NULL, -- Manager-issued ownership fence device_id BIGINT DEFAULT 0 NOT NULL, -- Device ID point_id BIGINT DEFAULT 0 NOT NULL, -- Point ID raw_value TEXT DEFAULT ''::TEXT NOT NULL, -- Raw value as captured from the device @@ -80,6 +86,11 @@ FROM public.create_hypertable('dc3_point_value', public.by_range('create_time', SELECT * FROM public.add_dimension('dc3_point_value', public.by_hash('device_id', 16)); +-- TimescaleDB requires every partitioning column in a unique index. The event id, +-- acquisition time, and device hash dimension together provide replay idempotency. +CREATE UNIQUE INDEX uk_point_value_event + ON dc3_point_value (message_id, create_time, device_id); + ALTER TABLE dc3_point_value SET( timescaledb.compress, @@ -88,3 +99,32 @@ SET( ); SELECT public.add_compression_policy('dc3_point_value', INTERVAL '7 days'); SELECT public.add_retention_policy('dc3_point_value', INTERVAL '180 days'); + +-- ---------------------------- +-- Transactional latest-value projection +-- ---------------------------- +-- This is a normal PostgreSQL table, not an in-process cache. Every Data Center +-- replica reads and writes the same projection. History and latest are updated in +-- one transaction, and older/out-of-order readings cannot overwrite newer values. +CREATE TABLE dc3_point_latest +( + tenant_id BIGINT NOT NULL, + device_id BIGINT NOT NULL, + point_id BIGINT NOT NULL, + message_id VARCHAR(36) NOT NULL, + schema_version INTEGER NOT NULL, + driver_node VARCHAR(128) NOT NULL, + sequence BIGINT NOT NULL, + fencing_token BIGINT NOT NULL, + raw_value TEXT DEFAULT ''::TEXT NOT NULL, + cal_value TEXT DEFAULT ''::TEXT NOT NULL, + num_value DOUBLE PRECISION, + driver_id BIGINT DEFAULT 0 NOT NULL, + create_time TIMESTAMPTZ NOT NULL, + operate_time TIMESTAMPTZ NOT NULL, + PRIMARY KEY (tenant_id, device_id, point_id) +); + +CREATE INDEX idx_point_latest_driver ON dc3_point_latest (tenant_id, driver_id); + +COMMENT ON TABLE dc3_point_latest IS 'Transactional latest point value projection shared by all Data Center replicas'; diff --git a/dc3/docker-compose-dev.yml b/dc3/docker-compose-dev.yml index 5ae2ed0b8..7a08ac28a 100644 --- a/dc3/docker-compose-dev.yml +++ b/dc3/docker-compose-dev.yml @@ -44,6 +44,18 @@ x-app-runtime-env: &app-runtime-env MQTT_BROKER_PORT: "1883" MQTT_USERNAME: ${MQTT_USERNAME:-dc3} MQTT_PASSWORD: ${MQTT_PASSWORD:-dc3dc3dc3} + POINT_BATCH_SIZE: "${POINT_BATCH_SIZE:-500}" + POINT_BATCH_RECEIVE_TIMEOUT_MILLIS: "${POINT_BATCH_RECEIVE_TIMEOUT_MILLIS:-100}" + POINT_CONCURRENT_CONSUMERS: "${POINT_CONCURRENT_CONSUMERS:-4}" + POINT_MAX_CONCURRENT_CONSUMERS: "${POINT_MAX_CONCURRENT_CONSUMERS:-16}" + POINT_PREFETCH_COUNT: "${POINT_PREFETCH_COUNT:-1000}" + POINT_RETRY_MAX_RETRIES: "${POINT_RETRY_MAX_RETRIES:-3}" + POINT_RETRY_INITIAL_INTERVAL_MILLIS: "${POINT_RETRY_INITIAL_INTERVAL_MILLIS:-1000}" + POINT_RETRY_MULTIPLIER: "${POINT_RETRY_MULTIPLIER:-2}" + POINT_RETRY_MAX_INTERVAL_MILLIS: "${POINT_RETRY_MAX_INTERVAL_MILLIS:-10000}" + DC3_DRIVER_LEASE_SECONDS: "${DC3_DRIVER_LEASE_SECONDS:-30}" + DC3_DRIVER_LEASE_RENEW_CRON: "${DC3_DRIVER_LEASE_RENEW_CRON:-0/10 * * * * ?}" + DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS: "${DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS:-300000}" services: gateway: @@ -237,7 +249,7 @@ services: hostname: dc3-driver-listening-virtual volumes: - logs:/dc3-driver/dc3-driver-listening-virtual/dc3/logs - - driver_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data + - driver_listening_virtual_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data logging: *default-logging networks: dc3net: @@ -260,7 +272,7 @@ services: hostname: dc3-driver-modbus-tcp volumes: - logs:/dc3-driver/dc3-driver-modbus-tcp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data + - driver_modbus_tcp_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data logging: *default-logging networks: dc3net: @@ -283,7 +295,7 @@ services: hostname: dc3-driver-modbus-rtu volumes: - logs:/dc3-driver/dc3-driver-modbus-rtu/dc3/logs - - driver_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data + - driver_modbus_rtu_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data logging: *default-logging networks: dc3net: @@ -304,6 +316,14 @@ services: <<: *app-runtime-env container_name: dc3-driver-mqtt hostname: dc3-driver-mqtt + volumes: + - logs:/dc3-driver/dc3-driver-mqtt/dc3/logs + - driver_mqtt_data:/dc3-driver/dc3-driver-mqtt/dc3/data + logging: *default-logging + networks: + dc3net: + aliases: + - dc3-driver-mqtt dc3-driver-bacnet-ip: build: @@ -321,7 +341,7 @@ services: hostname: dc3-driver-bacnet-ip volumes: - logs:/dc3-driver/dc3-driver-bacnet-ip/dc3/logs - - driver_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data + - driver_bacnet_ip_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data logging: *default-logging networks: dc3net: @@ -344,7 +364,7 @@ services: hostname: dc3-driver-ble volumes: - logs:/dc3-driver/dc3-driver-ble/dc3/logs - - driver_data:/dc3-driver/dc3-driver-ble/dc3/data + - driver_ble_data:/dc3-driver/dc3-driver-ble/dc3/data logging: *default-logging networks: dc3net: @@ -367,7 +387,7 @@ services: hostname: dc3-driver-can volumes: - logs:/dc3-driver/dc3-driver-can/dc3/logs - - driver_data:/dc3-driver/dc3-driver-can/dc3/data + - driver_can_data:/dc3-driver/dc3-driver-can/dc3/data logging: *default-logging networks: dc3net: @@ -390,7 +410,7 @@ services: hostname: dc3-driver-coap volumes: - logs:/dc3-driver/dc3-driver-coap/dc3/logs - - driver_data:/dc3-driver/dc3-driver-coap/dc3/data + - driver_coap_data:/dc3-driver/dc3-driver-coap/dc3/data logging: *default-logging networks: dc3net: @@ -413,7 +433,7 @@ services: hostname: dc3-driver-dlms volumes: - logs:/dc3-driver/dc3-driver-dlms/dc3/logs - - driver_data:/dc3-driver/dc3-driver-dlms/dc3/data + - driver_dlms_data:/dc3-driver/dc3-driver-dlms/dc3/data logging: *default-logging networks: dc3net: @@ -436,7 +456,7 @@ services: hostname: dc3-driver-ethernet-ip volumes: - logs:/dc3-driver/dc3-driver-ethernet-ip/dc3/logs - - driver_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data + - driver_ethernet_ip_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data logging: *default-logging networks: dc3net: @@ -459,7 +479,7 @@ services: hostname: dc3-driver-fins volumes: - logs:/dc3-driver/dc3-driver-fins/dc3/logs - - driver_data:/dc3-driver/dc3-driver-fins/dc3/data + - driver_fins_data:/dc3-driver/dc3-driver-fins/dc3/data logging: *default-logging networks: dc3net: @@ -482,7 +502,7 @@ services: hostname: dc3-driver-http volumes: - logs:/dc3-driver/dc3-driver-http/dc3/logs - - driver_data:/dc3-driver/dc3-driver-http/dc3/data + - driver_http_data:/dc3-driver/dc3-driver-http/dc3/data logging: *default-logging networks: dc3net: @@ -505,7 +525,7 @@ services: hostname: dc3-driver-iec104 volumes: - logs:/dc3-driver/dc3-driver-iec104/dc3/logs - - driver_data:/dc3-driver/dc3-driver-iec104/dc3/data + - driver_iec104_data:/dc3-driver/dc3-driver-iec104/dc3/data logging: *default-logging networks: dc3net: @@ -528,7 +548,7 @@ services: hostname: dc3-driver-lwm2m volumes: - logs:/dc3-driver/dc3-driver-lwm2m/dc3/logs - - driver_data:/dc3-driver/dc3-driver-lwm2m/dc3/data + - driver_lwm2m_data:/dc3-driver/dc3-driver-lwm2m/dc3/data logging: *default-logging networks: dc3net: @@ -551,7 +571,7 @@ services: hostname: dc3-driver-melsec volumes: - logs:/dc3-driver/dc3-driver-melsec/dc3/logs - - driver_data:/dc3-driver/dc3-driver-melsec/dc3/data + - driver_melsec_data:/dc3-driver/dc3-driver-melsec/dc3/data logging: *default-logging networks: dc3net: @@ -574,7 +594,7 @@ services: hostname: dc3-driver-mysql volumes: - logs:/dc3-driver/dc3-driver-mysql/dc3/logs - - driver_data:/dc3-driver/dc3-driver-mysql/dc3/data + - driver_mysql_data:/dc3-driver/dc3-driver-mysql/dc3/data logging: *default-logging networks: dc3net: @@ -597,7 +617,7 @@ services: hostname: dc3-driver-oracle volumes: - logs:/dc3-driver/dc3-driver-oracle/dc3/logs - - driver_data:/dc3-driver/dc3-driver-oracle/dc3/data + - driver_oracle_data:/dc3-driver/dc3-driver-oracle/dc3/data logging: *default-logging networks: dc3net: @@ -620,7 +640,7 @@ services: hostname: dc3-driver-postgresql volumes: - logs:/dc3-driver/dc3-driver-postgresql/dc3/logs - - driver_data:/dc3-driver/dc3-driver-postgresql/dc3/data + - driver_postgresql_data:/dc3-driver/dc3-driver-postgresql/dc3/data logging: *default-logging networks: dc3net: @@ -643,7 +663,7 @@ services: hostname: dc3-driver-serial volumes: - logs:/dc3-driver/dc3-driver-serial/dc3/logs - - driver_data:/dc3-driver/dc3-driver-serial/dc3/data + - driver_serial_data:/dc3-driver/dc3-driver-serial/dc3/data logging: *default-logging networks: dc3net: @@ -665,7 +685,7 @@ services: hostname: dc3-driver-dlt645 volumes: - logs:/dc3-driver/dc3-driver-dlt645/dc3/logs - - driver_data:/dc3-driver/dc3-driver-dlt645/dc3/data + - driver_dlt645_data:/dc3-driver/dc3-driver-dlt645/dc3/data logging: *default-logging networks: dc3net: @@ -688,7 +708,7 @@ services: hostname: dc3-driver-dnp3 volumes: - logs:/dc3-driver/dc3-driver-dnp3/dc3/logs - - driver_data:/dc3-driver/dc3-driver-dnp3/dc3/data + - driver_dnp3_data:/dc3-driver/dc3-driver-dnp3/dc3/data logging: *default-logging networks: dc3net: @@ -711,7 +731,7 @@ services: hostname: dc3-driver-iec61850 volumes: - logs:/dc3-driver/dc3-driver-iec61850/dc3/logs - - driver_data:/dc3-driver/dc3-driver-iec61850/dc3/data + - driver_iec61850_data:/dc3-driver/dc3-driver-iec61850/dc3/data logging: *default-logging networks: dc3net: @@ -734,7 +754,7 @@ services: hostname: dc3-driver-kafka volumes: - logs:/dc3-driver/dc3-driver-kafka/dc3/logs - - driver_data:/dc3-driver/dc3-driver-kafka/dc3/data + - driver_kafka_data:/dc3-driver/dc3-driver-kafka/dc3/data logging: *default-logging networks: dc3net: @@ -757,7 +777,7 @@ services: hostname: dc3-driver-knx volumes: - logs:/dc3-driver/dc3-driver-knx/dc3/logs - - driver_data:/dc3-driver/dc3-driver-knx/dc3/data + - driver_knx_data:/dc3-driver/dc3-driver-knx/dc3/data logging: *default-logging networks: dc3net: @@ -780,7 +800,7 @@ services: hostname: dc3-driver-lorawan volumes: - logs:/dc3-driver/dc3-driver-lorawan/dc3/logs - - driver_data:/dc3-driver/dc3-driver-lorawan/dc3/data + - driver_lorawan_data:/dc3-driver/dc3-driver-lorawan/dc3/data logging: *default-logging networks: dc3net: @@ -803,7 +823,7 @@ services: hostname: dc3-driver-mbus volumes: - logs:/dc3-driver/dc3-driver-mbus/dc3/logs - - driver_data:/dc3-driver/dc3-driver-mbus/dc3/data + - driver_mbus_data:/dc3-driver/dc3-driver-mbus/dc3/data logging: *default-logging networks: dc3net: @@ -826,7 +846,7 @@ services: hostname: dc3-driver-redis volumes: - logs:/dc3-driver/dc3-driver-redis/dc3/logs - - driver_data:/dc3-driver/dc3-driver-redis/dc3/data + - driver_redis_data:/dc3-driver/dc3-driver-redis/dc3/data logging: *default-logging networks: dc3net: @@ -848,7 +868,7 @@ services: hostname: dc3-driver-sl651 volumes: - logs:/dc3-driver/dc3-driver-sl651/dc3/logs - - driver_data:/dc3-driver/dc3-driver-sl651/dc3/data + - driver_sl651_data:/dc3-driver/dc3-driver-sl651/dc3/data logging: *default-logging networks: dc3net: @@ -871,7 +891,7 @@ services: hostname: dc3-driver-snmp volumes: - logs:/dc3-driver/dc3-driver-snmp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-snmp/dc3/data + - driver_snmp_data:/dc3-driver/dc3-driver-snmp/dc3/data logging: *default-logging networks: dc3net: @@ -894,7 +914,7 @@ services: hostname: dc3-driver-sqlserver volumes: - logs:/dc3-driver/dc3-driver-sqlserver/dc3/logs - - driver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data + - driver_sqlserver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data logging: *default-logging networks: dc3net: @@ -917,7 +937,7 @@ services: hostname: dc3-driver-tcp-udp volumes: - logs:/dc3-driver/dc3-driver-tcp-udp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data + - driver_tcp_udp_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data logging: *default-logging networks: dc3net: @@ -940,22 +960,13 @@ services: hostname: dc3-driver-zigbee volumes: - logs:/dc3-driver/dc3-driver-zigbee/dc3/logs - - driver_data:/dc3-driver/dc3-driver-zigbee/dc3/data + - driver_zigbee_data:/dc3-driver/dc3-driver-zigbee/dc3/data logging: *default-logging networks: dc3net: aliases: - dc3-driver-zigbee - volumes: - - logs:/dc3-driver/dc3-driver-mqtt/dc3/logs - - driver_data:/dc3-driver/dc3-driver-mqtt/dc3/data - logging: *default-logging - networks: - dc3net: - aliases: - - dc3-driver-mqtt - opc-da: build: context: .. @@ -972,7 +983,7 @@ services: hostname: dc3-driver-opc-da volumes: - logs:/dc3-driver/dc3-driver-opc-da/dc3/logs - - driver_data:/dc3-driver/dc3-driver-opc-da/dc3/data + - driver_opc_da_data:/dc3-driver/dc3-driver-opc-da/dc3/data logging: *default-logging networks: dc3net: @@ -995,7 +1006,7 @@ services: hostname: dc3-driver-opc-ua volumes: - logs:/dc3-driver/dc3-driver-opc-ua/dc3/logs - - driver_data:/dc3-driver/dc3-driver-opc-ua/dc3/data + - driver_opc_ua_data:/dc3-driver/dc3-driver-opc-ua/dc3/data logging: *default-logging networks: dc3net: @@ -1018,7 +1029,7 @@ services: hostname: dc3-driver-plcs7 volumes: - logs:/dc3-driver/dc3-driver-plcs7/dc3/logs - - driver_data:/dc3-driver/dc3-driver-plcs7/dc3/data + - driver_plcs7_data:/dc3-driver/dc3-driver-plcs7/dc3/data logging: *default-logging networks: dc3net: @@ -1041,7 +1052,7 @@ services: hostname: dc3-driver-virtual volumes: - logs:/dc3-driver/dc3-driver-virtual/dc3/logs - - driver_data:/dc3-driver/dc3-driver-virtual/dc3/data + - driver_virtual_data:/dc3-driver/dc3-driver-virtual/dc3/data logging: *default-logging networks: dc3net: @@ -1050,7 +1061,42 @@ services: volumes: logs: - driver_data: + driver_listening_virtual_data: + driver_modbus_tcp_data: + driver_modbus_rtu_data: + driver_mqtt_data: + driver_bacnet_ip_data: + driver_ble_data: + driver_can_data: + driver_coap_data: + driver_dlms_data: + driver_ethernet_ip_data: + driver_fins_data: + driver_http_data: + driver_iec104_data: + driver_lwm2m_data: + driver_melsec_data: + driver_mysql_data: + driver_oracle_data: + driver_postgresql_data: + driver_serial_data: + driver_dlt645_data: + driver_dnp3_data: + driver_iec61850_data: + driver_kafka_data: + driver_knx_data: + driver_lorawan_data: + driver_mbus_data: + driver_redis_data: + driver_sl651_data: + driver_snmp_data: + driver_sqlserver_data: + driver_tcp_udp_data: + driver_zigbee_data: + driver_opc_da_data: + driver_opc_ua_data: + driver_plcs7_data: + driver_virtual_data: networks: dc3net: driver: bridge diff --git a/dc3/docker-compose.yml b/dc3/docker-compose.yml index 7f6ecb72a..da15b5a31 100644 --- a/dc3/docker-compose.yml +++ b/dc3/docker-compose.yml @@ -44,6 +44,18 @@ x-app-runtime-env: &app-runtime-env MQTT_BROKER_PORT: "1883" MQTT_USERNAME: ${MQTT_USERNAME:-dc3} MQTT_PASSWORD: ${MQTT_PASSWORD:-dc3dc3dc3} + POINT_BATCH_SIZE: "${POINT_BATCH_SIZE:-500}" + POINT_BATCH_RECEIVE_TIMEOUT_MILLIS: "${POINT_BATCH_RECEIVE_TIMEOUT_MILLIS:-100}" + POINT_CONCURRENT_CONSUMERS: "${POINT_CONCURRENT_CONSUMERS:-4}" + POINT_MAX_CONCURRENT_CONSUMERS: "${POINT_MAX_CONCURRENT_CONSUMERS:-16}" + POINT_PREFETCH_COUNT: "${POINT_PREFETCH_COUNT:-1000}" + POINT_RETRY_MAX_RETRIES: "${POINT_RETRY_MAX_RETRIES:-3}" + POINT_RETRY_INITIAL_INTERVAL_MILLIS: "${POINT_RETRY_INITIAL_INTERVAL_MILLIS:-1000}" + POINT_RETRY_MULTIPLIER: "${POINT_RETRY_MULTIPLIER:-2}" + POINT_RETRY_MAX_INTERVAL_MILLIS: "${POINT_RETRY_MAX_INTERVAL_MILLIS:-10000}" + DC3_DRIVER_LEASE_SECONDS: "${DC3_DRIVER_LEASE_SECONDS:-30}" + DC3_DRIVER_LEASE_RENEW_CRON: "${DC3_DRIVER_LEASE_RENEW_CRON:-0/10 * * * * ?}" + DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS: "${DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS:-300000}" services: web: @@ -237,7 +249,7 @@ services: hostname: dc3-driver-listening-virtual volumes: - logs:/dc3-driver/dc3-driver-listening-virtual/dc3/logs - - driver_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data + - driver_listening_virtual_data:/dc3-driver/dc3-driver-listening-virtual/dc3/data logging: *default-logging networks: dc3net: @@ -256,7 +268,7 @@ services: hostname: dc3-driver-modbus-tcp volumes: - logs:/dc3-driver/dc3-driver-modbus-tcp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data + - driver_modbus_tcp_data:/dc3-driver/dc3-driver-modbus-tcp/dc3/data logging: *default-logging networks: dc3net: @@ -275,7 +287,7 @@ services: hostname: dc3-driver-modbus-rtu volumes: - logs:/dc3-driver/dc3-driver-modbus-rtu/dc3/logs - - driver_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data + - driver_modbus_rtu_data:/dc3-driver/dc3-driver-modbus-rtu/dc3/data logging: *default-logging networks: dc3net: @@ -294,7 +306,7 @@ services: hostname: dc3-driver-mqtt volumes: - logs:/dc3-driver/dc3-driver-mqtt/dc3/logs - - driver_data:/dc3-driver/dc3-driver-mqtt/dc3/data + - driver_mqtt_data:/dc3-driver/dc3-driver-mqtt/dc3/data logging: *default-logging networks: dc3net: @@ -313,7 +325,7 @@ services: hostname: dc3-driver-opc-da volumes: - logs:/dc3-driver/dc3-driver-opc-da/dc3/logs - - driver_data:/dc3-driver/dc3-driver-opc-da/dc3/data + - driver_opc_da_data:/dc3-driver/dc3-driver-opc-da/dc3/data logging: *default-logging networks: dc3net: @@ -332,7 +344,7 @@ services: hostname: dc3-driver-opc-ua volumes: - logs:/dc3-driver/dc3-driver-opc-ua/dc3/logs - - driver_data:/dc3-driver/dc3-driver-opc-ua/dc3/data + - driver_opc_ua_data:/dc3-driver/dc3-driver-opc-ua/dc3/data logging: *default-logging networks: dc3net: @@ -351,7 +363,7 @@ services: hostname: dc3-driver-plcs7 volumes: - logs:/dc3-driver/dc3-driver-plcs7/dc3/logs - - driver_data:/dc3-driver/dc3-driver-plcs7/dc3/data + - driver_plcs7_data:/dc3-driver/dc3-driver-plcs7/dc3/data logging: *default-logging networks: dc3net: @@ -370,7 +382,7 @@ services: hostname: dc3-driver-virtual volumes: - logs:/dc3-driver/dc3-driver-virtual/dc3/logs - - driver_data:/dc3-driver/dc3-driver-virtual/dc3/data + - driver_virtual_data:/dc3-driver/dc3-driver-virtual/dc3/data logging: *default-logging networks: dc3net: @@ -389,7 +401,7 @@ services: hostname: dc3-driver-bacnet-ip volumes: - logs:/dc3-driver/dc3-driver-bacnet-ip/dc3/logs - - driver_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data + - driver_bacnet_ip_data:/dc3-driver/dc3-driver-bacnet-ip/dc3/data logging: *default-logging networks: dc3net: @@ -408,7 +420,7 @@ services: hostname: dc3-driver-fins volumes: - logs:/dc3-driver/dc3-driver-fins/dc3/logs - - driver_data:/dc3-driver/dc3-driver-fins/dc3/data + - driver_fins_data:/dc3-driver/dc3-driver-fins/dc3/data logging: *default-logging networks: dc3net: @@ -427,7 +439,7 @@ services: hostname: dc3-driver-melsec volumes: - logs:/dc3-driver/dc3-driver-melsec/dc3/logs - - driver_data:/dc3-driver/dc3-driver-melsec/dc3/data + - driver_melsec_data:/dc3-driver/dc3-driver-melsec/dc3/data logging: *default-logging networks: dc3net: @@ -446,7 +458,7 @@ services: hostname: dc3-driver-ethernet-ip volumes: - logs:/dc3-driver/dc3-driver-ethernet-ip/dc3/logs - - driver_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data + - driver_ethernet_ip_data:/dc3-driver/dc3-driver-ethernet-ip/dc3/data logging: *default-logging networks: dc3net: @@ -465,7 +477,7 @@ services: hostname: dc3-driver-iec104 volumes: - logs:/dc3-driver/dc3-driver-iec104/dc3/logs - - driver_data:/dc3-driver/dc3-driver-iec104/dc3/data + - driver_iec104_data:/dc3-driver/dc3-driver-iec104/dc3/data logging: *default-logging networks: dc3net: @@ -484,7 +496,7 @@ services: hostname: dc3-driver-sl651 volumes: - logs:/dc3-driver/dc3-driver-sl651/dc3/logs - - driver_data:/dc3-driver/dc3-driver-sl651/dc3/data + - driver_sl651_data:/dc3-driver/dc3-driver-sl651/dc3/data logging: *default-logging networks: dc3net: @@ -503,7 +515,7 @@ services: hostname: dc3-driver-snmp volumes: - logs:/dc3-driver/dc3-driver-snmp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-snmp/dc3/data + - driver_snmp_data:/dc3-driver/dc3-driver-snmp/dc3/data logging: *default-logging networks: dc3net: @@ -522,7 +534,7 @@ services: hostname: dc3-driver-dlms volumes: - logs:/dc3-driver/dc3-driver-dlms/dc3/logs - - driver_data:/dc3-driver/dc3-driver-dlms/dc3/data + - driver_dlms_data:/dc3-driver/dc3-driver-dlms/dc3/data logging: *default-logging networks: dc3net: @@ -541,7 +553,7 @@ services: hostname: dc3-driver-coap volumes: - logs:/dc3-driver/dc3-driver-coap/dc3/logs - - driver_data:/dc3-driver/dc3-driver-coap/dc3/data + - driver_coap_data:/dc3-driver/dc3-driver-coap/dc3/data logging: *default-logging networks: dc3net: @@ -560,7 +572,7 @@ services: hostname: dc3-driver-lwm2m volumes: - logs:/dc3-driver/dc3-driver-lwm2m/dc3/logs - - driver_data:/dc3-driver/dc3-driver-lwm2m/dc3/data + - driver_lwm2m_data:/dc3-driver/dc3-driver-lwm2m/dc3/data logging: *default-logging networks: dc3net: @@ -579,7 +591,7 @@ services: hostname: dc3-driver-http volumes: - logs:/dc3-driver/dc3-driver-http/dc3/logs - - driver_data:/dc3-driver/dc3-driver-http/dc3/data + - driver_http_data:/dc3-driver/dc3-driver-http/dc3/data logging: *default-logging networks: dc3net: @@ -598,7 +610,7 @@ services: hostname: dc3-driver-serial volumes: - logs:/dc3-driver/dc3-driver-serial/dc3/logs - - driver_data:/dc3-driver/dc3-driver-serial/dc3/data + - driver_serial_data:/dc3-driver/dc3-driver-serial/dc3/data logging: *default-logging networks: dc3net: @@ -617,7 +629,7 @@ services: hostname: dc3-driver-tcp-udp volumes: - logs:/dc3-driver/dc3-driver-tcp-udp/dc3/logs - - driver_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data + - driver_tcp_udp_data:/dc3-driver/dc3-driver-tcp-udp/dc3/data logging: *default-logging networks: dc3net: @@ -636,7 +648,7 @@ services: hostname: dc3-driver-mysql volumes: - logs:/dc3-driver/dc3-driver-mysql/dc3/logs - - driver_data:/dc3-driver/dc3-driver-mysql/dc3/data + - driver_mysql_data:/dc3-driver/dc3-driver-mysql/dc3/data logging: *default-logging networks: dc3net: @@ -655,7 +667,7 @@ services: hostname: dc3-driver-postgresql volumes: - logs:/dc3-driver/dc3-driver-postgresql/dc3/logs - - driver_data:/dc3-driver/dc3-driver-postgresql/dc3/data + - driver_postgresql_data:/dc3-driver/dc3-driver-postgresql/dc3/data logging: *default-logging networks: dc3net: @@ -674,7 +686,7 @@ services: hostname: dc3-driver-oracle volumes: - logs:/dc3-driver/dc3-driver-oracle/dc3/logs - - driver_data:/dc3-driver/dc3-driver-oracle/dc3/data + - driver_oracle_data:/dc3-driver/dc3-driver-oracle/dc3/data logging: *default-logging networks: dc3net: @@ -693,7 +705,7 @@ services: hostname: dc3-driver-sqlserver volumes: - logs:/dc3-driver/dc3-driver-sqlserver/dc3/logs - - driver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data + - driver_sqlserver_data:/dc3-driver/dc3-driver-sqlserver/dc3/data logging: *default-logging networks: dc3net: @@ -702,7 +714,31 @@ services: volumes: logs: - driver_data: + driver_listening_virtual_data: + driver_modbus_tcp_data: + driver_modbus_rtu_data: + driver_mqtt_data: + driver_opc_da_data: + driver_opc_ua_data: + driver_plcs7_data: + driver_virtual_data: + driver_bacnet_ip_data: + driver_fins_data: + driver_melsec_data: + driver_ethernet_ip_data: + driver_iec104_data: + driver_sl651_data: + driver_snmp_data: + driver_dlms_data: + driver_coap_data: + driver_lwm2m_data: + driver_http_data: + driver_serial_data: + driver_tcp_udp_data: + driver_mysql_data: + driver_postgresql_data: + driver_oracle_data: + driver_sqlserver_data: nginx: networks: diff --git a/dc3/env/dev.env b/dc3/env/dev.env index 5de599ef4..06ace58b0 100644 --- a/dc3/env/dev.env +++ b/dc3/env/dev.env @@ -38,8 +38,18 @@ DC3_FACADE_MODE=grpc DC3_FACADE_GRPC_DEADLINE_MS=3000 DC3_SECURITY_KEY=dc3.security.key.2026.io.github.pnoker AUTH_HMAC_SECRET=io.github.pnoker.dc3 -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 +DC3_DRIVER_LEASE_SECONDS=30 +DC3_DRIVER_LEASE_RENEW_CRON=0/10 * * * * ? +DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS=300000 MQTT_BATCH_SPEED=100 MQTT_BATCH_INTERVAL=5 diff --git a/dc3/env/dev.env.sh b/dc3/env/dev.env.sh index d60d3fcb7..26f02cfeb 100644 --- a/dc3/env/dev.env.sh +++ b/dc3/env/dev.env.sh @@ -55,8 +55,18 @@ export DC3_FACADE_MODE=grpc export DC3_FACADE_GRPC_DEADLINE_MS=3000 export DC3_SECURITY_KEY=dc3.security.key.2026.io.github.pnoker export AUTH_HMAC_SECRET=io.github.pnoker.dc3 -export POINT_BATCH_SPEED=100 -export POINT_BATCH_INTERVAL=5 +export POINT_BATCH_SIZE=500 +export POINT_BATCH_RECEIVE_TIMEOUT_MILLIS=100 +export POINT_CONCURRENT_CONSUMERS=4 +export POINT_MAX_CONCURRENT_CONSUMERS=16 +export POINT_PREFETCH_COUNT=1000 +export POINT_RETRY_MAX_RETRIES=3 +export POINT_RETRY_INITIAL_INTERVAL_MILLIS=1000 +export POINT_RETRY_MULTIPLIER=2 +export POINT_RETRY_MAX_INTERVAL_MILLIS=10000 +export DC3_DRIVER_LEASE_SECONDS=30 +export DC3_DRIVER_LEASE_RENEW_CRON='0/10 * * * * ?' +export DC3_DRIVER_LEASE_QUEUE_EXPIRES_MILLIS=300000 export MQTT_BATCH_SPEED=100 export MQTT_BATCH_INTERVAL=5