Repository: gchq/sleeper
Issue: Ingest task reporting fails when task exits immediately

If an ingest task starts up and then immediately exits, it will try to report a task finished event with zero duration. This produces an ingest rate of NaN as it's divided by zero.

This throws an exception when it tries to store the event like `AmazonDynamoDBException: The parameter cannot be converted to a numeric value: NaN`.

There may also be an equivalent failure in the compaction reporting.

Changed files:
java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBUtilsPagingIT.java
java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBAttributesTest.java
java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBAttributes.java
java/ingest/ingest-status-store/src/main/java/sleeper/ingest/status/store/task/DynamoDBIngestTaskStatusFormat.java
java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/task/StoreIngestTaskIT.java
java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBRecordBuilder.java
java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBRecordBuilderIT.java
java/compaction/compaction-status-store/src/main/java/sleeper/compaction/status/store/task/DynamoDBCompactionTaskStatusFormat.java
java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/testutils/DynamoDBIngestTaskStatusStoreTestBase.java
java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBTableTestBase.java

diff --git a/java/compaction/compaction-status-store/src/main/java/sleeper/compaction/status/store/task/DynamoDBCompactionTaskStatusFormat.java b/java/compaction/compaction-status-store/src/main/java/sleeper/compaction/status/store/task/DynamoDBCompactionTaskStatusFormat.java
index dedfa526a..adb529d56 100644
--- a/java/compaction/compaction-status-store/src/main/java/sleeper/compaction/status/store/task/DynamoDBCompactionTaskStatusFormat.java
+++ b/java/compaction/compaction-status-store/src/main/java/sleeper/compaction/status/store/task/DynamoDBCompactionTaskStatusFormat.java
@@ -31,10 +31,10 @@ import java.util.Map;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
+import static sleeper.dynamodb.tools.DynamoDBAttributes.getDoubleAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getInstantAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getIntAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getLongAttribute;
-import static sleeper.dynamodb.tools.DynamoDBAttributes.getNumberAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getStringAttribute;
 
 public class DynamoDBCompactionTaskStatusFormat {
@@ -116,8 +116,8 @@ public class DynamoDBCompactionTaskStatusFormat {
                         .totalJobRuns(getIntAttribute(item, NUMBER_OF_JOBS, 0))
                         .totalRecordsRead(getLongAttribute(item, LINES_READ, 0))
                         .totalRecordsWritten(getLongAttribute(item, LINES_WRITTEN, 0))
-                        .recordsReadPerSecond(Double.parseDouble(getNumberAttribute(item, READ_RATE)))
-                        .recordsWrittenPerSecond(Double.parseDouble(getNumberAttribute(item, WRITE_RATE)))
+                        .recordsReadPerSecond(getDoubleAttribute(item, READ_RATE, 0))
+                        .recordsWrittenPerSecond(getDoubleAttribute(item, WRITE_RATE, 0))
                         .build());
                 break;
             default:
diff --git a/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBAttributes.java b/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBAttributes.java
index 23f0a32d1..d15cc5c3f 100644
--- a/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBAttributes.java
+++ b/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBAttributes.java
@@ -45,7 +45,14 @@ public class DynamoDBAttributes {
      * @return the AttributeValue
      */
     public static AttributeValue createNumberAttribute(Number number) {
-        return new AttributeValue().withN("" + number);
+        // To differentiate NaN and null:
+        // - An attribute which is set to the value of null will be treated as NaN
+        // - An attribute which is NOT set, will be treated as null
+        if (Double.valueOf(Double.NaN).equals(number)) {
+            return new AttributeValue().withNULL(true);
+        } else {
+            return new AttributeValue().withN(String.valueOf(number));
+        }
     }
 
     public static AttributeValue createBinaryAttribute(byte[] bytes) {
@@ -88,6 +95,18 @@ public class DynamoDBAttributes {
         return buildInstant.apply(Long.parseLong(string));
     }
 
+    public static double getDoubleAttribute(Map<String, AttributeValue> item, String key, double defaultValue) {
+        if (!item.containsKey(key)) {
+            return defaultValue;
+        }
+        String attributeValue = getNumberAttribute(item, key);
+        if (attributeValue == null) {
+            return Double.NaN;
+        } else {
+            return Double.parseDouble(attributeValue);
+        }
+    }
+
     private static <T> T getAttribute(Map<String, AttributeValue> item, String name, Function<AttributeValue, T> getter) {
         AttributeValue value = item.get(name);
         if (value == null) {
@@ -96,5 +115,4 @@ public class DynamoDBAttributes {
             return getter.apply(value);
         }
     }
-
 }
diff --git a/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBRecordBuilder.java b/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBRecordBuilder.java
index 65b83b361..e9106b9b8 100644
--- a/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBRecordBuilder.java
+++ b/java/dynamodb-tools/src/main/java/sleeper/dynamodb/tools/DynamoDBRecordBuilder.java
@@ -35,7 +35,11 @@ public class DynamoDBRecordBuilder {
     }
 
     public DynamoDBRecordBuilder number(String key, Number value) {
-        return add(new Attribute(key, createNumberAttribute(value)));
+        if (null == value) {
+            return remove(key);
+        } else {
+            return add(new Attribute(key, createNumberAttribute(value)));
+        }
     }
 
     public DynamoDBRecordBuilder apply(Consumer<DynamoDBRecordBuilder> config) {
@@ -53,6 +57,11 @@ public class DynamoDBRecordBuilder {
         return this;
     }
 
+    private DynamoDBRecordBuilder remove(String key) {
+        attributes.removeIf(attribute -> attribute.key.equals(key));
+        return this;
+    }
+
     private static class Attribute {
         private final String key;
         private final AttributeValue value;
diff --git a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBAttributesTest.java b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBAttributesTest.java
index 757375f01..758c0136b 100644
--- a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBAttributesTest.java
+++ b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBAttributesTest.java
@@ -72,4 +72,36 @@ public class DynamoDBAttributesTest {
         // Then the Instant attribute should be read from the record
         assertThat(DynamoDBAttributes.getInstantAttribute(item, TEST_KEY)).isEqualTo(time);
     }
+
+    @Test
+    void shouldGetDoubleAttribute() {
+        // Given
+        Map<String, AttributeValue> item = new HashMap<>();
+        item.put(TEST_KEY, DynamoDBAttributes.createNumberAttribute(123.456));
+
+        // When/Then
+        assertThat(DynamoDBAttributes.getDoubleAttribute(item, TEST_KEY, 0))
+                .isEqualTo(123.456);
+    }
+
+    @Test
+    void shouldGetDoubleAttributeWhenAttributeSetToNull() {
+        // Given
+        Map<String, AttributeValue> item = new HashMap<>();
+        item.put(TEST_KEY, null);
+
+        // When/Then
+        assertThat(DynamoDBAttributes.getDoubleAttribute(item, TEST_KEY, 0))
+                .isEqualTo(Double.valueOf(Double.NaN));
+    }
+
+    @Test
+    void shouldNotGetDoubleAttributeWhenAttributeNotSet() {
+        // Given
+        Map<String, AttributeValue> item = new HashMap<>();
+
+        // When/Then
+        assertThat(DynamoDBAttributes.getDoubleAttribute(item, TEST_KEY, 0))
+                .isEqualTo(0);
+    }
 }
diff --git a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBRecordBuilderIT.java b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBRecordBuilderIT.java
index 2ff652660..2a0c8a72f 100644
--- a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBRecordBuilderIT.java
+++ b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBRecordBuilderIT.java
@@ -31,15 +31,13 @@ import static org.assertj.core.api.Assertions.assertThat;
 public class DynamoDBRecordBuilderIT extends DynamoDBTableTestBase {
     @Test
     public void shouldCreateRecordWithStringAttribute() {
-        // Given we have a table in dynamodb that accepts strings
-        createStringTable();
-        // When we create a record with string attributes
+        // Given
         Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .string(TEST_VALUE, "value").build();
-        // And save that record to dynamodb
+        // When
         dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
-        // Then we should be able to retrieve the record from dynamodb
+        // Then
         ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
         assertThat(result.getItems()).containsExactly(record);
     }
@@ -47,46 +45,84 @@ public class DynamoDBRecordBuilderIT extends DynamoDBTableTestBase {
 
     @Test
     public void shouldCreateRecordWithIntAttribute() {
-        // Given we have a table in dynamodb that accepts strings
-        createNumericTable();
-        // When we create a record with string attributes
+        // Given
         Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .number(TEST_VALUE, 123).build();
-        // And save that record to dynamodb
+        // When
         dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
-        // Then we should be able to retrieve the record from dynamodb
+        // Then
         ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
         assertThat(result.getItems()).containsExactly(record);
     }
 
     @Test
     public void shouldCreateRecordWithLongAttribute() {
-        // Given we have a table in dynamodb that accepts strings
-        createNumericTable();
-        // When we create a record with string attributes
+        // Given
         Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .number(TEST_VALUE, 123L).build();
-        // And save that record to dynamodb
+        // When
         dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
-        // Then we should be able to retrieve the record from dynamodb
+        // Then
         ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
         assertThat(result.getItems()).containsExactly(record);
     }
 
     @Test
     public void shouldCreateRecordWithInstantAttribute() {
-        // Given we have a table in dynamodb that accepts strings
-        createNumericTable();
-        // When we create a record with string attributes
+        // Given
         Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .number(TEST_VALUE, Instant.now().toEpochMilli()).build();
-        // And save that record to dynamodb
+        // When
         dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
-        // Then we should be able to retrieve the record from dynamodb
+        // Then
         ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
         assertThat(result.getItems()).containsExactly(record);
     }
+
+    @Test
+    void shouldCreateRecordWithNaN() {
+        // Given
+        String key = UUID.randomUUID().toString();
+        Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
+                .string(TEST_KEY, key)
+                .number(TEST_VALUE, Double.NaN).build();
+        // When
+        dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
+        // Then
+        ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
+        assertThat(result.getItems()).containsExactly(record);
+    }
+
+    @Test
+    void shouldCreateRecordWithNullNumber() {
+        // Given
+        String key = UUID.randomUUID().toString();
+        Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
+                .string(TEST_KEY, key)
+                .number(TEST_VALUE, null).build();
+        // When
+        dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
+        // Then
+        ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
+        assertThat(result.getItems()).containsExactly(record);
+    }
+
+    @Test
+    void shouldUnsetExistingNumberAttributeWhenProvidingNull() {
+        // Given
+        String key = UUID.randomUUID().toString();
+        Map<String, AttributeValue> record = new DynamoDBRecordBuilder()
+                .string(TEST_KEY, key)
+                .number(TEST_VALUE, 123)
+                .number(TEST_VALUE, null).build();
+        // When
+        dynamoDBClient.putItem(new PutItemRequest(TEST_TABLE_NAME, record));
+        // Then
+        ScanResult result = dynamoDBClient.scan(new ScanRequest().withTableName(TEST_TABLE_NAME));
+        assertThat(result.getItems()).containsExactly(new DynamoDBRecordBuilder()
+                .string(TEST_KEY, key).build());
+    }
 }
diff --git a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBTableTestBase.java b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBTableTestBase.java
index 7d69f6fc9..30bc59a18 100644
--- a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBTableTestBase.java
+++ b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBTableTestBase.java
@@ -21,8 +21,9 @@ import com.amazonaws.services.dynamodbv2.model.KeySchemaElement;
 import com.amazonaws.services.dynamodbv2.model.KeyType;
 import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 
-import java.util.Arrays;
+import java.util.List;
 
 import static sleeper.dynamodb.tools.DynamoDBUtils.initialiseTable;
 
@@ -31,26 +32,21 @@ public class DynamoDBTableTestBase extends DynamoDBTestBase {
     public static final String TEST_VALUE = "test-value";
     public static final String TEST_TABLE_NAME = "dynamodb-tools-test-table";
 
+    @BeforeEach
+    void setup() {
+        createTable();
+    }
+
     @AfterEach
     public void tearDown() {
         dynamoDBClient.deleteTable(TEST_TABLE_NAME);
     }
 
-    public static void createStringTable() {
-        createTable(ScalarAttributeType.S);
-    }
-
-    public static void createNumericTable() {
-        createTable(ScalarAttributeType.N);
-    }
-
-    public static void createTable(ScalarAttributeType valueType) {
+    public static void createTable() {
         initialiseTable(dynamoDBClient, TEST_TABLE_NAME,
-                Arrays.asList(
-                        new AttributeDefinition(TEST_KEY, ScalarAttributeType.S),
-                        new AttributeDefinition(TEST_VALUE, valueType)),
-                Arrays.asList(
-                        new KeySchemaElement(TEST_KEY, KeyType.HASH),
-                        new KeySchemaElement(TEST_VALUE, KeyType.RANGE)));
+                List.of(
+                        new AttributeDefinition(TEST_KEY, ScalarAttributeType.S)),
+                List.of(
+                        new KeySchemaElement(TEST_KEY, KeyType.HASH)));
     }
 }
diff --git a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBUtilsPagingIT.java b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBUtilsPagingIT.java
index 41d1c2c7a..f72249953 100644
--- a/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBUtilsPagingIT.java
+++ b/java/dynamodb-tools/src/test/java/sleeper/dynamodb/tools/DynamoDBUtilsPagingIT.java
@@ -31,8 +31,6 @@ public class DynamoDBUtilsPagingIT extends DynamoDBTableTestBase {
     @Test
     void shouldReturnPagedResultsWhenMoreRecordsThanScanLimit() {
         // Given
-        createStringTable();
-
         Map<String, AttributeValue> record1 = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .string(TEST_VALUE, "value1").build();
@@ -53,8 +51,6 @@ public class DynamoDBUtilsPagingIT extends DynamoDBTableTestBase {
     @Test
     void shouldReturnOnePageOfResultsWhenFewerRecordsThanScanLimit() {
         // Given
-        createStringTable();
-
         Map<String, AttributeValue> record1 = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .string(TEST_VALUE, "value1").build();
@@ -71,8 +67,6 @@ public class DynamoDBUtilsPagingIT extends DynamoDBTableTestBase {
     @Test
     void shouldReturnPagedResultsWhenRecordsEqualToScanLimit() {
         // Given
-        createStringTable();
-
         Map<String, AttributeValue> record1 = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .string(TEST_VALUE, "value1").build();
@@ -89,8 +83,6 @@ public class DynamoDBUtilsPagingIT extends DynamoDBTableTestBase {
     @Test
     void shouldReturnPagedResultsWhenLastPageHasFewerRecordsThanScanLimit() {
         // Given
-        createStringTable();
-
         Map<String, AttributeValue> record1 = new DynamoDBRecordBuilder()
                 .string(TEST_KEY, UUID.randomUUID().toString())
                 .string(TEST_VALUE, "value1").build();
@@ -114,9 +106,6 @@ public class DynamoDBUtilsPagingIT extends DynamoDBTableTestBase {
 
     @Test
     void shouldReturnNoResultsWhenNoRecordsExist() {
-        // Given
-        createStringTable();
-
         // When/Then
         assertThat(streamPagedItems(dynamoDBClient, new ScanRequest()
                 .withTableName(TEST_TABLE_NAME)
diff --git a/java/ingest/ingest-status-store/src/main/java/sleeper/ingest/status/store/task/DynamoDBIngestTaskStatusFormat.java b/java/ingest/ingest-status-store/src/main/java/sleeper/ingest/status/store/task/DynamoDBIngestTaskStatusFormat.java
index 12276842e..f2f1aecf8 100644
--- a/java/ingest/ingest-status-store/src/main/java/sleeper/ingest/status/store/task/DynamoDBIngestTaskStatusFormat.java
+++ b/java/ingest/ingest-status-store/src/main/java/sleeper/ingest/status/store/task/DynamoDBIngestTaskStatusFormat.java
@@ -31,10 +31,10 @@ import java.util.Map;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
+import static sleeper.dynamodb.tools.DynamoDBAttributes.getDoubleAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getInstantAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getIntAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getLongAttribute;
-import static sleeper.dynamodb.tools.DynamoDBAttributes.getNumberAttribute;
 import static sleeper.dynamodb.tools.DynamoDBAttributes.getStringAttribute;
 
 public class DynamoDBIngestTaskStatusFormat {
@@ -113,8 +113,8 @@ public class DynamoDBIngestTaskStatusFormat {
                         .totalJobRuns(getIntAttribute(item, NUMBER_OF_JOBS, 0))
                         .totalRecordsRead(getLongAttribute(item, LINES_READ, 0))
                         .totalRecordsWritten(getLongAttribute(item, LINES_WRITTEN, 0))
-                        .recordsReadPerSecond(Double.parseDouble(getNumberAttribute(item, READ_RATE)))
-                        .recordsWrittenPerSecond(Double.parseDouble(getNumberAttribute(item, WRITE_RATE)))
+                        .recordsReadPerSecond(getDoubleAttribute(item, READ_RATE, 0))
+                        .recordsWrittenPerSecond(getDoubleAttribute(item, WRITE_RATE, 0))
                         .build());
                 break;
             default:
diff --git a/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/task/StoreIngestTaskIT.java b/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/task/StoreIngestTaskIT.java
index 7b41a1edb..e8cc2d178 100644
--- a/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/task/StoreIngestTaskIT.java
+++ b/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/task/StoreIngestTaskIT.java
@@ -79,4 +79,19 @@ public class StoreIngestTaskIT extends DynamoDBIngestTaskStatusStoreTestBase {
                 .isNull();
     }
 
+    @Test
+    public void shouldReportIngestTaskFinishedWithZeroDuration() {
+        // Given
+        IngestTaskStatus taskStatus = finishedTaskWithNoJobsAndZeroDuration();
+
+        // When
+        store.taskStarted(taskStatus);
+        store.taskFinished(taskStatus);
+
+        // Then
+        assertThat(store.getTask(taskStatus.getTaskId()))
+                .usingRecursiveComparison(IGNORE_EXPIRY_DATE)
+                .isEqualTo(taskStatus);
+    }
+
 }
diff --git a/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/testutils/DynamoDBIngestTaskStatusStoreTestBase.java b/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/testutils/DynamoDBIngestTaskStatusStoreTestBase.java
index b5fa6e022..9032b7268 100644
--- a/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/testutils/DynamoDBIngestTaskStatusStoreTestBase.java
+++ b/java/ingest/ingest-status-store/src/test/java/sleeper/ingest/status/store/testutils/DynamoDBIngestTaskStatusStoreTestBase.java
@@ -33,6 +33,7 @@ import sleeper.ingest.task.IngestTaskStatusStore;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.UUID;
 
 import static sleeper.configuration.properties.UserDefinedInstanceProperty.ID;
@@ -42,7 +43,11 @@ import static sleeper.ingest.status.store.task.DynamoDBIngestTaskStatusStore.tas
 public class DynamoDBIngestTaskStatusStoreTestBase extends DynamoDBTestBase {
 
     protected static final RecursiveComparisonConfiguration IGNORE_EXPIRY_DATE = RecursiveComparisonConfiguration.builder()
-            .withIgnoredFields("expiryDate").build();
+            .withIgnoredFields("expiryDate")
+            // For some reason, default Double comparator compares NaNs as object references instead of their double values
+            .withComparatorForFields(Comparator.naturalOrder(),
+                    "finishedStatus.recordsReadPerSecond", "finishedStatus.recordsWrittenPerSecond")
+            .build();
     private final InstanceProperties instanceProperties = IngestStatusStoreTestUtils.createInstanceProperties();
     private final String taskStatusTableName = taskStatusTableName(instanceProperties.get(ID));
     protected final IngestTaskStatusStore store = IngestTaskStatusStoreFactory.getStatusStore(dynamoDBClient, instanceProperties);
@@ -111,6 +116,12 @@ public class DynamoDBIngestTaskStatusStoreTestBase extends DynamoDBTestBase {
         ).build();
     }
 
+    protected static IngestTaskStatus finishedTaskWithNoJobsAndZeroDuration() {
+        return startedTaskWithDefaultsBuilder().finished(
+                defaultTaskStartTime(), IngestTaskFinishedStatus.builder()
+        ).build();
+    }
+
     protected static IngestTaskStatus taskWithStartTime(Instant startTime) {
         return taskBuilder().startTime(startTime).build();
     }