Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
}
BulkResponse result;
try {
result = client.bulk(br.refresh(Refresh.True).build());
result = client.bulk(br.refresh(Refresh.WaitFor).build());
// Log errors, if any
if (result.errors()) {
logger.log(Level.SEVERE, TextUtil.BULK_HAD_ERRORS);
Expand Down Expand Up @@ -427,17 +427,29 @@
@Override
public List<Channel> findAllById(Iterable<String> channelIds) {
try {
List<String> ids =
StreamSupport.stream(channelIds.spliterator(), false).collect(Collectors.toList());
List<String> ids = normalizeIds(channelIds);

SearchRequest.Builder searchBuilder =
new SearchRequest.Builder()
.index(esService.getES_CHANNEL_INDEX())
.query(IdsQuery.of(q -> q.values(ids))._toQuery())
.size(esService.getES_QUERY_SIZE())
.sort(SortOptions.of(s -> s.field(FieldSort.of(f -> f.field("name")))));
SearchResponse<Channel> response = client.search(searchBuilder.build(), Channel.class);
return response.hits().hits().stream().map(Hit::source).collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyList();
}

int lookupBatchSize = Math.max(1, Math.min(chunkSize, esService.getES_QUERY_SIZE()));

Check warning on line 436 in src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Math.clamp" instead of "Math.min" or "Math.max".

See more on https://sonarcloud.io/project/issues?id=ChannelFinder_ChannelFinderService&issues=AZ4D81aHAQ_Df9u5NAbo&open=AZ4D81aHAQ_Df9u5NAbo&pullRequest=217
List<Channel> result = new ArrayList<>();

for (int i = 0; i < ids.size(); i += lookupBatchSize) {
List<String> chunk = ids.subList(i, Math.min(i + lookupBatchSize, ids.size()));
SearchRequest.Builder searchBuilder =
new SearchRequest.Builder()
.index(esService.getES_CHANNEL_INDEX())
.query(IdsQuery.of(q -> q.values(chunk))._toQuery())
.size(chunk.size())
.sort(SortOptions.of(s -> s.field(FieldSort.of(f -> f.field("name")))));
SearchResponse<Channel> response = client.search(searchBuilder.build(), Channel.class);
result.addAll(
response.hits().hits().stream().map(Hit::source).collect(Collectors.toList()));
}

return result;
} catch (ElasticsearchException | IOException e) {
logger.log(Level.SEVERE, TextUtil.FAILED_TO_FIND_ALL_CHANNELS, e);
throw new ResponseStatusException(
Expand Down Expand Up @@ -805,9 +817,70 @@
}

@Override
@SuppressWarnings("unchecked")
public void deleteAllById(Iterable<? extends String> ids) {
// TODO Auto-generated method stub
deleteAllByIdBestEffort((Iterable<String>) ids);
}

public long deleteAllByIdBestEffort(Iterable<String> ids) {
List<String> idList = normalizeIds(ids);

if (idList.isEmpty()) {
return 0;
}

long deletedCount = 0;

for (int i = 0; i < idList.size(); i += chunkSize) {
List<String> chunk = idList.subList(i, Math.min(i + chunkSize, idList.size()));
BulkRequest.Builder br = new BulkRequest.Builder();
for (String id : chunk) {
br.operations(op -> op.delete(del -> del.index(esService.getES_CHANNEL_INDEX()).id(id)));
}
br.refresh(Refresh.True);

try {
BulkResponse result = client.bulk(br.build());
for (BulkResponseItem item : result.items()) {
if (item.error() != null) {
logger.log(
Level.SEVERE,
() ->
MessageFormat.format(
"Failed to delete channel id {0}: {1}", item.id(), item.error().reason()));
continue;
}
if (Integer.valueOf(200).equals(item.status())) {
deletedCount++;
Comment thread
shroffk marked this conversation as resolved.
}
}
} catch (IOException e) {
logger.log(
Level.SEVERE,
MessageFormat.format(
"Bulk delete failed for chunk starting at index {0} with size {1}",
i, chunk.size()),
e);
}
}

return deletedCount;
}

/**
* Normalizes channel IDs by dropping null/blank values and removing duplicates while preserving
* encounter order.
*
* @param ids raw channel IDs from request/repository callers
* @return distinct, non-blank channel IDs in encounter order
*/
private static List<String> normalizeIds(Iterable<String> ids) {
// TODO: Consider rejecting blank/whitespace-only IDs with 400 at the API boundary.

Check warning on line 878 in src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=ChannelFinder_ChannelFinderService&issues=AZ4Xugtez9F_u5ZTvjvK&open=AZ4Xugtez9F_u5ZTvjvK&pullRequest=217
return StreamSupport.stream(ids.spliterator(), false)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toCollection(LinkedHashSet::new))
.stream()
.toList();
}

@PreDestroy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
}

public Iterable<Channel> create(Iterable<Channel> channels) {
requireRole(ROLES.CF_CHANNEL, "channels batch");

Check failure on line 95 in src/main/java/org/phoebus/channelfinder/service/ChannelService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "channels batch" 3 times.

See more on https://sonarcloud.io/project/issues?id=ChannelFinder_ChannelFinderService&issues=AZ4D81ZIAQ_Df9u5NAbn&open=AZ4D81ZIAQ_Df9u5NAbn&pullRequest=217

List<Channel> channelList = Lists.newArrayList(channels);
Map<String, Channel> existing = findExistingChannels(channelList);
Expand Down Expand Up @@ -179,6 +179,24 @@
channelRepository.deleteById(channelName);
}

public long remove(Iterable<String> channelNames) {
requireRole(ROLES.CF_CHANNEL, "channels batch");
List<Channel> existingChannels = channelRepository.findAllById(channelNames);

for (Channel existing : existingChannels) {
requireOwner(existing);
audit.log(
Level.INFO, () -> MessageFormat.format(TextUtil.DELETE_CHANNEL, existing.getName()));
}

if (existingChannels.isEmpty()) {
return 0;
}

return channelRepository.deleteAllByIdBestEffort(
existingChannels.stream().map(Channel::getName).toList());
}

private Map<String, Channel> findExistingChannels(List<Channel> channels) {
return channelRepository.findAllById(channels.stream().map(Channel::getName).toList()).stream()
.collect(Collectors.toMap(Channel::getName, c -> c));
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
Original file line number Diff line number Diff line change
Expand Up @@ -269,4 +269,24 @@ long queryCount(
})
@DeleteMapping("/{channelName}")
void remove(@PathVariable("channelName") String channelName);

@Operation(
summary = "Delete multiple channels",
description = "Delete multiple channel instances identified by a request-body list of names.",
operationId = "deleteChannels",
tags = {"Channel"})
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Number of channels deleted"),
@ApiResponse(
responseCode = "401",
description = "Unauthorized",
content = @Content(schema = @Schema(implementation = ResponseStatusException.class))),
@ApiResponse(
responseCode = "500",
description = "Error while trying to delete channels",
content = @Content(schema = @Schema(implementation = ResponseStatusException.class)))
Comment thread
jacomago marked this conversation as resolved.
})
@DeleteMapping
long remove(@RequestBody List<String> channelNames);
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ public Iterable<Channel> update(Iterable<Channel> channels) {
public void remove(String channelName) {
channelService.remove(channelName);
}

@Override
public long remove(List<String> channelNames) {
return channelService.remove(channelNames);
}
}
143 changes: 122 additions & 21 deletions src/test/java/org/phoebus/channelfinder/ChannelRepositoryIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,127 @@ void deleteXmlTag() {
"Failed to delete the channel");
}

