Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ ScheduledTask createTask(String taskType,
*/
void cancelTask(String taskId);

/**
* Cancels any in-flight execution and permanently removes the task from storage and memory.
* Unlike cancelTask, the task record is not retained after this call.
* Safe to call on COMPLETED or CANCELLED tasks.
*
* @param taskId the task ID to delete
*/
void deleteTask(String taskId);

/**
* Gets all tasks from both storage and memory.
* This provides a complete view of all tasks in the system,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ interface TypeStatistics {
*/
<T extends Serializable> void remove(String itemType, String id, String tenantId, Class<T> typeClass);

/**
* Removes a value from the cache, resolving the itemType internally from the registered type config.
* Silently no-ops if {@code typeClass} has no registered configuration.
*
* @param id the item identifier
* @param tenantId the tenant identifier
* @param typeClass the class of the type to remove (used to look up the registered itemType)
* @param <T> the type to remove
*/
<T extends Serializable> void remove(String id, String tenantId, Class<T> typeClass);

/**
* Clears all cached values for a tenant.
*
Expand Down
66 changes: 66 additions & 0 deletions api/src/main/java/org/apache/unomi/api/utils/PollingUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.unomi.api.utils;

import java.util.function.Predicate;
import java.util.function.Supplier;

/**
* Utility methods for polling until a condition is met.
*/
public final class PollingUtils {

private PollingUtils() {}

/**
* Polls {@code call} repeatedly until {@code predicate} is satisfied or retries are exhausted.
* The first attempt is made immediately (no leading sleep).
*
* @param <T> type returned by {@code call}
* @param failMessage message for the exception if the predicate is never satisfied
* @param call supplier invoked on each attempt
* @param predicate condition that must hold on the returned value
* @param delayMillis milliseconds to sleep between attempts (must be &gt;= 0)
* @param maxRetries maximum number of attempts (must be &gt;= 0); 0 means no calls are made
* and {@code IllegalStateException} is thrown immediately
* @return the first value satisfying {@code predicate}
* @throws IllegalArgumentException if {@code delayMillis} or {@code maxRetries} is negative
* @throws InterruptedException if the thread is interrupted while sleeping between attempts
* @throws IllegalStateException if the predicate is not satisfied within {@code maxRetries} attempts
*/
public static <T> T pollUntil(String failMessage, Supplier<T> call, Predicate<T> predicate,
long delayMillis, int maxRetries) throws InterruptedException {
if (delayMillis < 0) {
throw new IllegalArgumentException("delayMillis must be non-negative, got: " + delayMillis);
}
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must be non-negative, got: " + maxRetries);
}
T last = null;
for (int i = 0; i < maxRetries; i++) {
if (i > 0) {
Thread.sleep(delayMillis);
}
last = call.get();
if (predicate.test(last)) {
return last;
}
}
String detail = last != null ? " (last value: " + last + ")" : "";
throw new IllegalStateException(failMessage + detail);
}
}
195 changes: 195 additions & 0 deletions api/src/test/java/org/apache/unomi/api/utils/PollingUtilsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.unomi.api.utils;

import org.junit.Test;

import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.Assert.*;

