-
Notifications
You must be signed in to change notification settings - Fork 3
feat(devices): add and remove tags on a device at runtime [CU-86ak6uwjn] #2134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d9f147a
8f49849
0fd5bef
2043c02
b64a829
51ef62f
ada5ab6
b0a96b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| package com.openframe.api.service.device; | ||
|
|
||
| import com.openframe.api.exception.DeviceNotFoundException; | ||
| import com.openframe.core.exception.ConflictException; | ||
| import com.openframe.data.document.tag.Tag; | ||
| import com.openframe.data.document.tag.TagAssignment; | ||
| import com.openframe.data.document.tag.TagValidation; | ||
| import com.openframe.data.repository.device.MachineRepository; | ||
| import com.openframe.data.repository.tag.TagAssignmentRepository; | ||
| import com.openframe.data.repository.tag.TagRepository; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.dao.DuplicateKeyException; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.validation.annotation.Validated; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.ArrayList; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import static com.openframe.data.document.tag.TagEntityType.DEVICE; | ||
|
|
||
| /** | ||
| * Attaches and detaches DEVICE tags at runtime — the counterpart to | ||
| * {@code RegistrationTagAssignmentService}, which does the same thing from the agent-registration | ||
| * payload. Both write only Mongo ({@code tags} + {@code tag_assignments}); the Pinot facet columns | ||
| * are refreshed by {@code MachineTagEventAspect}, which intercepts the repository calls made here | ||
| * and republishes the machine's full tag list to Kafka. | ||
| * | ||
| * <p>Every write therefore has to go through an intercepted repository method — {@code save} or | ||
| * {@code deleteByEntityIdAndTagIdAndEntityType}. Bypassing them (e.g. {@code deleteAll}) would | ||
| * leave the device's Pinot row carrying tags it no longer has. | ||
| * | ||
| * <p>Deliberately not {@code @Transactional}: no Mongo transaction manager is configured, so the | ||
| * annotation would promise atomicity it cannot give. The tag key is written before the assignment, | ||
| * so a failure in between leaves at worst an unused key. | ||
| * | ||
| * <p>Not reused from the client-core service because {@code openframe-client-core} does not depend | ||
| * on {@code openframe-api-lib}, and pulling the logic down into a shared module would drag the | ||
| * machine/tag repositories along with it. | ||
| */ | ||
| @Service | ||
| @Slf4j | ||
| @Validated | ||
| @RequiredArgsConstructor | ||
| public class DeviceTagService { | ||
|
|
||
| private final TagRepository tagRepository; | ||
| private final TagAssignmentRepository tagAssignmentRepository; | ||
| private final MachineRepository machineRepository; | ||
|
|
||
| /** | ||
| * Tags a device with {@code key}, creating the tag key on first use. | ||
| * | ||
| * <p>Additive in both directions: new {@code values} are merged into the device's existing | ||
| * assignment rather than replacing it, and into the tag's list of predefined options. Calling | ||
| * it twice with the same key and values is a no-op beyond the Pinot republish. | ||
| * | ||
| * @return the tag, carrying this device's values (not the tag's full option list) | ||
| */ | ||
| public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List<String> values) { | ||
| return writeTag(machineId, key, values, false); | ||
| } | ||
|
|
||
| /** | ||
| * Sets the device's values for {@code key} to exactly {@code values}, creating the tag key and | ||
| * the assignment on first use. This is how a single value is taken off a device: send the list | ||
| * without it. {@code null} or empty leaves the device carrying the key as a plain label; use | ||
| * {@link #removeTag} to detach the key altogether. | ||
| * | ||
| * <p>Only the device's assignment is replaced. The tag's predefined options stay additive — a | ||
| * value dropped here may still be in use on other devices. | ||
| * | ||
| * @return the tag, carrying this device's values (not the tag's full option list) | ||
| */ | ||
| public Tag setTagValues(@NotBlank String machineId, @NotBlank String key, List<String> values) { | ||
| return writeTag(machineId, key, values, true); | ||
| } | ||
|
|
||
| private Tag writeTag(String machineId, String key, List<String> values, boolean replace) { | ||
| TagValidation.validateKey(key); | ||
| TagValidation.validateValues(values, key); | ||
| requireMachine(machineId); | ||
|
|
||
| Tag tag = findOrCreateTag(key, values); | ||
| List<String> assignedValues = upsertAssignment(machineId, tag.getId(), values, replace); | ||
|
|
||
| log.info("{} tag '{}' on machine {}, values now {}", replace ? "Set" : "Assigned", key, machineId, assignedValues); | ||
| return Tag.builder() | ||
| .id(tag.getId()) | ||
| .key(tag.getKey()) | ||
| .description(tag.getDescription()) | ||
| .color(tag.getColor()) | ||
| .values(assignedValues) | ||
| .entityType(tag.getEntityType()) | ||
| .createdAt(tag.getCreatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * Detaches a tag from a device. The tag key itself survives — it stays available for other | ||
| * devices and in the filter dropdowns; use {@code TagService.deleteTag} to drop the key | ||
| * everywhere. | ||
| * | ||
| * @return {@code true} if the device had the tag, {@code false} if there was nothing to remove | ||
| */ | ||
| public boolean removeTag(@NotBlank String machineId, @NotBlank String tagId) { | ||
| requireMachine(machineId); | ||
|
|
||
| boolean assigned = tagAssignmentRepository | ||
| .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE) | ||
| .isPresent(); | ||
| if (!assigned) { | ||
| log.info("Tag {} is not assigned to machine {}, nothing to remove", tagId, machineId); | ||
| return false; | ||
| } | ||
|
|
||
| // Aspect-intercepted: publishes the machine's remaining tags before the delete proceeds. | ||
| tagAssignmentRepository.deleteByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); | ||
| log.info("Removed tag {} from machine {}", tagId, machineId); | ||
| return true; | ||
| } | ||
|
|
||
| private void requireMachine(String machineId) { | ||
| if (machineRepository.findByMachineId(machineId).isEmpty()) { | ||
| throw new DeviceNotFoundException("Device not found: " + machineId); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Finds the DEVICE tag for {@code key}, or creates it. On an existing tag any previously unseen | ||
| * values are appended to its predefined options, so a value typed on one device becomes a | ||
| * suggestion for the next. | ||
| * | ||
| * <p>Keys are unique ignoring case, same as {@code TagService.createTag}: the Mongo index is | ||
| * case-sensitive, so without the check "Site" would quietly become a second key next to "site". | ||
| */ | ||
| private Tag findOrCreateTag(String key, List<String> values) { | ||
| Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE); | ||
| if (existing == null) { | ||
| if (tagRepository.existsByKeyIgnoreCaseAndEntityType(key, DEVICE)) { | ||
| throw new ConflictException("Tag with key '" + key + "' already exists"); | ||
| } | ||
| try { | ||
| Tag created = tagRepository.save(Tag.builder() | ||
| .key(key) | ||
| .values(merge(null, values)) | ||
| .entityType(DEVICE) | ||
| .createdAt(Instant.now()) | ||
| .build()); | ||
| log.info("Created DEVICE tag '{}' (id={})", key, created.getId()); | ||
| return created; | ||
| } catch (DuplicateKeyException e) { | ||
| // Lost a first-use race to another request: the unique (tenant, key, entityType) | ||
| // index kept the key single, so carry on with the winner's tag. | ||
| existing = tagRepository.findByKeyAndEntityType(key, DEVICE); | ||
| if (existing == null) { | ||
| throw e; | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+141
to
+164
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🦩 🟠 [warn/recommended] findOrCreateTag has a TOCTOU race between the existsByKeyIgnoreCase check and save that can allow duplicate keys differing only by case In findOrCreateTag, the code checks existsByKeyIgnoreCaseAndEntityType(key, DEVICE) and, if false, proceeds to save. Between the check and the save, another request could create a tag with a different case of the same key. The DuplicateKeyException catch block only guards against an exact-key race (the same case), because the underlying unique index is stated to be case-sensitive ('Keys are unique ignoring case ... the Mongo index is case-sensitive, so without the check "Site" would quietly become a second key next to "site"'). This means the case-insensitive uniqueness guarantee promised in the class doc is not actually race-safe: two concurrent requests with 'Site' and 'site' could both pass the exists check and both succeed at save (since the underlying index is case-sensitive), producing two case-variant keys — the exact failure mode the check claims to prevent. Evidence🤖 Prompt for AI agentsconfidence: 35 — react 👍/👎 to teach the reviewer
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and knowingly left: the ignore-case check is best-effort, exactly like |
||
|
|
||
| List<String> merged = merge(existing.getValues(), values); | ||
| if (merged.size() != size(existing.getValues())) { | ||
| existing.setValues(merged); | ||
| // Aspect-intercepted: refreshes every device already carrying this tag. | ||
| existing = tagRepository.save(existing); | ||
| log.info("Appended values {} to existing tag '{}'", values, key); | ||
| } | ||
| return existing; | ||
| } | ||
|
Comment on lines
+141
to
+174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🦩 🟠 [warn/recommended] assignDeviceTag has no upper bound on tag values / no duplicate protection against unbounded tag growth per device assignTag merges new values into both the tag's global option list and the device's own assignment with no cap on collection size. Since values are user/agent supplied via a public GraphQL mutation, repeated calls with novel values will grow both Evidence🤖 Prompt for AI agentsconfidence: 30 — react 👍/👎 to teach the reviewer
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not in this PR. Duplicates are already handled — values go through an insertion-ordered set, an unchanged list isn't saved at all, and each value is length/charset-validated by |
||
|
|
||
| /** | ||
| * Writes {@code values} to the device's assignment, creating it if the device does not carry the | ||
| * tag yet: merged into the current values, or in place of them when {@code replace} is set. | ||
| * Saving through the repository is what triggers the Pinot republish, so an unchanged list is | ||
| * not saved at all. | ||
| */ | ||
| private List<String> upsertAssignment(String machineId, String tagId, List<String> values, boolean replace) { | ||
| Optional<TagAssignment> existing = tagAssignmentRepository | ||
| .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); | ||
|
|
||
| if (existing.isEmpty()) { | ||
| try { | ||
| TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder() | ||
| .entityId(machineId) | ||
| .tagId(tagId) | ||
| .entityType(DEVICE) | ||
| .values(merge(null, values)) | ||
| .taggedAt(Instant.now()) | ||
| .build()); | ||
| return saved.getValues(); | ||
| } catch (DuplicateKeyException e) { | ||
| // Same race on the unique (tenant, entity, tag, entityType) index: apply the values | ||
| // to the assignment the other request just created. | ||
| existing = tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); | ||
| if (existing.isEmpty()) { | ||
| throw e; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| TagAssignment assignment = existing.get(); | ||
| List<String> updated = merge(replace ? null : assignment.getValues(), values); | ||
| if (!updated.equals(normalize(assignment.getValues()))) { | ||
| assignment.setValues(updated); | ||
| return tagAssignmentRepository.save(assignment).getValues(); | ||
| } | ||
| return normalize(assignment.getValues()); | ||
| } | ||
|
|
||
| /** Insertion-ordered union — existing values keep their order, new ones are appended. */ | ||
| private static List<String> merge(List<String> current, List<String> added) { | ||
| LinkedHashSet<String> merged = new LinkedHashSet<>(normalize(current)); | ||
| merged.addAll(normalize(added)); | ||
| return new ArrayList<>(merged); | ||
| } | ||
|
|
||
| private static List<String> normalize(List<String> values) { | ||
| return values != null ? values : List.of(); | ||
| } | ||
|
|
||
| private static int size(List<String> values) { | ||
| return values != null ? values.size() : 0; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🦩 🟠 [warn/recommended] findOrCreateTag / upsertAssignment read-then-write pattern is not concurrency-safe against parallel assignTag calls for the same key
findOrCreateTag() does a findByKeyAndEntityType then conditionally save() without any uniqueness constraint enforcement or optimistic locking visible in this diff. Two concurrent assignDeviceTag calls for a brand-new key (e.g., two devices tagging the same new key simultaneously) can both see
existing == nulland each create a separate Tag document for the same key, resulting in duplicate tag keys in Mongo. Given this is a new device-facing mutation exposed to potentially concurrent client callers, a unique index on (key, entityType) with duplicate-key handling (as required elsewhere in the org's OPENFRAM-006-11 idiom for idempotent inserts) should be applied here too.Evidence
🤖 Prompt for AI agents
confidence: 35 — react 👍/👎 to teach the reviewer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Half right. Duplicate keys can't happen:
Taghas a unique(tenantId, key, entityType)index andTagAssignmenta unique(tenantId, entityId, tagId, entityType)one, andauto-index-creationis on in the base configs. What did happen is that the request losing the race got aDuplicateKeyExceptionsurfaced as a database error. Fixed in ada5ab6: both creates now catch it, re-read the winner's document and continue through the normal merge path. Covered by three new cases inDeviceTagServiceTest.