/** delete multiple channels */
@Test
void deleteXmlChannels() {
Channel testChannel = new Channel("testChannel", "testOwner", testProperties, testTags);
Channel testChannel1 = new Channel("testChannel1", "testOwner1", testProperties, testTags);
List<Channel> testChannels = Arrays.asList(testChannel, testChannel1);
Iterable<Channel> createdChannels = channelRepository.indexAll(testChannels);
// Start with all created channels in cleanup list in case deleteAll fails
cleanupTestChannels = new ArrayList<>(Lists.newArrayList(createdChannels));

channelRepository.deleteAll(testChannels);
// verify the channels were deleted as expected
Assertions.assertFalse(
channelRepository.existsById(testChannel.getName()),
"Failed to delete the channel " + testChannel.getName());
cleanupTestChannels.removeIf(ch -> ch.getName().equals(testChannel.getName()));
Assertions.assertFalse(
channelRepository.existsById(testChannel1.getName()),
"Failed to delete the channel " + testChannel1.getName());
cleanupTestChannels.removeIf(ch -> ch.getName().equals(testChannel1.getName()));
}


/** Test best effort delete - should delete what it can, even when some channels don't exist */
@Test
void deleteChannelsBestEffort() {
Channel testChannel = new Channel("testChannel", "testOwner", testProperties, testTags);
Channel testChannel1 = new Channel("testChannel1", "testOwner1", testProperties, testTags);
List<Channel> testChannels = Arrays.asList(testChannel, testChannel1);
Iterable<Channel> createdChannels = channelRepository.indexAll(testChannels);
cleanupTestChannels = new ArrayList<>(Lists.newArrayList(createdChannels));

// Create a list with existing and non-existing channel IDs
List<String> channelIdsToDelete =
Arrays.asList(
testChannel.getName(), // exists
"non-existent-channel-1", // doesn't exist
testChannel1.getName(), // exists
"non-existent-channel-2" // doesn't exist
);

// Delete using best effort - should succeed for existing channels
long deletedCount = channelRepository.deleteAllByIdBestEffort(channelIdsToDelete);

// Verify that both existing channels were deleted (count should be 2)
Assertions.assertEquals(
2, deletedCount, "Expected 2 channels to be deleted, but got " + deletedCount);

// Verify the first channel was actually deleted
Assertions.assertFalse(
channelRepository.existsById(testChannel.getName()),
"Failed to delete the channel " + testChannel.getName());

// Verify the second channel was actually deleted
Assertions.assertFalse(
channelRepository.existsById(testChannel1.getName()),
"Failed to delete the channel " + testChannel1.getName());

cleanupTestChannels.clear();
}