public class PollingUtilsTest {

// --- utility class structure ---

@Test
public void testPrivateConstructorForCoverage() throws Exception {
Constructor<PollingUtils> ctor = PollingUtils.class.getDeclaredConstructor();
assertFalse("Constructor should be private", Modifier.isPublic(ctor.getModifiers()));
ctor.setAccessible(true);
ctor.newInstance(); // covers the private constructor body
}

// --- input validation ---

@Test(expected = IllegalArgumentException.class)
public void testNegativeMaxRetriesThrowsIllegalArgument() throws InterruptedException {
PollingUtils.pollUntil("fail", () -> "x", v -> true, 0, -1);
}

@Test(expected = IllegalArgumentException.class)
public void testNegativeDelayThrowsIllegalArgument() throws InterruptedException {
PollingUtils.pollUntil("fail", () -> "x", v -> true, -1, 3);
}

// --- success paths ---

@Test
public void testReturnsImmediatelyWhenFirstAttemptSatisfiesPredicate() throws InterruptedException {
String result = PollingUtils.pollUntil("fail", () -> "hello", "hello"::equals, 0, 3);
assertEquals("hello", result);
}

@Test
public void testReturnsAfterSeveralAttempts() throws InterruptedException {
AtomicInteger calls = new AtomicInteger(0);
// Returns null for the first two calls, "ready" on the third
String result = PollingUtils.pollUntil(
"should eventually be ready",
() -> calls.incrementAndGet() >= 3 ? "ready" : null,
"ready"::equals,
1, 5
);
assertEquals("ready", result);
assertEquals(3, calls.get());
}

@Test
public void testSucceedsOnLastPossibleAttempt() throws InterruptedException {
AtomicInteger calls = new AtomicInteger(0);
int maxRetries = 4;
Integer result = PollingUtils.pollUntil(
"fail",
calls::incrementAndGet,
n -> n == maxRetries,
1, maxRetries
);
assertEquals(maxRetries, (int) result);
assertEquals(maxRetries, calls.get());
}

// --- exhaustion: last value non-null ---

@Test
public void testExceptionMessageContainsFailMessageAndLastValue() {
try {
PollingUtils.pollUntil("not found", () -> "last-result", v -> false, 1, 2);
fail("Expected IllegalStateException");
} catch (IllegalStateException e) {
assertTrue("fail message should be in exception", e.getMessage().contains("not found"));
assertTrue("last value should be in exception", e.getMessage().contains("last-result"));
} catch (InterruptedException e) {
fail("Should not be interrupted");
}
}

@Test
public void testCallCountMatchesMaxRetriesOnExhaustion() {
AtomicInteger calls = new AtomicInteger(0);
try {
PollingUtils.pollUntil("exhausted", calls::incrementAndGet, v -> false, 1, 5);
fail("Expected IllegalStateException");
} catch (IllegalStateException e) {
assertEquals("should have tried exactly maxRetries times", 5, calls.get());
} catch (InterruptedException e) {
fail("Should not be interrupted");
}
}

// --- exhaustion: last value null ---

@Test
public void testExceptionMessageOmitsDetailWhenCallReturnsNull() {
// Covers the last != null → false branch: call runs but returns null, predicate never satisfied
try {
PollingUtils.pollUntil("null result", () -> null, v -> false, 1, 2);
fail("Expected IllegalStateException");
} catch (IllegalStateException e) {
assertEquals("message should be exactly the failMessage with no detail appended",
"null result", e.getMessage());
} catch (InterruptedException e) {
fail("Should not be interrupted");
}
}

@Test
public void testZeroMaxRetriesMeansNoCallsAndImmediateThrow() {
// Covers: loop never entered, last stays null, throws with plain failMessage
AtomicInteger calls = new AtomicInteger(0);
try {
PollingUtils.pollUntil("zero retries", () -> { calls.incrementAndGet(); return "x"; }, "x"::equals, 1, 0);
fail("Expected IllegalStateException");
} catch (IllegalStateException e) {
assertEquals(0, calls.get());
assertEquals("zero retries", e.getMessage());
} catch (InterruptedException e) {
fail("Should not be interrupted");
}
}

// --- null return value on success ---

@Test
public void testCanReturnNullWhenPredicateAcceptsNull() throws InterruptedException {
// Callers may wait for something to become absent; predicate v -> v == null is valid
String result = PollingUtils.pollUntil("fail", () -> null, v -> v == null, 0, 3);
assertNull(result);
}

// --- supplier throws ---

@Test
public void testRuntimeExceptionFromSupplierPropagatesUnwrapped() {
RuntimeException cause = new IllegalArgumentException("bad call");
try {
PollingUtils.pollUntil("fail", () -> { throw cause; }, v -> true, 0, 3);
fail("Expected RuntimeException");
} catch (RuntimeException e) {
assertSame("exception should propagate without wrapping", cause, e);
} catch (InterruptedException e) {
fail("Should not be interrupted");
}
}

// --- interruption ---

@Test
public void testPropagatesInterruptionDuringSleep() throws Exception {
AtomicInteger calls = new AtomicInteger(0);
Thread thread = new Thread(() -> {
try {
PollingUtils.pollUntil("interrupted", () -> {
calls.incrementAndGet();
return null;
}, v -> false, 10_000, 100);
fail("Should not reach here");
} catch (InterruptedException e) {
// expected — thread was interrupted during the inter-attempt sleep
}
});
thread.start();
// Wait for at least one call to complete, then interrupt during the long sleep
while (calls.get() == 0) {
Thread.sleep(1);
}
thread.interrupt();
thread.join(2000);
assertFalse("Thread should have finished after interruption", thread.isAlive());
}
}
5 changes: 5 additions & 0 deletions bom/artifacts/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
<artifactId>unomi-json-schema-rest</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>unomi-json-schema-shell-commands</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>unomi-rest</artifactId>
Expand Down
1 change: 1 addition & 0 deletions extensions/json-schema/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<modules>
<module>services</module>
<module>rest</module>
<module>shell-commands</module>
</modules>

</project>
Loading
Loading