Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openframe-api-lib/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<groupId>com.openframe.oss</groupId>
<artifactId>openframe-data-mongo-sync</artifactId>
</dependency>
<dependency>
<groupId>com.openframe.oss</groupId>
<artifactId>openframe-data-redis</artifactId>
</dependency>

<!-- OpenFrame Dependencies -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class LogDetails {
private String userId;
private String deviceId;
private String hostname;
private String nickname;
private String organizationId;
private String organizationName;
private String summary;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class LogEvent {
private String userId;
private String deviceId;
private String hostname;
private String nickname;
private String organizationId;
private String organizationName;
private String summary;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.openframe.api.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

@Getter
public class DeviceNicknameUpdatedEvent extends ApplicationEvent {

private final String machineId;

public DeviceNicknameUpdatedEvent(Object source, String machineId) {
super(source);
this.machineId = machineId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ private LogEvent mapToLogEvent(LogProjection log) {
.userId(log.userId)
.deviceId(log.deviceId)
.hostname(log.hostname)
.nickname(log.nickname)
.organizationId(log.organizationId)
.organizationName(log.organizationName)
.build();
Expand All @@ -222,6 +223,7 @@ private LogDetails mapToLogDetails(UnifiedLogEvent logEvent) {
.userId(logEvent.getUserId())
.deviceId(logEvent.getDeviceId())
.hostname(logEvent.getHostname())
.nickname(logEvent.getNickname())
.organizationId(logEvent.getOrganizationId())
.organizationName(logEvent.getOrganizationName())
.summary(logEvent.getMessage())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.openframe.api.dto.shared.PageInfo;
import com.openframe.api.dto.shared.SortDirection;
import com.openframe.api.dto.shared.SortInput;
import com.openframe.api.event.DeviceNicknameUpdatedEvent;
import com.openframe.api.exception.DeviceNotFoundException;
import com.openframe.api.mapper.DeviceFilterOptionMapper;
import com.openframe.api.service.processor.DeviceStatusProcessor;
Expand All @@ -32,6 +33,7 @@
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

Expand Down Expand Up @@ -64,6 +66,7 @@ public class DeviceService {
private final ScheduleScriptDeviceService scheduleScriptDeviceService;
private final DeviceFilterOptionMapper deviceFilterOptionMapper;
private final TenantIdProvider tenantIdProvider;
private final ApplicationEventPublisher eventPublisher;

public Optional<Machine> findByMachineId(@NotBlank String machineId) {
log.debug("Finding machine by ID: {}", machineId);
Expand Down Expand Up @@ -368,6 +371,7 @@ public Machine updateNickname(@NotBlank String machineId, String nickname) {
MachineWriteResult result = machineWriter
.update(machineId, machineUpdate().set(NICKNAME, normalizeNickname(nickname)))
.orElseThrow(() -> new DeviceNotFoundException("Device not found: " + machineId));
eventPublisher.publishEvent(new DeviceNicknameUpdatedEvent(this, machineId));
log.info("Device {} nickname updated", machineId);
return result.after();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.openframe.api.service.device;

import com.openframe.api.event.DeviceNicknameUpdatedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import static com.openframe.data.repository.redis.MachineIdCacheService.INVALIDATION_CHANNEL;

@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(name = "spring.redis.enabled", havingValue = "true")
public class MachineCacheInvalidationPublisher {

private final StringRedisTemplate redisTemplate;

@EventListener
public void onNicknameUpdated(DeviceNicknameUpdatedEvent event) {
String machineId = event.getMachineId();
try {
redisTemplate.convertAndSend(INVALIDATION_CHANNEL, machineId);
log.info("Published machine cache invalidation: machineId={}", machineId);
} catch (Exception e) {
log.warn("Failed to publish machine cache invalidation, cached info expires by TTL: machineId={}",
machineId, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.openframe.api.service.device;

import com.openframe.api.event.DeviceNicknameUpdatedEvent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.StringRedisTemplate;

import static com.openframe.data.repository.redis.MachineIdCacheService.INVALIDATION_CHANNEL;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class MachineCacheInvalidationPublisherTest {

private static final String MACHINE_ID = "6d925893-702a-4223-b62f-2f80b927cbaa";

@Mock
private StringRedisTemplate redisTemplate;

@InjectMocks
private MachineCacheInvalidationPublisher publisher;

@Test
@DisplayName("onNicknameUpdated: forwards the machineId on the global invalidation channel the stream services subscribe to")
void onNicknameUpdated_publishesMachineIdOnGlobalChannel() {
publisher.onNicknameUpdated(new DeviceNicknameUpdatedEvent(this, MACHINE_ID));

verify(redisTemplate).convertAndSend(INVALIDATION_CHANNEL, MACHINE_ID);
}

@Test
@DisplayName("onNicknameUpdated: a Redis failure never propagates — the rename is already persisted and the cache TTL covers it")
void onNicknameUpdated_redisFailureDoesNotPropagate() {
doThrow(new RedisConnectionFailureException("redis down"))
.when(redisTemplate).convertAndSend(any(), any());

assertThatCode(() -> publisher.onNicknameUpdated(new DeviceNicknameUpdatedEvent(this, MACHINE_ID)))
.doesNotThrowAnyException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ type LogEvent implements Node {
userId: String
deviceId: String
hostname: String
nickname: String
organizationId: String
organizationName: String
summary: String
Expand All @@ -95,6 +96,7 @@ type LogDetails implements Node {
userId: String
deviceId: String
hostname: String
nickname: String
organizationId: String
organizationName: String
message: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.openframe.api.dto.device.DeviceFilterCriteria;
import com.openframe.api.dto.shared.CursorPaginationCriteria;
import com.openframe.api.event.DeviceNicknameUpdatedEvent;
import com.openframe.api.exception.DeviceNotFoundException;
import com.openframe.api.mapper.DeviceFilterOptionMapper;
import com.openframe.api.service.device.DeviceService;
Expand All @@ -25,6 +26,7 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;

import java.time.Instant;
import java.util.List;
Expand Down Expand Up @@ -59,11 +61,14 @@ class DeviceServiceTest {
@Mock private DeviceFilterOptionMapper deviceFilterOptionMapper;
@Mock
private TenantIdProvider tenantIdProvider;
@Mock
private ApplicationEventPublisher eventPublisher;

private DeviceService service() {
DeviceService s = new DeviceService(machineRepository, deviceOnlineDispatchRepository, machineWriter,
tagRepository, tagAssignmentRepository,
deviceStatusProcessor, scheduleScriptDeviceService, deviceFilterOptionMapper, tenantIdProvider);
deviceStatusProcessor, scheduleScriptDeviceService, deviceFilterOptionMapper, tenantIdProvider,
eventPublisher);
lenient().when(tenantIdProvider.getTenantId()).thenReturn(TENANT_ID);
lenient().when(machineRepository.countMachines(any(), any(MachineQueryFilter.class), any())).thenReturn(0L);
lenient().when(machineRepository.findMachinesWithCursor(any(), any(MachineQueryFilter.class), any(),
Expand Down Expand Up @@ -369,6 +374,31 @@ void updateNickname_doesNotSaveWholeDocument() {
verify(machineWriter).update(eq("m1"), any(MachineUpdate.class));
}

@Test
@DisplayName("updateNickname: publishes the invalidation event for the renamed machine once the write succeeded")
void updateNickname_publishesInvalidationEventAfterWrite() {
DeviceService s = service();
stubAtomicNicknameUpdate("m1");

s.updateNickname("m1", "Reception iMac");

ArgumentCaptor<DeviceNicknameUpdatedEvent> captor = ArgumentCaptor.forClass(DeviceNicknameUpdatedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertThat(captor.getValue().getMachineId()).isEqualTo("m1");
}

@Test
@DisplayName("updateNickname: an unknown device fails before anything is published — nothing to invalidate")
void updateNickname_unknownDevice_doesNotPublish() {
DeviceService s = service();
when(machineWriter.update(eq("m1"), any(MachineUpdate.class))).thenReturn(Optional.empty());

assertThatThrownBy(() -> s.updateNickname("m1", "Reception iMac"))
.isInstanceOf(DeviceNotFoundException.class);

verifyNoInteractions(eventPublisher);
}

@Test
@DisplayName("updateNickname: a blank value clears the nickname (stored as null)")
void updateNickname_blankClears() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public class UnifiedLogEvent {
@Column("hostname")
private String hostname;

@Column("nickname")
private String nickname;

/**
* Organization ID associated with the event.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class IntegratedToolEvent implements KafkaMessage {
private String userId;
private String deviceId;
private String hostname;
private String nickname;
private String organizationId;
private String organizationName;
private String ingestDay;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class CachedMachineInfo implements Serializable {

private String machineId;
private String hostname;
private String nickname;
private String organizationId;
}

Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.List;

/**
* Service for machine and organization cache operations using Spring Cache abstraction
* Uses lightweight DTOs to avoid serialization issues and reduce cache size
Expand All @@ -24,18 +28,26 @@
@ConditionalOnProperty(name = "openframe.machine-id.cache.enabled", havingValue = "true")
public class MachineIdCacheService {

public static final String INVALIDATION_CHANNEL = "machine:cache:invalidate";

private static final String MACHINE_CACHE = "machineCache";
private static final String TENANT_MACHINE_CACHE = "tenantMachineCache";
private static final String MACHINE_BY_ID_CACHE = "machineByIdCache";
private static final String KEY_SEPARATOR = ":";

private final ToolConnectionRepository toolConnectionRepository;
private final MachineRepository machineRepository;
private final OrganizationRepository organizationRepository;
private final CacheManager cacheManager;

/**
* Get cached machine info from cache or database by agent ID
* Returns only essential fields (machineId, hostname, organizationId)
` * Returns only essential fields (machineId, hostname, nickname, organizationId)
*
* @param agentId the agent ID
* @return the CachedMachineInfo object, or null if not found
*/
@Cacheable(value = "machineCache", key = "#agentId", unless = "#result == null")
@Cacheable(value = MACHINE_CACHE, key = "#agentId", unless = "#result == null")
public CachedMachineInfo getMachine(String agentId) {
log.debug("Fetching machine info for agent: {}", agentId);
try {
Expand All @@ -46,6 +58,7 @@ public CachedMachineInfo getMachine(String agentId) {
.map(machine -> new CachedMachineInfo(
machine.getMachineId(),
machine.getHostname(),
machine.getNickname(),
machine.getOrganizationId()
))
.orElse(null);
Expand All @@ -55,7 +68,7 @@ public CachedMachineInfo getMachine(String agentId) {
}
}

@Cacheable(value = "tenantMachineCache", key = "#tenantId + ':' + #toolType + ':' + #agentId", unless = "#result == null")
@Cacheable(value = TENANT_MACHINE_CACHE, key = "#tenantId + ':' + #toolType + ':' + #agentId", unless = "#result == null")
public CachedMachineInfo getMachine(String tenantId, ToolType toolType, String agentId) {
log.debug("Fetching machine info for agent: {} (tenant: {}, tool: {})", agentId, tenantId, toolType);
try {
Expand All @@ -66,6 +79,7 @@ public CachedMachineInfo getMachine(String tenantId, ToolType toolType, String a
.map(machine -> new CachedMachineInfo(
machine.getMachineId(),
machine.getHostname(),
machine.getNickname(),
machine.getOrganizationId()
))
.orElse(null);
Expand All @@ -82,14 +96,15 @@ public CachedMachineInfo getMachine(String tenantId, ToolType toolType, String a
* @param machineId openframe-native machineId
* @return the {@link CachedMachineInfo}, or {@code null} if the machine is not found in the local store
*/
@Cacheable(value = "machineByIdCache", key = "#machineId", unless = "#result == null")
@Cacheable(value = MACHINE_BY_ID_CACHE, key = "#machineId", unless = "#result == null")
public CachedMachineInfo getMachineByMachineId(String machineId) {
log.debug("Fetching machine info by machineId: {}", machineId);
try {
return machineRepository.findByMachineId(machineId)
.map(machine -> new CachedMachineInfo(
machine.getMachineId(),
machine.getHostname(),
machine.getNickname(),
machine.getOrganizationId()
))
.orElse(null);
Expand Down Expand Up @@ -121,5 +136,32 @@ public CachedOrganizationInfo getOrganization(String organizationId) {
return null;
}
}

public void evictMachine(String machineId) {
evict(MACHINE_BY_ID_CACHE, machineId);
List<ToolConnection> connections = toolConnectionRepository.findByMachineId(machineId);
connections.forEach(this::evictConnectionEntries);
log.info("Evicted cached machine info: machineId={} toolConnections={}", machineId, connections.size());
}

private void evictConnectionEntries(ToolConnection connection) {
String agentToolId = connection.getAgentToolId();
String tenantMachineKey = tenantMachineKey(connection);
evict(MACHINE_CACHE, agentToolId);
evict(TENANT_MACHINE_CACHE, tenantMachineKey);
}

private String tenantMachineKey(ToolConnection connection) {
return connection.getTenantId() + KEY_SEPARATOR + connection.getToolType()
+ KEY_SEPARATOR + connection.getAgentToolId();
}

private void evict(String cacheName, String key) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
return;
}
cache.evict(key);
}
}

Loading
Loading