/** Test best effort delete with all non-existent channels - should return 0 deleted count */
@Test
void deleteChannelsBestEffortWithNoExistingChannels() {
// Create a list with only non-existing channel IDs
List<String> channelIdsToDelete =
Arrays.asList("non-existent-channel-1", "non-existent-channel-2", "non-existent-channel-3");

// Delete using best effort - should handle gracefully with no deletions
long deletedCount = channelRepository.deleteAllByIdBestEffort(channelIdsToDelete);

// Verify that no channels were deleted
Assertions.assertEquals(
0, deletedCount, "Expected 0 channels to be deleted, but got " + deletedCount);
}

/** Test best effort delete with empty list - should return 0 deleted count */
@Test
void deleteChannelsBestEffortWithEmptyList() {
// Delete using best effort with an empty list
long deletedCount = channelRepository.deleteAllByIdBestEffort(Collections.emptyList());

// Verify that no channels were deleted
Assertions.assertEquals(
0,
deletedCount,
"Expected 0 channels to be deleted from empty list, but got " + deletedCount);
}

/** Test best effort delete with duplicate IDs - should handle duplicates correctly */
@Test
void deleteChannelsBestEffortWithDuplicateIds() {
Channel testChannel = new Channel("testChannel", "testOwner", testProperties, testTags);
Iterable<Channel> createdChannels = channelRepository.indexAll(Arrays.asList(testChannel));
cleanupTestChannels = new ArrayList<>(Lists.newArrayList(createdChannels));

// Create a list with duplicate IDs
List<String> channelIdsToDelete =
Arrays.asList(
testChannel.getName(), // first occurrence
"non-existent-channel",
testChannel.getName() // duplicate of existing channel
);

// Delete using best effort
long deletedCount = channelRepository.deleteAllByIdBestEffort(channelIdsToDelete);

// Should delete the channel only once (duplicates are normalized)
Assertions.assertEquals(
1,
deletedCount,
"Expected 1 channel to be deleted (duplicates should be normalized), but got "
+ deletedCount);

// Verify the channel was actually deleted
Assertions.assertFalse(
channelRepository.existsById(testChannel.getName()), "Failed to delete the channel");

cleanupTestChannels.clear();
}

/**
* Update a channel with 1. additional list of tags and properties 2. update the values of
* existing properties
Expand Down Expand Up @@ -510,27 +631,6 @@ private List<Property> createTestProperties() {
}
}

/**
* A utility class which will create the requested number of test tags named 'test-tag#'
*
* @return list of created tags
*/
private List<Tag> createTestTags(int count) {
List<Tag> testTags = new ArrayList<Tag>();
for (int i = 0; i < count; i++) {
Tag testTag = new Tag();
testTag.setName("test-tag" + i);
testTag.setOwner("test-owner");
testTags.add(testTag);
}
try {
return Lists.newArrayList(tagRepository.indexAll(testTags));
} catch (Exception e) {
tagRepository.deleteAll(testTags);
return Collections.emptyList();
}
}

// Helper operations to create and clean up the resources needed for successful
// testing of the channelRepository operations

Expand Down Expand Up @@ -575,4 +675,5 @@ public void cleanup() {
void tearDown() throws IOException {
ElasticConfigIT.teardown(esService);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,9 @@ void handleChannelsCreateUpdateCheck() {
+ "]";

ITUtilChannels.assertCreateReplaceMultipleChannels(
AuthorizationChoice.ADMIN, "", json_multiple, HttpURLConnection.HTTP_INTERNAL_ERROR);
AuthorizationChoice.ADMIN, "", json_multiple, HttpURLConnection.HTTP_BAD_REQUEST);

ITUtilChannels.assertUpdateChannels("", json_multiple, HttpURLConnection.HTTP_INTERNAL_ERROR);
ITUtilChannels.assertUpdateChannels("", json_multiple, HttpURLConnection.HTTP_BAD_REQUEST);

json_multiple =
"["
Expand Down
Loading
Loading