diff --git a/CHANGES.md b/CHANGES.md index 2a7889ae..f0e679e2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## Version 1.0.0 (Not yet Released) +* Create a RepairHistory to Store Information on Repair Operations Performed by ecChronos Agent #730 * Generate Unique EcChronos ID #678 * Create RepairConfiguration class for repair configurations - Issue #716 * Create DistributedJmxProxy and DistributedJmxProxyFactory - Issue #715 diff --git a/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryData.java b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryData.java new file mode 100644 index 00000000..debb1d39 --- /dev/null +++ b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryData.java @@ -0,0 +1,231 @@ +/* + * Copyright 2024 Telefonaktiebolaget LM Ericsson + * + * Licensed 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 com.ericsson.bss.cassandra.ecchronos.data.repairhistory; + +import com.ericsson.bss.cassandra.ecchronos.utils.enums.repair.RepairStatus; +import com.google.common.base.Preconditions; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public final class RepairHistoryData +{ + private final UUID myTableId; + private final UUID myNodeId; + private final UUID myRepairId; + private final UUID myJobId; + private final UUID myCoordinatorId; + private final String myRangeBegin; + private final String myRangeEnd; + private final Set myParticipants; + private final RepairStatus myStatus; + private final Instant myStartedAt; + private final Instant myFinishedAt; + private final long myLookBackTimeInMs; + + private RepairHistoryData(final Builder builder) + { + this.myTableId = builder.myTableId; + this.myNodeId = builder.myNodeId; + this.myRepairId = builder.myRepairId; + this.myJobId = builder.myJobId; + this.myCoordinatorId = builder.myCoordinatorId; + this.myRangeBegin = builder.myRangeBegin; + this.myRangeEnd = builder.myRangeEnd; + this.myParticipants = builder.myParticipants == null ? Set.of() : Set.copyOf(builder.myParticipants); + this.myStatus = builder.myStatus; + this.myStartedAt = builder.myStartedAt; + this.myFinishedAt = builder.myFinishedAt; + this.myLookBackTimeInMs = builder.myLookBackTimeInMs; + } + + public UUID getTableId() + { + return myTableId; + } + + public UUID getNodeId() + { + return myNodeId; + } + + public UUID getJobId() + { + return myJobId; + } + + public UUID getRepairId() + { + return myRepairId; + } + + public UUID getCoordinatorId() + { + return myCoordinatorId; + } + + public String getRangeBegin() + { + return myRangeBegin; + } + + public String getRangeEnd() + { + return myRangeEnd; + } + + public Set getParticipants() + { + return myParticipants; + } + + public RepairStatus getStatus() + { + return myStatus; + } + + public Instant getStartedAt() + { + return myStartedAt; + } + + public Instant getFinishedAt() + { + return myFinishedAt; + } + + public long getLookBackTimeInMilliseconds() + { + return myLookBackTimeInMs; + } + + public static Builder copyOf(final RepairHistoryData repairHistoryData) + { + return new RepairHistoryData.Builder() + .withTableId(repairHistoryData.myTableId) + .withNodeId(repairHistoryData.myNodeId) + .withRepairId(repairHistoryData.myRepairId) + .withJobId(repairHistoryData.myJobId) + .withCoordinatorId(repairHistoryData.myCoordinatorId) + .withRangeBegin(repairHistoryData.myRangeBegin) + .withRangeEnd(repairHistoryData.myRangeEnd) + .withParticipants(repairHistoryData.myParticipants) + .withStatus(repairHistoryData.myStatus) + .withStartedAt(repairHistoryData.myStartedAt) + .withFinishedAt(repairHistoryData.myFinishedAt) + .withLookBackTimeInMilliseconds(repairHistoryData.myLookBackTimeInMs); + } + + public static final class Builder + { + private UUID myTableId; + private UUID myNodeId; + private UUID myRepairId; + private UUID myJobId; + private UUID myCoordinatorId; + private String myRangeBegin; + private String myRangeEnd; + private Set myParticipants; + private RepairStatus myStatus; + private Instant myStartedAt; + private Instant myFinishedAt; + private long myLookBackTimeInMs; + + public Builder withTableId(final UUID tableId) + { + this.myTableId = tableId; + return this; + } + + public Builder withNodeId(final UUID nodeId) + { + this.myNodeId = nodeId; + return this; + } + + public Builder withRepairId(final UUID repairId) + { + this.myRepairId = repairId; + return this; + } + + public Builder withJobId(final UUID jobId) + { + this.myJobId = jobId; + return this; + } + + public Builder withCoordinatorId(final UUID coordinatorId) + { + this.myCoordinatorId = coordinatorId; + return this; + } + + public Builder withRangeBegin(final String rangeBegin) + { + this.myRangeBegin = rangeBegin; + return this; + } + + public Builder withRangeEnd(final String rangeEnd) + { + this.myRangeEnd = rangeEnd; + return this; + } + + public Builder withParticipants(final Set participants) + { + this.myParticipants = (participants == null) ? Set.of() : new HashSet<>(participants); + return this; + } + + public Builder withStatus(final RepairStatus status) + { + this.myStatus = status; + return this; + } + + public Builder withStartedAt(final Instant startedAt) + { + this.myStartedAt = startedAt; + return this; + } + + public Builder withFinishedAt(final Instant finishedAt) + { + this.myFinishedAt = finishedAt; + return this; + } + + public Builder withLookBackTimeInMilliseconds(final long lookBackTimeInMilliseconds) + { + this.myLookBackTimeInMs = lookBackTimeInMilliseconds; + return this; + } + + public RepairHistoryData build() + { + Preconditions.checkNotNull(myTableId, "Table ID cannot be null"); + Preconditions.checkNotNull(myNodeId, "Node ID cannot be null"); + Preconditions.checkNotNull(myRepairId, "Repair ID cannot be null"); + Preconditions.checkNotNull(myStatus, "Status cannot be null"); + Preconditions.checkNotNull(myStartedAt, "StartedAt cannot be null"); + Preconditions.checkNotNull(myFinishedAt, "FinishedAt cannot be null"); + Preconditions.checkArgument(myLookBackTimeInMs > 0, "LookBack time must be a positive number"); + return new RepairHistoryData(this); + } + } +} diff --git a/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryService.java b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryService.java new file mode 100644 index 00000000..f91f33a3 --- /dev/null +++ b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/RepairHistoryService.java @@ -0,0 +1,327 @@ +/* + * Copyright 2024 Telefonaktiebolaget LM Ericsson + * + * Licensed 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 com.ericsson.bss.cassandra.ecchronos.data.repairhistory; + +import com.datastax.oss.driver.api.core.ConsistencyLevel; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.BoundStatement; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.querybuilder.QueryBuilder; +import com.ericsson.bss.cassandra.ecchronos.utils.enums.repair.RepairStatus; +import com.google.common.base.Preconditions; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.bindMarker; +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.selectFrom; + +public final class RepairHistoryService +{ + + private static final Logger LOG = LoggerFactory.getLogger(RepairHistoryService.class); + private static final String UNIVERSAL_TIMEZONE = "UTC"; + + private static final String KEYSPACE_NAME = "ecchronos"; + private static final String TABLE_NAME = "repair_history"; + private static final String COLUMN_NODE_ID = "node_id"; + private static final String COLUMN_TABLE_ID = "table_id"; + private static final String COLUMN_REPAIR_ID = "repair_id"; + private static final String COLUMN_JOB_ID = "job_id"; + private static final String COLUMN_COORDINATOR_ID = "coordinator_id"; + private static final String COLUMN_RANGE_BEGIN = "range_begin"; + private static final String COLUMN_RANGE_END = "range_end"; + private static final String COLUMN_PARTICIPANTS = "participants"; + private static final String COLUMN_STATUS = "status"; + private static final String COLUMN_STARTED_AT = "started_at"; + private static final String COLUMN_FINISHED_AT = "finished_at"; + + private final PreparedStatement myCreateStatement; + private final PreparedStatement myUpdateStatement; + private final PreparedStatement mySelectStatement; + private final CqlSession myCqlSession; + + public RepairHistoryService(final CqlSession cqlSession) + { + myCqlSession = Preconditions.checkNotNull(cqlSession, "CqlSession cannot be null"); + myCreateStatement = myCqlSession + .prepare(QueryBuilder.insertInto(KEYSPACE_NAME, TABLE_NAME) + .value(COLUMN_TABLE_ID, bindMarker()) + .value(COLUMN_NODE_ID, bindMarker()) + .value(COLUMN_REPAIR_ID, bindMarker()) + .value(COLUMN_JOB_ID, bindMarker()) + .value(COLUMN_COORDINATOR_ID, bindMarker()) + .value(COLUMN_RANGE_BEGIN, bindMarker()) + .value(COLUMN_RANGE_END, bindMarker()) + .value(COLUMN_PARTICIPANTS, bindMarker()) + .value(COLUMN_STATUS, bindMarker()) + .value(COLUMN_STARTED_AT, bindMarker()) + .value(COLUMN_FINISHED_AT, bindMarker()) + .build() + .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM)); + myUpdateStatement = myCqlSession + .prepare(QueryBuilder.update(KEYSPACE_NAME, TABLE_NAME) + .setColumn(COLUMN_JOB_ID, bindMarker()) + .setColumn(COLUMN_COORDINATOR_ID, bindMarker()) + .setColumn(COLUMN_RANGE_BEGIN, bindMarker()) + .setColumn(COLUMN_RANGE_END, bindMarker()) + .setColumn(COLUMN_PARTICIPANTS, bindMarker()) + .setColumn(COLUMN_STATUS, bindMarker()) + .setColumn(COLUMN_STARTED_AT, bindMarker()) + .setColumn(COLUMN_FINISHED_AT, bindMarker()) + .whereColumn(COLUMN_TABLE_ID) + .isEqualTo(bindMarker()) + .whereColumn(COLUMN_NODE_ID) + .isEqualTo(bindMarker()) + .whereColumn(COLUMN_REPAIR_ID) + .isEqualTo(bindMarker()) + .build() + .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM)); + mySelectStatement = myCqlSession + .prepare(selectFrom(KEYSPACE_NAME, TABLE_NAME) + .columns(COLUMN_NODE_ID, COLUMN_TABLE_ID, COLUMN_REPAIR_ID, COLUMN_JOB_ID, COLUMN_COORDINATOR_ID, + COLUMN_RANGE_BEGIN, COLUMN_RANGE_END, COLUMN_PARTICIPANTS, COLUMN_STATUS, COLUMN_STARTED_AT, + COLUMN_FINISHED_AT) + .whereColumn(COLUMN_TABLE_ID) + .isEqualTo(bindMarker()) + .whereColumn(COLUMN_NODE_ID) + .isEqualTo(bindMarker()) + .build() + .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM)); + } + + /** + * Retrieves the repair history entries that fall within the specified time period. + * + * This method fetches repair history rows from a Cassandra table and filters them based on the + * started and finished timestamps in the given time period. The filtering is done using local + * dates to ignore timezone differences. + * + * @param repairHistoryData The data containing the time period to filter the repair history. + * @return A list of filtered RepairHistoryData objects containing repair history entries. + */ + public List getRepairHistoryByTimePeriod(final RepairHistoryData repairHistoryData) + { + ResultSet resultSet = getRepairHistoryInfo(repairHistoryData); + List rows = StreamSupport.stream(resultSet.spliterator(), false).collect(Collectors.toList()); + + Instant queryStartedAt = repairHistoryData.getStartedAt(); + Instant queryFinishedAt = repairHistoryData.getFinishedAt(); + + LocalDate queryStartDate = queryStartedAt.atZone(ZoneId.of(UNIVERSAL_TIMEZONE)).toLocalDate(); + LocalDate queryFinishDate = queryFinishedAt.atZone(ZoneId.of(UNIVERSAL_TIMEZONE)).toLocalDate(); + + List filteredHistoryData = rows.stream() + .filter(row -> + { + // Extract the started and finished timestamps from the row + Instant startedAt = row.get(COLUMN_STARTED_AT, Instant.class); + Instant finishedAt = row.get(COLUMN_FINISHED_AT, Instant.class); + + // Convert the row start and finish times to local dates + LocalDate rowStartDate = startedAt.atZone(ZoneId.of(UNIVERSAL_TIMEZONE)).toLocalDate(); + LocalDate rowFinishDate = finishedAt.atZone(ZoneId.of(UNIVERSAL_TIMEZONE)).toLocalDate(); + + LOG.debug("Filtering row: startedAt={}, finishedAt={}, queryStartedAt={}, queryFinishedAt={}", + startedAt, finishedAt, queryStartedAt, queryFinishedAt); + + // Check if the row is within the date range + return !rowFinishDate.isBefore(queryStartDate) && !rowStartDate.isAfter(queryFinishDate); + }) + .map(row -> convertRowToRepairHistoryData(row, repairHistoryData.getLookBackTimeInMilliseconds())) + .collect(Collectors.toList()); + + return filteredHistoryData; + } + + /** + * Retrieves the repair history entries that match the specified status. + * + * This method fetches all repair history rows from a Cassandra table and filters them + * based on the provided status in the RepairHistoryData object. The results are returned as a list + * of filtered RepairHistoryData objects. + * + * @param repairHistoryData The data containing the status to filter the repair history. + * @return A list of filtered RepairHistoryData objects that match the specified status. + */ + public List getRepairHistoryByStatus(final RepairHistoryData repairHistoryData) + { + LOG.info("Fetching repair history for status: {}", repairHistoryData.getStatus()); + ResultSet resultSet = getRepairHistoryInfo(repairHistoryData); + List rows = StreamSupport.stream(resultSet.spliterator(), false).collect(Collectors.toList()); + + List filteredHistoryData = rows.stream() + .filter(row -> + { + String status = row.getString(COLUMN_STATUS); + boolean matches = repairHistoryData.getStatus().name().equalsIgnoreCase(status); + + LOG.debug("Row status: {}, matches: {}", status, matches); + return matches; + }) + .map(row -> convertRowToRepairHistoryData(row, repairHistoryData.getLookBackTimeInMilliseconds())) + .collect(Collectors.toList()); + + return filteredHistoryData; + } + + /** + * Retrieves the repair history entries that fall within the look back time period. + * + * This method calculates the look back time based on the system's current time and the + * look back duration specified in the ecc.yml configuration file. It then queries the + * repair history records that have a start time greater than or equal to the look back time. + * + * @param repairHistoryData The data containing the look back time to filter the repair history. + * @return A list of filtered RepairHistoryData objects that fall within the look back time period. + */ + public List getRepairHistoryByLookBackTime(final RepairHistoryData repairHistoryData) + { + long currentTimeInMillis = System.currentTimeMillis(); + long lookBackTimeInMillis = repairHistoryData.getLookBackTimeInMilliseconds(); + long lookBackStartTime = currentTimeInMillis - lookBackTimeInMillis; + + LOG.info("Fetching repair history with look back start time: {}", lookBackStartTime); + + ResultSet resultSet = getRepairHistoryInfo(repairHistoryData); + List rows = StreamSupport.stream(resultSet.spliterator(), false).collect(Collectors.toList()); + + // Filter rows based on the look back time + List filteredHistoryData = rows.stream() + .filter(row -> + { + Instant startedAt = row.get(COLUMN_STARTED_AT, Instant.class); + Instant finishedAt = row.get(COLUMN_FINISHED_AT, Instant.class); + + long startedAtInMillis = startedAt.toEpochMilli(); + long finishedAtInMillis = finishedAt.toEpochMilli(); + + // Ensure either startedAt or finishedAt falls within the lookback period + boolean withinLookBackPeriod = (startedAtInMillis >= lookBackStartTime || finishedAtInMillis >= lookBackStartTime); + + LOG.debug("Row startedAt: {}, finishedAt: {}, within look back period: {}", startedAt, finishedAt, + withinLookBackPeriod); + return withinLookBackPeriod; + }) + .map(row -> convertRowToRepairHistoryData(row, lookBackTimeInMillis)) + .collect(Collectors.toList()); + + return filteredHistoryData; + } + + public ResultSet getRepairHistoryInfo(final RepairHistoryData repairHistoryData) + { + BoundStatement boundStatement = mySelectStatement.bind(repairHistoryData.getTableId(), + repairHistoryData.getNodeId()); + return myCqlSession.execute(boundStatement); + } + + public ResultSet insertRepairHistoryInfo(final RepairHistoryData repairHistoryData) + { + LOG.info("Preparing to insert repair history with tableId {} and nodeId {}", + repairHistoryData.getTableId(), repairHistoryData.getNodeId()); + BoundStatement repairHistoryInfo = myCreateStatement.bind(repairHistoryData.getTableId(), + repairHistoryData.getNodeId(), + repairHistoryData.getRepairId(), + repairHistoryData.getJobId(), + repairHistoryData.getCoordinatorId(), + repairHistoryData.getRangeBegin(), + repairHistoryData.getRangeEnd(), + repairHistoryData.getParticipants(), + repairHistoryData.getStatus().name(), + repairHistoryData.getStartedAt(), + repairHistoryData.getFinishedAt()); + ResultSet tmpResultSet = myCqlSession.execute(repairHistoryInfo); + if (tmpResultSet.wasApplied()) + { + LOG.info("RepairHistory inserted successfully with tableId {} and nodeId {}", + repairHistoryData.getTableId(), repairHistoryData.getNodeId()); + } + else + { + LOG.error("Unable to insert repairHistory with tableId {} and nodeId {}", + repairHistoryData.getTableId(), + repairHistoryData.getNodeId()); + } + return tmpResultSet; + } + + public ResultSet updateRepairHistoryInfo(final RepairHistoryData repairHistoryData) + { + BoundStatement updateRepairHistoryInfo = myUpdateStatement.bind(repairHistoryData.getJobId(), + repairHistoryData.getCoordinatorId(), + repairHistoryData.getRangeBegin(), + repairHistoryData.getRangeEnd(), + repairHistoryData.getParticipants(), + repairHistoryData.getStatus().name(), + repairHistoryData.getStartedAt(), + repairHistoryData.getFinishedAt(), + repairHistoryData.getTableId(), + repairHistoryData.getNodeId(), + repairHistoryData.getRepairId()); + ResultSet tmpResultSet = myCqlSession.execute(updateRepairHistoryInfo); + if (tmpResultSet.wasApplied()) + { + LOG.info("RepairHistory successfully updated for tableId {} and nodeId {}", + repairHistoryData.getTableId(), repairHistoryData.getNodeId()); + } + else + { + LOG.error("RepairHistory updated failed for tableId {} and nodeId {}", + repairHistoryData.getTableId(), repairHistoryData.getNodeId()); + } + return tmpResultSet; + } + + private RepairHistoryData convertRowToRepairHistoryData(final Row row, final long lookBackTimeInMs) + { + UUID tableId = row.get(COLUMN_TABLE_ID, UUID.class); + UUID nodeId = row.get(COLUMN_NODE_ID, UUID.class); + UUID repairId = row.get(COLUMN_REPAIR_ID, UUID.class); + UUID jobId = row.get(COLUMN_JOB_ID, UUID.class); + UUID coordinatorId = row.get(COLUMN_COORDINATOR_ID, UUID.class); + String rangeBegin = row.get(COLUMN_RANGE_BEGIN, String.class); + String rangeEnd = row.get(COLUMN_RANGE_END, String.class); + Set participants = row.getSet(COLUMN_PARTICIPANTS, UUID.class); + String status = row.get(COLUMN_STATUS, String.class); + Instant startedAt = row.get(COLUMN_STARTED_AT, Instant.class); + Instant finishedAt = row.get(COLUMN_FINISHED_AT, Instant.class); + RepairStatus repairStatus = RepairStatus.getFromStatus(status); + + return new RepairHistoryData.Builder() + .withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(repairId) + .withJobId(jobId) + .withCoordinatorId(coordinatorId) + .withRangeBegin(rangeBegin) + .withRangeEnd(rangeEnd) + .withParticipants(participants) + .withStatus(repairStatus) + .withStartedAt(startedAt) + .withFinishedAt(finishedAt) + .withLookBackTimeInMilliseconds(lookBackTimeInMs) + .build(); + } +} diff --git a/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/package-info.java b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/package-info.java new file mode 100644 index 00000000..9896aabb --- /dev/null +++ b/data/src/main/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Telefonaktiebolaget LM Ericsson + * + * Licensed 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. + */ +/** + * This package contains classes related to the handling and management of repair history data + * for the ecChronos service, which interfaces with Cassandra to maintain repair operation logs. + * Contains the representation of repair_history table. + */ +package com.ericsson.bss.cassandra.ecchronos.data.repairhistory; + diff --git a/data/src/test/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/TestRepairHistoryService.java b/data/src/test/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/TestRepairHistoryService.java new file mode 100644 index 00000000..7edfce6c --- /dev/null +++ b/data/src/test/java/com/ericsson/bss/cassandra/ecchronos/data/repairhistory/TestRepairHistoryService.java @@ -0,0 +1,372 @@ +/* + * Copyright 2024 Telefonaktiebolaget LM Ericsson + * + * Licensed 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 com.ericsson.bss.cassandra.ecchronos.data.repairhistory; + +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.ericsson.bss.cassandra.ecchronos.data.utils.AbstractCassandraTest; +import com.ericsson.bss.cassandra.ecchronos.utils.enums.repair.RepairStatus; +import java.io.IOException; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static com.ericsson.bss.cassandra.ecchronos.data.repairhistory.RepairHistoryData.Builder; +import static com.ericsson.bss.cassandra.ecchronos.data.repairhistory.RepairHistoryData.copyOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class TestRepairHistoryService extends AbstractCassandraTest +{ + + private static final String ECCHRONOS_KEYSPACE = "ecchronos"; + private static final String COLUMN_NODE_ID = "node_id"; + private static final String COLUMN_TABLE_ID = "table_id"; + private static final String COLUMN_REPAIR_ID = "repair_id"; + private static final String COLUMN_COORDINATOR_ID = "coordinator_id"; + private static final String COLUMN_STATUS = "status"; + + private RepairHistoryService myRepairHistoryService; + + @Before + public void setup() throws IOException + { + AbstractCassandraTest.mySession.execute(String.format( + "CREATE KEYSPACE IF NOT EXISTS %s WITH replication = {'class': 'NetworkTopologyStrategy', 'DC1': 1}", + ECCHRONOS_KEYSPACE)); + String query = String.format( + "CREATE TABLE IF NOT EXISTS %s.repair_history(" + + "table_id UUID, " + + "node_id UUID, " + + "repair_id timeuuid, " + + "job_id UUID, " + + "coordinator_id UUID, " + + "range_begin text, " + + "range_end text, " + + "participants set, " + + "status text, " + + "started_at timestamp, " + + "finished_at timestamp, " + + "PRIMARY KEY((table_id,node_id), repair_id)) " + + "WITH CLUSTERING ORDER BY (repair_id DESC);", + ECCHRONOS_KEYSPACE); + AbstractCassandraTest.mySession.execute(query); + myRepairHistoryService = new RepairHistoryService(AbstractCassandraTest.mySession); + } + + @After + public void testCleanup() + { + AbstractCassandraTest.mySession.execute(SimpleStatement.newInstance( + String.format("TRUNCATE %s.%s", ECCHRONOS_KEYSPACE, "repair_history"))); + } + + @Test + public void testWriteReadUpdateRepairHistoryInfo() + { + UUID tableId = UUID.randomUUID(); + UUID nodeId = UUID.randomUUID(); + UUID repairId = Uuids.timeBased(); + UUID coordinatorId = UUID.randomUUID(); + Builder builder = new Builder(); + + RepairHistoryData repairHistoryData = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(repairId) + .withStatus(RepairStatus.STARTED) + .withStartedAt(Instant.now()) + .withFinishedAt(Instant.now().now()) + .withLookBackTimeInMilliseconds(1) + .build(); + + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData); + + ResultSet result = myRepairHistoryService.getRepairHistoryInfo(repairHistoryData); + assertNotNull(result); + Row row = result.one(); + UUID expectedTableId = row.get(COLUMN_TABLE_ID, UUID.class); + UUID expectedNodeId = row.get(COLUMN_NODE_ID, UUID.class); + UUID expectedRepairId = row.get(COLUMN_REPAIR_ID, UUID.class); + UUID expectedCoordinatorId = row.get(COLUMN_COORDINATOR_ID, UUID.class); + + assertEquals(expectedTableId, tableId); + assertEquals(expectedNodeId, nodeId); + assertEquals(expectedRepairId, repairId); + assertEquals(expectedCoordinatorId, null); + + RepairHistoryData updatedRepairHistoryData = copyOf(repairHistoryData) + .withJobId(UUID.randomUUID()) + .withCoordinatorId(coordinatorId) + .withRangeBegin("123") + .withRangeEnd("1000") + .withParticipants(Collections.EMPTY_SET) + .withStatus(RepairStatus.SUCCESS) + .withStartedAt(Instant.now()) + .withFinishedAt(Instant.now().now()) + .withLookBackTimeInMilliseconds(1) + .build(); + + myRepairHistoryService.updateRepairHistoryInfo(updatedRepairHistoryData); + + ResultSet updatedResult = myRepairHistoryService.getRepairHistoryInfo(repairHistoryData); + Row updatedRow = updatedResult.one(); + expectedCoordinatorId = updatedRow.get(COLUMN_COORDINATOR_ID, UUID.class); + String expectedStatus = updatedRow.get(COLUMN_STATUS, String.class); + assertEquals(expectedCoordinatorId, coordinatorId); + assertEquals(expectedStatus, RepairStatus.SUCCESS.name()); + } + + @Test + public void testGetRepairHistoryByTimePeriod() + { + // Insert multiple repair history data entries + UUID tableId = UUID.randomUUID(); + UUID nodeId = UUID.randomUUID(); + + Builder builder = new Builder(); + + Instant now = Instant.now(); + Instant oneHourAgo = now.minusSeconds(3600); + Instant oneHourLater = now.plusSeconds(3600); + + // Entry 1 (within range) + RepairHistoryData repairHistoryData1 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.STARTED) + .withStartedAt(oneHourAgo) // 1 hour ago + .withFinishedAt(now) // Now + .withLookBackTimeInMilliseconds(1) + .build(); + + // Entry 2 (within range) + RepairHistoryData repairHistoryData2 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.SUCCESS) + .withStartedAt(now.minusSeconds(600)) // 10 minutes ago + .withFinishedAt(now.plusSeconds(600)) // 10 minutes in the future + .withLookBackTimeInMilliseconds(1) + .build(); + + // Insert data into the repair history table + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData1); + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData2); + + // Fetch the data by time period (including the last hour) + RepairHistoryData queryData = builder.withTableId(tableId) + .withNodeId(nodeId) + .withStartedAt(oneHourAgo) // Start of time range (1 hour ago) + .withFinishedAt(oneHourLater) // End of time range (1 hour in the future) + .build(); + + List result = myRepairHistoryService.getRepairHistoryByTimePeriod(queryData); + + // Verify the fetched data is correct and within the expected range + assertNotNull(result); + + // There should be at least two rows matching the time range + assertEquals(2, result.size()); + + for (RepairHistoryData repairHistoryData : result) + { + UUID expectedTableId = repairHistoryData.getTableId(); + UUID expectedNodeId = repairHistoryData.getNodeId(); + + assertEquals(expectedTableId, tableId); + assertEquals(expectedNodeId, nodeId); + } + } + + @Test + public void testGetRepairHistoryByStatus() + { + // Insert multiple repair history data entries + UUID tableId = UUID.randomUUID(); + UUID nodeId = UUID.randomUUID(); + + Builder builder = new Builder(); + + // Entry 1 (status: SUCCESS) + RepairHistoryData repairHistoryData1 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.SUCCESS) + .withStartedAt(Instant.now().minusSeconds(3600)) // 1 hour ago + .withFinishedAt(Instant.now()) + .withLookBackTimeInMilliseconds(1) + .build(); + + // Entry 2 (status: FAILED) + RepairHistoryData repairHistoryData2 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.FAILED) + .withStartedAt(Instant.now().minusSeconds(7200)) // 2 hours ago + .withFinishedAt(Instant.now().minusSeconds(3600)) // 1 hour ago + .build(); + + // Insert data into the repair history table + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData1); + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData2); + + RepairHistoryData queryData = builder.withTableId(tableId) + .withNodeId(nodeId) + .withStatus(RepairStatus.SUCCESS) + .build(); + + List result = myRepairHistoryService.getRepairHistoryByStatus(queryData); + + // Verify the fetched data is correct and matches the expected status + assertNotNull(result); + assertEquals(1, result.size()); + + for (RepairHistoryData repairHistoryData : result) + { + UUID expectedTableId = repairHistoryData.getTableId(); + UUID expectedNodeId = repairHistoryData.getNodeId(); + String status = repairHistoryData.getStatus().name(); + + assertEquals(expectedTableId, tableId); + assertEquals(expectedNodeId, nodeId); + assertEquals(status, RepairStatus.SUCCESS.name()); + } + } + + @Test + public void testGetRepairHistoryByStatusNoMatches() + { + // Insert a repair history entry with a different status + UUID tableId = UUID.randomUUID(); + UUID nodeId = UUID.randomUUID(); + + Builder builder = new Builder(); + + // Entry (status: IN_PROGRESS) + RepairHistoryData repairHistoryData = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.STARTED) + .withStartedAt(Instant.now().minusSeconds(3600)) // 1 hour ago + .withFinishedAt(Instant.now()) // Now + .withLookBackTimeInMilliseconds(1) + .build(); + + // Insert data into the repair history table + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData); + + // Fetch the data by a different status + RepairHistoryData queryData = builder.withTableId(tableId) + .withNodeId(nodeId) + .withStatus(RepairStatus.SUCCESS) + .withLookBackTimeInMilliseconds(1) + .build(); + + List result = myRepairHistoryService.getRepairHistoryByStatus(queryData); + + // Verify that no rows match the SUCCESS status + assertNotNull(result); + assertEquals(0, result.size()); + } + + @Test + public void testGetRepairHistoryByLookBackTime() + { + UUID tableId = UUID.randomUUID(); + UUID nodeId = UUID.randomUUID(); + + Builder builder = new Builder(); + + Instant now = Instant.now(); + Instant thirtyDaysAgo = now.minusSeconds(30 * 24 * 60 * 60); // 30 days ago + Instant oneHourAgo = now.minusSeconds(3600); // 1 hour ago + Instant twoMonthsAgo = now.minusSeconds(60 * 24 * 60 * 60); // 60 days ago (outside the lookback window) + + System.out.println("Now: " + now); + System.out.println("Thirty days ago: " + thirtyDaysAgo); + System.out.println("One hour ago: " + oneHourAgo); + System.out.println("Two months ago: " + twoMonthsAgo); + + // Entry 1 (within lookback time, 30 days ago) + RepairHistoryData repairHistoryData1 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.STARTED) + .withStartedAt(thirtyDaysAgo) // 30 days ago + .withFinishedAt(now) + .withLookBackTimeInMilliseconds(30 * 24 * 60 * 60 * 1000L) // 30 days in milliseconds + .build(); + + // Entry 2 (within lookback time, 1 hour ago) + RepairHistoryData repairHistoryData2 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.SUCCESS) + .withStartedAt(oneHourAgo) // 1 hour ago + .withFinishedAt(now) + .withLookBackTimeInMilliseconds(30 * 24 * 60 * 60 * 1000L) // 30 days in milliseconds + .build(); + + // Entry 3 (outside lookback time, 60 days ago) + RepairHistoryData repairHistoryData3 = builder.withTableId(tableId) + .withNodeId(nodeId) + .withRepairId(Uuids.timeBased()) + .withStatus(RepairStatus.FAILED) + .withStartedAt(twoMonthsAgo) // 60 days ago + .withFinishedAt(now.minusSeconds(60 * 24 * 60 * 60)) // Also 60 days ago + .withLookBackTimeInMilliseconds(30 * 24 * 60 * 60 * 1000L) // 30 days in milliseconds + .build(); + + // Insert data into the repair history table + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData1); + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData2); + myRepairHistoryService.insertRepairHistoryInfo(repairHistoryData3); + + // Fetch the data by lookback time (30 days) + RepairHistoryData queryData = builder.withLookBackTimeInMilliseconds(30 * 24 * 60 * 60 * 1000L).build(); + List result = myRepairHistoryService.getRepairHistoryByLookBackTime(queryData); + + // Verify the fetched data is correct and within the look back time range + assertNotNull(result); + assertEquals(2, result.size()); // Should return only two entries + + // Tolerance for comparing timestamps, in milliseconds + long toleranceMillis = 1; + for (RepairHistoryData repairHistoryData : result) + { + UUID expectedTableId = repairHistoryData.getTableId(); + UUID expectedNodeId = repairHistoryData.getNodeId(); + + // Validate table ID and node ID + assertEquals(expectedTableId, tableId); + assertEquals(expectedNodeId, nodeId); + + Instant startedAt = repairHistoryData.getStartedAt(); + + // Check if startedAt is after or equal to thirtyDaysAgo with a tolerance + boolean isAfterOrEqual = startedAt.isAfter(thirtyDaysAgo) || + (startedAt.toEpochMilli() >= thirtyDaysAgo.toEpochMilli() - toleranceMillis); + + assertTrue("Expected startedAt " + startedAt + " to be after or equal to " + thirtyDaysAgo, isAfterOrEqual); + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 343e19d1..bdc61601 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -104,6 +104,28 @@ CREATE TABLE ecchronos.nodes_sync node_id DESC ); ``` +### Repair History + +A RepairHistory table to store relevant repair operation data. Data can be retrieved for auditing or debugging purposes. + +```cql +CREATE TABLE ecchronos.repair_history( + table_id uuid, + node_id uuid, + repair_id timeuuid, + job_id uuid, + coordinator_id uuid, + range_begin text, + range_end text, + participants set, + status text, + started_at timestamp, + finished_at timestamp, + PRIMARY KEY((table_id,node_id), repair_id)) + WITH compaction = {'class': 'TimeWindowCompactionStrategy'} + AND default_time_to_live = 2592000 + AND CLUSTERING ORDER BY (repair_id DESC); +``` ### Leases diff --git a/utils/src/main/java/com/ericsson/bss/cassandra/ecchronos/utils/enums/repair/RepairStatus.java b/utils/src/main/java/com/ericsson/bss/cassandra/ecchronos/utils/enums/repair/RepairStatus.java new file mode 100644 index 00000000..28034e80 --- /dev/null +++ b/utils/src/main/java/com/ericsson/bss/cassandra/ecchronos/utils/enums/repair/RepairStatus.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Telefonaktiebolaget LM Ericsson + * + * Licensed 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 com.ericsson.bss.cassandra.ecchronos.utils.enums.repair; + +public enum RepairStatus +{ + STARTED, + SUCCESS, + FAILED, + UNKNOWN; + + /** + * Get RepairStatus from value. + * + * @param status The status value. + * @return RepairStatus + */ + public static RepairStatus getFromStatus(final String status) + { + RepairStatus repairStatus; + + try + { + repairStatus = RepairStatus.valueOf(status); + } + catch (IllegalArgumentException e) + { + repairStatus = RepairStatus.UNKNOWN; + } + + return repairStatus; + } +}