Coordinate metadata migration maintenance

This commit is contained in:
Logic
2026-08-10 03:07:08 +08:00
parent dd3039611c
commit 6bc71db9f1
21 changed files with 1585 additions and 2 deletions
@@ -0,0 +1,68 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import org.apache.hertzbeat.common.transaction.MetadataWriteMaintenanceLease;
/** Releases one acquired maintenance window in strict reverse order with retryable progress. */
final class CompositeMigrationMaintenanceLease implements MigrationMaintenanceLease {
private final DeploymentSingletonLease authorityLease;
private final MigrationSourceLease sourceLease;
private final MetadataMaintenanceLease producerLease;
private final MetadataWriteMaintenanceLease writeLease;
private final Runnable reservationRelease;
private boolean writeReleased;
private boolean producerReleased;
private boolean sourceReleased;
private boolean authorityReleased;
CompositeMigrationMaintenanceLease(
DeploymentSingletonLease authorityLease,
MigrationSourceLease sourceLease,
MetadataMaintenanceLease producerLease,
MetadataWriteMaintenanceLease writeLease,
Runnable reservationRelease) {
this.authorityLease = authorityLease;
this.sourceLease = sourceLease;
this.producerLease = producerLease;
this.writeLease = writeLease;
this.reservationRelease = reservationRelease;
}
@Override
public synchronized void close() {
try {
releaseInOrder();
} catch (MigrationMaintenanceException exception) {
throw exception;
} catch (RuntimeException exception) {
throw MigrationMaintenanceException.resumeFailure();
}
reservationRelease.run();
}
private void releaseInOrder() {
if (!writeReleased) {
writeLease.close();
writeReleased = true;
}
if (!producerReleased) {
producerLease.resume();
producerReleased = true;
}
if (!sourceReleased) {
sourceLease.close();
sourceReleased = true;
}
if (!authorityReleased) {
authorityLease.close();
authorityReleased = true;
}
}
}
@@ -0,0 +1,152 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.sql.DataSource;
/** Bounded JDBC connection acquisition that closes every result arriving after its deadline. */
final class DeadlineConnectionAcquirer implements AutoCloseable {
private final DataSource dataSource;
private final ThreadPoolExecutor executor;
DeadlineConnectionAcquirer(DataSource dataSource) {
this.dataSource = dataSource;
executor = new ThreadPoolExecutor(0, 1, 30, TimeUnit.SECONDS, new SynchronousQueue<>(),
Thread.ofPlatform().daemon(true).name("migration-source-connection", 0).factory());
}
Connection acquire(Duration timeout) {
long timeoutNanos;
try {
timeoutNanos = timeout.toNanos();
} catch (ArithmeticException exception) {
throw MigrationMaintenanceException.invalidRequest();
}
Attempt attempt = new Attempt();
try {
executor.execute(() -> connect(attempt));
} catch (RejectedExecutionException exception) {
if (executor.isShutdown()) {
throw MigrationMaintenanceException.sourceUnavailable();
}
throw MigrationMaintenanceException.timeout();
}
return attempt.await(timeoutNanos);
}
private void connect(Attempt attempt) {
Connection acquired = null;
Throwable failure = null;
try {
acquired = dataSource.getConnection();
} catch (Throwable connectionFailure) {
failure = connectionFailure;
}
attempt.complete(acquired, failure);
}
@Override
public void close() {
executor.shutdownNow();
}
private static final class Attempt {
private final Object lock = new Object();
private final CountDownLatch completed = new CountDownLatch(1);
private Connection connection;
private Throwable failure;
private boolean abandoned;
private boolean finished;
private Connection await(long timeoutNanos) {
try {
if (completed.await(timeoutNanos, TimeUnit.NANOSECONDS)) {
return claimCompleted();
}
return abandonOrClaim();
} catch (InterruptedException exception) {
abandon();
Thread.currentThread().interrupt();
throw MigrationMaintenanceException.interrupted();
}
}
private void complete(Connection acquired, Throwable acquiredFailure) {
synchronized (lock) {
if (abandoned) {
closeLate(acquired);
} else {
connection = acquired;
failure = acquiredFailure;
}
finished = true;
}
completed.countDown();
}
private Connection abandonOrClaim() {
synchronized (lock) {
if (finished) {
return claimCompletedLocked();
}
abandoned = true;
}
throw MigrationMaintenanceException.timeout();
}
private void abandon() {
synchronized (lock) {
if (!finished) {
abandoned = true;
} else {
closeLate(connection);
connection = null;
}
}
}
private Connection claimCompleted() {
synchronized (lock) {
return claimCompletedLocked();
}
}
private Connection claimCompletedLocked() {
if (failure instanceof Error error) {
throw error;
}
if (failure != null || connection == null) {
throw MigrationMaintenanceException.sourceUnavailable();
}
Connection claimed = connection;
connection = null;
return claimed;
}
private static void closeLate(Connection lateConnection) {
if (lateConnection == null) {
return;
}
try {
lateConnection.close();
} catch (SQLException | RuntimeException exception) {
// A late connection never becomes a lease; its details remain private.
}
}
}
}
@@ -0,0 +1,246 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.time.Duration;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.LongSupplier;
import org.apache.hertzbeat.common.transaction.MetadataWriteAdmissionCoordinator;
import org.apache.hertzbeat.common.transaction.MetadataWriteAdmissionErrorCode;
import org.apache.hertzbeat.common.transaction.MetadataWriteAdmissionException;
import org.apache.hertzbeat.common.transaction.MetadataWriteMaintenanceLease;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/** Composes deployment, source, producer, and transaction fences without owning their state machines. */
@Component
public final class DefaultMigrationMaintenanceOrchestrator implements MigrationMaintenanceOrchestrator {
private final ReentrantLock lock = new ReentrantLock();
private final DeploymentSingletonAuthority deploymentAuthority;
private final MigrationSourceGuard sourceGuard;
private final MetadataMaintenanceCoordinator producerCoordinator;
private final MetadataWriteAdmissionCoordinator writeCoordinator;
private final LongSupplier ticker;
private String operationId;
private Object ownerToken;
private MetadataMaintenanceLease recoveryProducerLease;
private boolean recoveryProducerCoordinatorRequired;
private MigrationSourceLease recoverySourceLease;
private DeploymentSingletonLease recoveryAuthorityLease;
@Autowired
public DefaultMigrationMaintenanceOrchestrator(
DeploymentSingletonAuthority deploymentAuthority,
MigrationSourceGuard sourceGuard,
MetadataMaintenanceCoordinator producerCoordinator,
MetadataWriteAdmissionCoordinator writeCoordinator) {
this(deploymentAuthority, sourceGuard, producerCoordinator, writeCoordinator, System::nanoTime);
}
DefaultMigrationMaintenanceOrchestrator(
DeploymentSingletonAuthority deploymentAuthority,
MigrationSourceGuard sourceGuard,
MetadataMaintenanceCoordinator producerCoordinator,
MetadataWriteAdmissionCoordinator writeCoordinator,
LongSupplier ticker) {
this.deploymentAuthority = deploymentAuthority;
this.sourceGuard = sourceGuard;
this.producerCoordinator = producerCoordinator;
this.writeCoordinator = writeCoordinator;
this.ticker = ticker;
}
@Override
public MigrationMaintenanceLease acquire(String requestedOperationId, Duration timeout) {
requireRequest(requestedOperationId);
MaintenanceDeadline deadline;
try {
deadline = MaintenanceDeadline.start(timeout, ticker);
} catch (MetadataMaintenanceException exception) {
throw MigrationMaintenanceException.invalidRequest();
}
Object token = reserve(requestedOperationId);
DeploymentSingletonLease authorityLease = null;
MigrationSourceLease sourceLease = null;
MetadataMaintenanceLease producerLease = null;
try {
authorityLease = deploymentAuthority.acquire(requestedOperationId, deadline.remaining());
sourceLease = sourceGuard.fence(requestedOperationId, deadline.remaining());
producerLease = producerCoordinator.quiesce(requestedOperationId, deadline.remaining());
MetadataWriteMaintenanceLease writeLease =
writeCoordinator.acquire(requestedOperationId, deadline.remaining());
return new CompositeMigrationMaintenanceLease(
authorityLease, sourceLease, producerLease, writeLease, () -> releaseReservation(token));
} catch (Error error) {
if (cleanupFailedAcquisition(error, producerLease, sourceLease, authorityLease)) {
releaseReservation(token);
} else {
retainRecovery(producerLease, sourceLease, authorityLease);
}
throw error;
} catch (RuntimeException exception) {
MigrationMaintenanceException primary = mapFailure(exception);
if (cleanupFailedAcquisition(primary, producerLease, sourceLease, authorityLease)) {
releaseReservation(token);
} else {
retainRecovery(producerLease, sourceLease, authorityLease);
}
throw primary;
}
}
private Object reserve(String requestedOperationId) {
lock.lock();
try {
if (ownerToken != null) {
if (!requestedOperationId.equals(operationId)
|| !hasRecovery()) {
throw MigrationMaintenanceException.operationConflict();
}
recoverFailedAcquisition();
}
Object token = new Object();
ownerToken = token;
operationId = requestedOperationId;
return token;
} finally {
lock.unlock();
}
}
private boolean cleanupFailedAcquisition(
Throwable primary,
MetadataMaintenanceLease producerLease,
MigrationSourceLease sourceLease,
DeploymentSingletonLease authorityLease) {
boolean interrupted = Thread.currentThread().isInterrupted();
boolean producerReleased = producerCoordinator.snapshot().phase() == MetadataMaintenancePhase.RUNNING;
if (producerLease != null) {
producerReleased = suppressCleanup(primary, producerLease::resume);
}
boolean sourceReleased = sourceLease == null;
if (producerReleased && sourceLease != null) {
sourceReleased = suppressCleanup(primary, sourceLease::close);
}
boolean authorityReleased = authorityLease == null;
if (producerReleased && sourceReleased && authorityLease != null) {
authorityReleased = suppressCleanup(primary, authorityLease::close);
}
if (interrupted) {
Thread.currentThread().interrupt();
}
return producerReleased && sourceReleased && authorityReleased;
}
private boolean suppressCleanup(Throwable primary, Runnable cleanup) {
try {
cleanup.run();
return true;
} catch (RuntimeException exception) {
primary.addSuppressed(MigrationMaintenanceException.resumeFailure());
return false;
}
}
private void retainRecovery(
MetadataMaintenanceLease producerLease,
MigrationSourceLease sourceLease,
DeploymentSingletonLease authorityLease) {
lock.lock();
try {
recoveryProducerLease = producerLease;
recoveryProducerCoordinatorRequired = producerLease == null
&& producerCoordinator.snapshot().phase() != MetadataMaintenancePhase.RUNNING;
recoverySourceLease = sourceLease;
recoveryAuthorityLease = authorityLease;
} finally {
lock.unlock();
}
}
private void recoverFailedAcquisition() {
try {
if (recoveryProducerLease != null) {
recoveryProducerLease.resume();
recoveryProducerLease = null;
}
if (recoveryProducerCoordinatorRequired) {
producerCoordinator.recover(operationId);
recoveryProducerCoordinatorRequired = false;
}
if (recoverySourceLease != null) {
recoverySourceLease.close();
recoverySourceLease = null;
}
if (recoveryAuthorityLease != null) {
recoveryAuthorityLease.close();
recoveryAuthorityLease = null;
}
ownerToken = null;
operationId = null;
} catch (RuntimeException exception) {
throw MigrationMaintenanceException.resumeFailure();
}
}
private boolean hasRecovery() {
return recoveryProducerLease != null
|| recoveryProducerCoordinatorRequired
|| recoverySourceLease != null
|| recoveryAuthorityLease != null;
}
private MigrationMaintenanceException mapFailure(RuntimeException exception) {
if (exception instanceof MigrationMaintenanceException migrationFailure) {
return migrationFailure;
}
if (exception instanceof MetadataMaintenanceException maintenanceFailure) {
return switch (maintenanceFailure.code()) {
case INVALID_REQUEST -> MigrationMaintenanceException.invalidRequest();
case OPERATION_CONFLICT, STALE_LEASE -> MigrationMaintenanceException.operationConflict();
case QUIESCE_TIMEOUT -> MigrationMaintenanceException.timeout();
case QUIESCE_INTERRUPTED -> MigrationMaintenanceException.interrupted();
case PARTICIPANT_FAILURE, RESUME_FAILURE -> MigrationMaintenanceException.maintenanceFailure();
};
}
if (exception instanceof MetadataWriteAdmissionException writeFailure) {
MetadataWriteAdmissionErrorCode code = writeFailure.code();
return switch (code) {
case INVALID_REQUEST -> MigrationMaintenanceException.invalidRequest();
case OPERATION_CONFLICT, MAINTENANCE_ACTIVE -> MigrationMaintenanceException.operationConflict();
case DRAIN_TIMEOUT -> MigrationMaintenanceException.timeout();
case ACQUISITION_INTERRUPTED -> MigrationMaintenanceException.interrupted();
};
}
return MigrationMaintenanceException.maintenanceFailure();
}
private void releaseReservation(Object token) {
lock.lock();
try {
if (ownerToken == token) {
ownerToken = null;
operationId = null;
recoveryProducerLease = null;
recoveryProducerCoordinatorRequired = false;
recoverySourceLease = null;
recoveryAuthorityLease = null;
}
} finally {
lock.unlock();
}
}
private void requireRequest(String requestedOperationId) {
if (requestedOperationId == null || requestedOperationId.isBlank()) {
throw MigrationMaintenanceException.invalidRequest();
}
}
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.time.Duration;
/** Proves and fences single-manager ownership for one logical deployment. */
public interface DeploymentSingletonAuthority {
DeploymentSingletonLease acquire(String operationId, Duration timeout);
}
@@ -0,0 +1,15 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
/** Owner capability for one authoritative deployment-singleton fence. */
public interface DeploymentSingletonLease extends AutoCloseable {
@Override
void close();
}
@@ -0,0 +1,98 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.nio.file.Path;
import java.util.Locale;
/** Pure embedded-H2 source-safety classification. */
final class EmbeddedH2SourceClassifier {
private static final String H2_PREFIX = "jdbc:h2:";
private EmbeddedH2SourceClassifier() {
}
static boolean isSafeEmbeddedSource(String productName, String jdbcUrl) {
if (productName == null || !"h2".equals(productName.trim().toLowerCase(Locale.ROOT))
|| jdbcUrl == null) {
return false;
}
String url = jdbcUrl.trim().toLowerCase(Locale.ROOT);
if (!url.startsWith(H2_PREFIX) || hasUnsafeSetting(url)) {
return false;
}
String location = url.substring(H2_PREFIX.length());
if (location.startsWith("mem:")) {
return !isRemote(location.substring("mem:".length()));
}
String fileLocation = location.startsWith("file:")
? location.substring("file:".length())
: location;
return !isRemote(fileLocation) && (location.startsWith("file:")
|| location.startsWith("./")
|| location.startsWith("../")
|| location.startsWith(".\\")
|| location.startsWith("..\\")
|| location.startsWith("~/")
|| location.startsWith("/")
|| isWindowsDrive(location));
}
static boolean matchesConfiguredSource(String configuredUrl, String actualUrl) {
String configuredLocation = sourceLocation(configuredUrl);
String actualLocation = sourceLocation(actualUrl);
if (configuredLocation == null || actualLocation == null) {
return false;
}
if (configuredLocation.startsWith("mem:") || actualLocation.startsWith("mem:")) {
return configuredLocation.equals(actualLocation);
}
try {
return localPath(configuredLocation).equals(localPath(actualLocation));
} catch (RuntimeException exception) {
return false;
}
}
private static String sourceLocation(String jdbcUrl) {
if (jdbcUrl == null || !jdbcUrl.regionMatches(true, 0, H2_PREFIX, 0, H2_PREFIX.length())) {
return null;
}
String location = jdbcUrl.substring(H2_PREFIX.length()).split(";", 2)[0];
return location.regionMatches(true, 0, "file:", 0, "file:".length())
? location.substring("file:".length()) : location;
}
private static Path localPath(String location) {
String expanded = location.startsWith("~/") || location.startsWith("~\\")
? System.getProperty("user.home") + location.substring(1) : location;
return Path.of(expanded).toAbsolutePath().normalize();
}
private static boolean isRemote(String location) {
return location.contains("://") || location.startsWith("//") || location.startsWith("\\\\");
}
private static boolean isWindowsDrive(String location) {
return location.length() >= 3
&& Character.isLetter(location.charAt(0))
&& location.charAt(1) == ':'
&& (location.charAt(2) == '\\' || location.charAt(2) == '/');
}
private static boolean hasUnsafeSetting(String url) {
for (String setting : url.split(";")) {
String normalized = setting.strip();
if (normalized.startsWith("auto_server") || normalized.equals("file_lock=no")) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,101 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.sql.DataSource;
import org.apache.hertzbeat.common.runtime.ConditionalOnNormalBusinessRuntime;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.stereotype.Component;
/** Holds an embedded H2 connection after proving its configured local access mode is safe. */
@Component
@ConditionalOnNormalBusinessRuntime
public final class EmbeddedH2SourceGuard implements MigrationSourceGuard, DisposableBean {
private final DataSourceProperties dataSourceProperties;
private final DeadlineConnectionAcquirer connectionAcquirer;
public EmbeddedH2SourceGuard(DataSource dataSource, DataSourceProperties dataSourceProperties) {
this.dataSourceProperties = dataSourceProperties;
this.connectionAcquirer = new DeadlineConnectionAcquirer(dataSource);
}
@Override
public MigrationSourceLease fence(String operationId, Duration timeout) {
requireRequest(operationId, timeout);
String configuredUrl = dataSourceProperties.getUrl();
if (!EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", configuredUrl)) {
throw MigrationMaintenanceException.sourceUnavailable();
}
Connection connection = connectionAcquirer.acquire(timeout);
try {
DatabaseMetaData metadata = connection.getMetaData();
String productName = metadata.getDatabaseProductName();
String actualUrl = metadata.getURL();
if (!EmbeddedH2SourceClassifier.isSafeEmbeddedSource(productName, actualUrl)
|| !EmbeddedH2SourceClassifier.matchesConfiguredSource(configuredUrl, actualUrl)) {
closeRejectedConnection(connection);
throw MigrationMaintenanceException.sourceUnavailable();
}
return new ConnectionSourceLease(connection);
} catch (MigrationMaintenanceException exception) {
throw exception;
} catch (SQLException | RuntimeException exception) {
closeRejectedConnection(connection);
throw MigrationMaintenanceException.sourceUnavailable();
}
}
@Override
public void destroy() {
connectionAcquirer.close();
}
private void requireRequest(String operationId, Duration timeout) {
if (operationId == null || operationId.isBlank() || timeout == null || timeout.isNegative()) {
throw MigrationMaintenanceException.invalidRequest();
}
}
private void closeRejectedConnection(Connection connection) {
try {
connection.close();
} catch (SQLException | RuntimeException exception) {
// The stable source failure remains primary.
}
}
private static final class ConnectionSourceLease implements MigrationSourceLease {
private final Connection connection;
private final AtomicBoolean closed = new AtomicBoolean();
private ConnectionSourceLease(Connection connection) {
this.connection = connection;
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
try {
connection.close();
} catch (SQLException | RuntimeException exception) {
closed.set(false);
throw MigrationMaintenanceException.resumeFailure();
}
}
}
}
@@ -73,6 +73,23 @@ public final class MetadataMaintenanceCoordinator {
}
}
void recover(String requestedOperationId) {
requireOperationId(requestedOperationId);
lock.lock();
try {
if (phase != MetadataMaintenancePhase.RECOVERY_REQUIRED
|| !requestedOperationId.equals(operationId)) {
throw MetadataMaintenanceException.operationConflict();
}
if (!resumeAllParticipants()) {
throw MetadataMaintenanceException.resumeFailure();
}
reopen();
} finally {
lock.unlock();
}
}
void resume(String resumedOperationId, long resumedEpoch, Object resumedToken) {
lock.lock();
try {
@@ -0,0 +1,33 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Fail-closed fallbacks for maintenance facts that the runtime cannot prove. */
@Configuration(proxyBeanMethods = false)
public class MigrationGuardConfiguration {
@Bean
@ConditionalOnMissingBean(DeploymentSingletonAuthority.class)
DeploymentSingletonAuthority unavailableDeploymentSingletonAuthority() {
return (operationId, timeout) -> {
throw MigrationMaintenanceException.deploymentAuthorityUnavailable();
};
}
@Bean
@ConditionalOnMissingBean(MigrationSourceGuard.class)
MigrationSourceGuard unavailableMigrationSourceGuard() {
return (operationId, timeout) -> {
throw MigrationMaintenanceException.sourceUnavailable();
};
}
}
@@ -0,0 +1,21 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
/** Stable safe failure categories for migration maintenance acquisition and release. */
public enum MigrationMaintenanceErrorCode {
MIGRATION_DEPLOYMENT_AUTHORITY_UNAVAILABLE,
MIGRATION_SOURCE_UNAVAILABLE,
MIGRATION_MULTI_NODE_UNSUPPORTED,
MIGRATION_OPERATION_CONFLICT,
MIGRATION_MAINTENANCE_TIMEOUT,
MIGRATION_MAINTENANCE_INTERRUPTED,
MIGRATION_MAINTENANCE_FAILURE,
MIGRATION_RESUME_FAILURE,
INVALID_REQUEST
}
@@ -0,0 +1,77 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
/** Secret-free migration maintenance failure. */
public final class MigrationMaintenanceException extends RuntimeException {
private final MigrationMaintenanceErrorCode code;
private MigrationMaintenanceException(MigrationMaintenanceErrorCode code, String message) {
super(message);
this.code = code;
}
public MigrationMaintenanceErrorCode code() {
return code;
}
public String safeMessage() {
return getMessage();
}
public static MigrationMaintenanceException deploymentAuthorityUnavailable() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_DEPLOYMENT_AUTHORITY_UNAVAILABLE,
"Migration deployment authority is unavailable");
}
public static MigrationMaintenanceException sourceUnavailable() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE,
"Migration metadata source is unavailable");
}
public static MigrationMaintenanceException multiNodeUnsupported() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_MULTI_NODE_UNSUPPORTED,
"Multi-node metadata migration is unsupported");
}
static MigrationMaintenanceException operationConflict() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT,
"Migration maintenance operation is already active");
}
static MigrationMaintenanceException timeout() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_TIMEOUT,
"Migration maintenance acquisition timed out");
}
static MigrationMaintenanceException interrupted() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_INTERRUPTED,
"Migration maintenance acquisition was interrupted");
}
static MigrationMaintenanceException maintenanceFailure() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_FAILURE,
"Migration maintenance acquisition failed");
}
static MigrationMaintenanceException resumeFailure() {
return failure(MigrationMaintenanceErrorCode.MIGRATION_RESUME_FAILURE,
"Migration maintenance release failed");
}
static MigrationMaintenanceException invalidRequest() {
return failure(MigrationMaintenanceErrorCode.INVALID_REQUEST,
"Migration maintenance request is invalid");
}
private static MigrationMaintenanceException failure(
MigrationMaintenanceErrorCode code, String message) {
return new MigrationMaintenanceException(code, message);
}
}
@@ -0,0 +1,15 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
/** Owner capability for one fully acquired migration maintenance window. */
public interface MigrationMaintenanceLease extends AutoCloseable {
@Override
void close();
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.time.Duration;
/** Acquires the complete process-local maintenance window required before metadata migration. */
public interface MigrationMaintenanceOrchestrator {
MigrationMaintenanceLease acquire(String operationId, Duration timeout);
}
@@ -0,0 +1,16 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import java.time.Duration;
/** Fences a metadata source whose local access mode is safe for migration. */
public interface MigrationSourceGuard {
MigrationSourceLease fence(String operationId, Duration timeout);
}
@@ -0,0 +1,15 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
/** Capability that releases one safe metadata-source fence. */
public interface MigrationSourceLease extends AutoCloseable {
@Override
void close();
}
@@ -0,0 +1,355 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.common.transaction.MetadataWriteAdmissionCoordinator;
import org.apache.hertzbeat.common.transaction.MetadataWriteMaintenanceLease;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
class DefaultMigrationMaintenanceOrchestratorTest {
@Test
void acquiresForwardWithOneDeadlineAndReleasesInReverse() {
Harness harness = harness();
AtomicLong ticker = new AtomicLong(10);
DefaultMigrationMaintenanceOrchestrator orchestrator = new DefaultMigrationMaintenanceOrchestrator(
harness.deploymentAuthority, harness.sourceGuard,
harness.producerCoordinator, harness.writeCoordinator, ticker::get);
when(harness.deploymentAuthority.acquire(eq("operation-a"), any())).thenAnswer(invocation -> {
assertThat((Duration) invocation.getArgument(1)).isEqualTo(Duration.ofNanos(100));
ticker.addAndGet(10);
return harness.authorityLease;
});
when(harness.sourceGuard.fence(eq("operation-a"), any())).thenAnswer(invocation -> {
assertThat((Duration) invocation.getArgument(1)).isEqualTo(Duration.ofNanos(90));
ticker.addAndGet(20);
return harness.sourceLease;
});
when(harness.producerCoordinator.quiesce(eq("operation-a"), any())).thenAnswer(invocation -> {
assertThat((Duration) invocation.getArgument(1)).isEqualTo(Duration.ofNanos(70));
ticker.addAndGet(30);
return harness.producerLease;
});
when(harness.writeCoordinator.acquire(eq("operation-a"), any())).thenAnswer(invocation -> {
assertThat((Duration) invocation.getArgument(1)).isEqualTo(Duration.ofNanos(40));
return harness.writeLease;
});
MigrationMaintenanceLease lease = orchestrator.acquire("operation-a", Duration.ofNanos(100));
lease.close();
InOrder order = inOrder(harness.deploymentAuthority, harness.sourceGuard, harness.producerCoordinator,
harness.writeCoordinator, harness.writeLease, harness.producerLease,
harness.sourceLease, harness.authorityLease);
order.verify(harness.deploymentAuthority).acquire(eq("operation-a"), any());
order.verify(harness.sourceGuard).fence(eq("operation-a"), any());
order.verify(harness.producerCoordinator).quiesce(eq("operation-a"), any());
order.verify(harness.writeCoordinator).acquire(eq("operation-a"), any());
order.verify(harness.writeLease).close();
order.verify(harness.producerLease).resume();
order.verify(harness.sourceLease).close();
order.verify(harness.authorityLease).close();
}
@Test
void deploymentAuthorityUnknownOrMultiFailsBeforeSourceOrLocalPause() {
Harness unknown = harness();
when(unknown.deploymentAuthority.acquire(eq("operation-a"), any()))
.thenThrow(MigrationMaintenanceException.deploymentAuthorityUnavailable());
DefaultMigrationMaintenanceOrchestrator unknownOrchestrator = unknown.orchestrator();
assertThatThrownBy(() -> unknownOrchestrator.acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_DEPLOYMENT_AUTHORITY_UNAVAILABLE));
verify(unknown.sourceGuard, never()).fence(any(), any());
verify(unknown.producerCoordinator, never()).quiesce(any(), any());
verify(unknown.writeCoordinator, never()).acquire(any(), any());
Harness multi = harness();
when(multi.deploymentAuthority.acquire(eq("operation-a"), any()))
.thenThrow(MigrationMaintenanceException.multiNodeUnsupported());
assertThatThrownBy(() -> multi.orchestrator().acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MULTI_NODE_UNSUPPORTED));
verify(multi.producerCoordinator, never()).quiesce(any(), any());
}
@Test
void secondAndThirdStepFailuresCleanUpInReverseWithoutReplacingPrimary() {
Harness second = harness();
when(second.sourceGuard.fence(eq("operation-a"), any())).thenReturn(second.sourceLease);
when(second.producerCoordinator.quiesce(eq("operation-a"), any()))
.thenThrow(MetadataMaintenanceException.quiesceTimeout());
Mockito.doThrow(new IllegalStateException("private-cleanup")).when(second.sourceLease).close();
assertThatThrownBy(() -> second.orchestrator().acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception -> {
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_TIMEOUT);
assertThat(exception.getSuppressed()).hasSize(1);
assertThat(exception.getMessage()).doesNotContain("private");
});
Harness third = harness();
when(third.sourceGuard.fence(eq("operation-a"), any())).thenReturn(third.sourceLease);
when(third.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(third.producerLease);
when(third.writeCoordinator.acquire(eq("operation-a"), any()))
.thenThrow(new IllegalStateException("private-write"));
assertThatThrownBy(() -> third.orchestrator().acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_FAILURE));
InOrder cleanup = inOrder(third.producerLease, third.sourceLease, third.authorityLease);
cleanup.verify(third.producerLease).resume();
cleanup.verify(third.sourceLease).close();
cleanup.verify(third.authorityLease).close();
}
@Test
void failedAcquisitionRecoveryRetainsFenceAndOwnershipUntilSameOperationRetries() {
Harness harness = harness();
when(harness.sourceGuard.fence(eq("operation-a"), any()))
.thenReturn(harness.sourceLease)
.thenThrow(MigrationMaintenanceException.sourceUnavailable());
when(harness.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(harness.producerLease);
when(harness.writeCoordinator.acquire(eq("operation-a"), any()))
.thenThrow(new IllegalStateException("private-write"));
Mockito.doThrow(new IllegalStateException("private-resume"))
.doNothing().when(harness.producerLease).resume();
DefaultMigrationMaintenanceOrchestrator orchestrator = harness.orchestrator();
assertThatThrownBy(() -> orchestrator.acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception -> {
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_FAILURE);
assertThat(exception.getSuppressed()).hasSize(1);
});
verify(harness.sourceLease, never()).close();
verify(harness.authorityLease, never()).close();
assertThatThrownBy(() -> orchestrator.acquire("operation-b", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT));
assertThatThrownBy(() -> orchestrator.acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE));
verify(harness.producerLease, Mockito.times(2)).resume();
verify(harness.sourceLease).close();
verify(harness.authorityLease, Mockito.times(2)).close();
}
@Test
void producerRecoveryWithoutLeaseRetainsFenceUntilSameOperationRecoversCoordinator() {
Harness harness = harness();
when(harness.sourceGuard.fence(eq("operation-a"), any()))
.thenReturn(harness.sourceLease)
.thenThrow(MigrationMaintenanceException.sourceUnavailable());
when(harness.producerCoordinator.quiesce(eq("operation-a"), any()))
.thenThrow(MetadataMaintenanceException.participantFailure());
AtomicReference<MetadataMaintenanceSnapshot> producerState = new AtomicReference<>(
new MetadataMaintenanceSnapshot(MetadataMaintenancePhase.RECOVERY_REQUIRED, "operation-a", 1));
when(harness.producerCoordinator.snapshot()).thenAnswer(invocation -> producerState.get());
Mockito.doAnswer(invocation -> {
producerState.set(new MetadataMaintenanceSnapshot(MetadataMaintenancePhase.RUNNING, null, 1));
return null;
}).when(harness.producerCoordinator).recover("operation-a");
DefaultMigrationMaintenanceOrchestrator orchestrator = harness.orchestrator();
assertThatThrownBy(() -> orchestrator.acquire("operation-a", Duration.ofSeconds(1)))
.isInstanceOf(MigrationMaintenanceException.class);
verify(harness.sourceLease, never()).close();
assertThatThrownBy(() -> orchestrator.acquire("operation-b", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT));
assertThatThrownBy(() -> orchestrator.acquire("operation-a", Duration.ofSeconds(1)))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE));
verify(harness.producerCoordinator).recover("operation-a");
verify(harness.sourceLease).close();
verify(harness.authorityLease, Mockito.times(2)).close();
}
@Test
void resumeFailureRetainsTopologyAndSameOwnerCanRetry() {
Harness harness = harness();
when(harness.sourceGuard.fence(eq("operation-a"), any())).thenReturn(harness.sourceLease);
when(harness.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(harness.producerLease);
when(harness.writeCoordinator.acquire(eq("operation-a"), any())).thenReturn(harness.writeLease);
Mockito.doThrow(new IllegalStateException()).doNothing().when(harness.producerLease).resume();
MigrationMaintenanceLease lease = harness.orchestrator().acquire("operation-a", Duration.ZERO);
assertThatThrownBy(lease::close)
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_RESUME_FAILURE));
verify(harness.sourceLease, never()).close();
verify(harness.authorityLease, never()).close();
lease.close();
verify(harness.writeLease).close();
verify(harness.producerLease, Mockito.times(2)).resume();
verify(harness.sourceLease).close();
verify(harness.authorityLease).close();
}
@Test
void concurrentAcquisitionHasOnlyOneOwnerCapability() throws Exception {
Harness harness = harness();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(harness.sourceGuard.fence(eq("operation-a"), any())).thenAnswer(invocation -> {
entered.countDown();
release.await();
return harness.sourceLease;
});
when(harness.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(harness.producerLease);
when(harness.writeCoordinator.acquire(eq("operation-a"), any())).thenReturn(harness.writeLease);
DefaultMigrationMaintenanceOrchestrator orchestrator = harness.orchestrator();
AtomicReference<MigrationMaintenanceLease> owner = new AtomicReference<>();
Thread thread = Thread.ofPlatform().start(() ->
owner.set(orchestrator.acquire("operation-a", Duration.ofSeconds(30))));
assertThat(entered.await(1, TimeUnit.SECONDS)).isTrue();
assertThatThrownBy(() -> orchestrator.acquire("operation-b", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT));
assertThatThrownBy(() -> orchestrator.acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT));
release.countDown();
thread.join(1_000);
owner.get().close();
}
@Test
void sharedAuthorityNotSeparateEmbeddedSourcesDeterminesSingletonOwnership() {
Harness first = harness();
Harness second = harness();
FakeSingletonAuthority authority = new FakeSingletonAuthority();
when(first.sourceGuard.fence(eq("operation-a"), any())).thenReturn(first.sourceLease);
when(first.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(first.producerLease);
when(first.writeCoordinator.acquire(eq("operation-a"), any())).thenReturn(first.writeLease);
when(second.sourceGuard.fence(eq("operation-b"), any())).thenReturn(second.sourceLease);
when(second.producerCoordinator.quiesce(eq("operation-b"), any())).thenReturn(second.producerLease);
when(second.writeCoordinator.acquire(eq("operation-b"), any())).thenReturn(second.writeLease);
DefaultMigrationMaintenanceOrchestrator firstOrchestrator = new DefaultMigrationMaintenanceOrchestrator(
authority, first.sourceGuard, first.producerCoordinator, first.writeCoordinator);
DefaultMigrationMaintenanceOrchestrator secondOrchestrator = new DefaultMigrationMaintenanceOrchestrator(
authority, second.sourceGuard, second.producerCoordinator, second.writeCoordinator);
MigrationMaintenanceLease firstLease = firstOrchestrator.acquire("operation-a", Duration.ZERO);
assertThatThrownBy(() -> secondOrchestrator.acquire("operation-b", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_OPERATION_CONFLICT));
verify(second.sourceGuard, never()).fence(any(), any());
firstLease.close();
secondOrchestrator.acquire("operation-b", Duration.ZERO).close();
}
@Test
void validatesNegativeAndOverflowButAllowsZeroAndNewInstanceStartsIdle() {
Harness first = harness();
when(first.sourceGuard.fence(eq("operation-a"), any())).thenReturn(first.sourceLease);
when(first.producerCoordinator.quiesce(eq("operation-a"), any())).thenReturn(first.producerLease);
when(first.writeCoordinator.acquire(eq("operation-a"), any())).thenReturn(first.writeLease);
MigrationMaintenanceLease lease = first.orchestrator().acquire("operation-a", Duration.ZERO);
assertThatThrownBy(() -> first.orchestrator().acquire("operation-b", Duration.ofSeconds(-1)))
.isInstanceOf(MigrationMaintenanceException.class);
assertThatThrownBy(() -> first.orchestrator().acquire("operation-b", Duration.ofSeconds(Long.MAX_VALUE)))
.isInstanceOf(MigrationMaintenanceException.class);
lease.close();
Harness restarted = harness();
when(restarted.sourceGuard.fence(eq("operation-b"), any())).thenReturn(restarted.sourceLease);
when(restarted.producerCoordinator.quiesce(eq("operation-b"), any())).thenReturn(restarted.producerLease);
when(restarted.writeCoordinator.acquire(eq("operation-b"), any())).thenReturn(restarted.writeLease);
restarted.orchestrator().acquire("operation-b", Duration.ZERO).close();
}
@Test
void interruptClassificationKeepsInterruptBit() {
Harness harness = harness();
when(harness.deploymentAuthority.acquire(eq("operation-a"), any())).thenAnswer(invocation -> {
Thread.currentThread().interrupt();
throw MigrationMaintenanceException.interrupted();
});
assertThatThrownBy(() -> harness.orchestrator().acquire("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_INTERRUPTED));
assertThat(Thread.interrupted()).isTrue();
}
private static Harness harness() {
Harness harness = new Harness(
Mockito.mock(DeploymentSingletonAuthority.class),
Mockito.mock(DeploymentSingletonLease.class),
Mockito.mock(MigrationSourceGuard.class),
Mockito.mock(MigrationSourceLease.class),
Mockito.mock(MetadataMaintenanceCoordinator.class),
Mockito.mock(MetadataMaintenanceLease.class),
Mockito.mock(MetadataWriteAdmissionCoordinator.class),
Mockito.mock(MetadataWriteMaintenanceLease.class));
when(harness.deploymentAuthority.acquire(any(), any())).thenReturn(harness.authorityLease);
when(harness.producerCoordinator.snapshot()).thenReturn(
new MetadataMaintenanceSnapshot(MetadataMaintenancePhase.RUNNING, null, 0));
return harness;
}
private record Harness(
DeploymentSingletonAuthority deploymentAuthority,
DeploymentSingletonLease authorityLease,
MigrationSourceGuard sourceGuard,
MigrationSourceLease sourceLease,
MetadataMaintenanceCoordinator producerCoordinator,
MetadataMaintenanceLease producerLease,
MetadataWriteAdmissionCoordinator writeCoordinator,
MetadataWriteMaintenanceLease writeLease) {
DefaultMigrationMaintenanceOrchestrator orchestrator() {
return new DefaultMigrationMaintenanceOrchestrator(
deploymentAuthority, sourceGuard, producerCoordinator, writeCoordinator);
}
}
private static final class FakeSingletonAuthority implements DeploymentSingletonAuthority {
private boolean owned;
@Override
public synchronized DeploymentSingletonLease acquire(String operationId, Duration timeout) {
if (owned) {
throw MigrationMaintenanceException.operationConflict();
}
owned = true;
return this::release;
}
private synchronized void release() {
owned = false;
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class EmbeddedH2SourceClassifierTest {
@Test
void acceptsOnlyExplicitEmbeddedMemoryAndLocalFileUrls() {
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:mem:manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:file:/var/lib/manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:./data/manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:.\\data\\manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:..\\data\\manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:C:\\data\\manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:tcp://db/manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:ssl://db/manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:file:tcp://db/manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:file:////db/manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:h2:\\\\db\\manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource(
"H2", "jdbc:h2:file:/var/lib/manager;AUTO_SERVER=TRUE")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource(
"H2", "jdbc:h2:file:/var/lib/manager;FILE_LOCK=NO")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("PostgreSQL", "jdbc:h2:mem:manager")).isFalse();
assertThat(EmbeddedH2SourceClassifier.isSafeEmbeddedSource("H2", "jdbc:unknown:manager")).isFalse();
}
@Test
void matchesConfiguredSourceToActualMetadataLocation() {
assertThat(EmbeddedH2SourceClassifier.matchesConfiguredSource(
"jdbc:h2:mem:manager;MODE=MYSQL", "jdbc:h2:mem:manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.matchesConfiguredSource(
"jdbc:h2:./data/manager;MODE=MYSQL", "jdbc:h2:file:./data/manager")).isTrue();
assertThat(EmbeddedH2SourceClassifier.matchesConfiguredSource(
"jdbc:h2:mem:manager", "jdbc:h2:mem:other")).isFalse();
assertThat(EmbeddedH2SourceClassifier.matchesConfiguredSource(
"jdbc:h2:file:/safe/manager", "jdbc:h2:file:/other/manager")).isFalse();
}
}
@@ -0,0 +1,232 @@
/*
* 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.
*/
package org.apache.hertzbeat.manager.maintenance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
class EmbeddedH2SourceGuardTest {
@Test
void holdsSafeSourceConnectionUntilLeaseClosesExactlyOnce() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
Connection connection = Mockito.mock(Connection.class);
DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getMetaData()).thenReturn(metadata);
when(metadata.getDatabaseProductName()).thenReturn("H2");
when(metadata.getURL()).thenReturn("jdbc:h2:mem:manager");
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
MigrationSourceLease lease = guard.fence("operation-a", Duration.ofSeconds(1));
verify(connection, never()).close();
lease.close();
lease.close();
verify(connection).close();
guard.destroy();
}
@Test
void rejectsAmbiguousSourceAndClosesConnectionWithoutDetails() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
Connection connection = Mockito.mock(Connection.class);
DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getMetaData()).thenReturn(metadata);
when(metadata.getDatabaseProductName()).thenReturn("H2");
when(metadata.getURL()).thenReturn("jdbc:h2:tcp://private-host/secret");
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
assertThatThrownBy(() -> guard.fence("operation-a", Duration.ofSeconds(1)))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception -> {
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE);
assertThat(exception.safeMessage()).doesNotContain("private-host").doesNotContain("secret");
assertThat(exception.getCause()).isNull();
});
verify(connection).close();
guard.destroy();
}
@Test
void rejectsRawAutoServerAndFileLockSettingsThatMetadataRemoves(@TempDir Path directory) throws Exception {
String baseUrl = "jdbc:h2:file:" + directory.resolve("manager");
EmbeddedH2SourceGuard safeGuard = guard(new DriverManagerDataSource(baseUrl), baseUrl);
safeGuard.fence("safe-source", Duration.ofSeconds(1)).close();
safeGuard.destroy();
for (String setting : new String[] {";AUTO_SERVER=TRUE", ";FILE_LOCK=NO"}) {
String configuredUrl = baseUrl + setting;
try (Connection connection = DriverManager.getConnection(configuredUrl)) {
assertThat(connection.getMetaData().getURL()).doesNotContain(setting.substring(1));
}
DriverManagerDataSource dataSource = new DriverManagerDataSource(configuredUrl);
assertThatThrownBy(() -> guard(dataSource, configuredUrl)
.fence("operation-a", Duration.ofSeconds(1)))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE));
}
}
@Test
void timeoutAbandonsBlockingConnectionAndClosesLateResult() throws Exception {
assertTimedOutConnectionClosed(Duration.ZERO);
assertTimedOutConnectionClosed(Duration.ofMillis(20));
}
@Test
void rejectsOverflowDurationWithoutStartingConnectionWork() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
assertThatThrownBy(() -> guard.fence("operation-a", Duration.ofSeconds(Long.MAX_VALUE)))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.INVALID_REQUEST));
verify(dataSource, never()).getConnection();
guard.destroy();
}
@Test
void destroyRejectsNewAcquisitionWithoutStartingConnectionWork() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
guard.destroy();
assertThatThrownBy(() -> guard.fence("operation-a", Duration.ofSeconds(1)))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_SOURCE_UNAVAILABLE));
verify(dataSource, never()).getConnection();
}
private void assertTimedOutConnectionClosed(Duration timeout) throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
Connection connection = Mockito.mock(Connection.class);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch closed = new CountDownLatch(1);
when(dataSource.getConnection()).thenAnswer(invocation -> {
entered.countDown();
release.await();
return connection;
});
Mockito.doAnswer(invocation -> {
closed.countDown();
return null;
}).when(connection).close();
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
assertThatThrownBy(() -> guard.fence("operation-a", timeout))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_TIMEOUT));
assertThat(entered.await(1, TimeUnit.SECONDS)).isTrue();
release.countDown();
assertThat(closed.await(1, TimeUnit.SECONDS)).isTrue();
guard.destroy();
}
@Test
void interruptAbandonsBlockingConnectionAndPreservesInterrupt() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
Connection connection = Mockito.mock(Connection.class);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch closed = new CountDownLatch(1);
when(dataSource.getConnection()).thenAnswer(invocation -> {
entered.countDown();
release.await();
return connection;
});
Mockito.doAnswer(invocation -> {
closed.countDown();
return null;
}).when(connection).close();
AtomicReference<MigrationMaintenanceErrorCode> code = new AtomicReference<>();
AtomicBoolean interrupted = new AtomicBoolean();
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
Thread caller = Thread.ofPlatform().start(() -> {
try {
guard.fence("operation-a", Duration.ofSeconds(30));
} catch (MigrationMaintenanceException exception) {
code.set(exception.code());
interrupted.set(Thread.currentThread().isInterrupted());
}
});
assertThat(entered.await(1, TimeUnit.SECONDS)).isTrue();
caller.interrupt();
caller.join(1_000);
assertThat(code.get()).isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_INTERRUPTED);
assertThat(interrupted).isTrue();
release.countDown();
assertThat(closed.await(1, TimeUnit.SECONDS)).isTrue();
guard.destroy();
}
@Test
void oneStuckDriverCallBoundsAllLaterAcquisitionWork() throws Exception {
DataSource dataSource = Mockito.mock(DataSource.class);
Connection connection = Mockito.mock(Connection.class);
AtomicInteger calls = new AtomicInteger();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch closed = new CountDownLatch(1);
when(dataSource.getConnection()).thenAnswer(invocation -> {
calls.incrementAndGet();
entered.countDown();
release.await();
return connection;
});
Mockito.doAnswer(invocation -> {
closed.countDown();
return null;
}).when(connection).close();
EmbeddedH2SourceGuard guard = guard(dataSource, "jdbc:h2:mem:manager");
assertThatThrownBy(() -> guard.fence("operation-a", Duration.ZERO))
.isInstanceOf(MigrationMaintenanceException.class);
assertThat(entered.await(1, TimeUnit.SECONDS)).isTrue();
for (int index = 0; index < 20; index++) {
assertThatThrownBy(() -> guard.fence("operation-a", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_MAINTENANCE_TIMEOUT));
}
assertThat(calls).hasValue(1);
release.countDown();
assertThat(closed.await(1, TimeUnit.SECONDS)).isTrue();
guard.destroy();
}
private static EmbeddedH2SourceGuard guard(DataSource dataSource, String configuredUrl) {
DataSourceProperties properties = new DataSourceProperties();
properties.setUrl(configuredUrl);
return new EmbeddedH2SourceGuard(dataSource, properties);
}
}
@@ -15,6 +15,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
@@ -199,7 +200,8 @@ class MetadataMaintenanceCoordinatorTest {
}
@Test
void failedParticipantRecoveryCannotBeReportedAsRunning() {
void failedParticipantRecoveryRequiresSameOperationAndCanBeRetriedExplicitly() {
AtomicBoolean resumeFails = new AtomicBoolean(true);
MetadataMaintenanceParticipant participant = new MetadataMaintenanceParticipant() {
@Override
public String participantId() {
@@ -213,7 +215,9 @@ class MetadataMaintenanceCoordinatorTest {
@Override
public void resume() {
throw MetadataMaintenanceException.resumeFailure();
if (resumeFails.get()) {
throw MetadataMaintenanceException.resumeFailure();
}
}
};
MetadataMaintenanceCoordinator coordinator = new MetadataMaintenanceCoordinator(List.of(participant));
@@ -223,6 +227,12 @@ class MetadataMaintenanceCoordinatorTest {
assertThat(exception.code()).isEqualTo(MetadataMaintenanceErrorCode.QUIESCE_TIMEOUT));
assertThat(coordinator.snapshot().phase()).isEqualTo(MetadataMaintenancePhase.RECOVERY_REQUIRED);
assertThatThrownBy(() -> coordinator.recover("operation-b"))
.isInstanceOfSatisfying(MetadataMaintenanceException.class, exception ->
assertThat(exception.code()).isEqualTo(MetadataMaintenanceErrorCode.OPERATION_CONFLICT));
resumeFails.set(false);
coordinator.recover("operation-a");
assertThat(coordinator.snapshot().phase()).isEqualTo(MetadataMaintenancePhase.RUNNING);
}
@Test
@@ -8,8 +8,10 @@
package org.apache.hertzbeat.startup;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import org.apache.hertzbeat.common.transaction.MetadataWriteAdmissionAdvisor;
@@ -21,6 +23,12 @@ import org.apache.hertzbeat.manager.maintenance.MetadataMaintenanceCoordinator;
import org.apache.hertzbeat.manager.maintenance.MetadataMaintenanceParticipant;
import org.apache.hertzbeat.manager.maintenance.AlertMetadataMaintenanceParticipant;
import org.apache.hertzbeat.manager.maintenance.CollectorLifecycleMaintenanceParticipant;
import org.apache.hertzbeat.manager.maintenance.DeploymentSingletonAuthority;
import org.apache.hertzbeat.manager.maintenance.EmbeddedH2SourceGuard;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceOrchestrator;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceErrorCode;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceException;
import org.apache.hertzbeat.manager.maintenance.MigrationSourceGuard;
import org.apache.hertzbeat.manager.setup.runtime.SetupRuntimeTransition;
import org.apache.hertzbeat.warehouse.store.DataStorageDispatch;
import org.apache.hertzbeat.warehouse.store.metadata.JdbcMonitorStatusMetadataWriter;
@@ -52,6 +60,17 @@ class MetadataWriteAdmissionStartupContextTest {
@Test
void startupHasOneAdmissionBoundaryAndOneTransactionSource() throws Exception {
assertThat(context.getBeansOfType(MetadataMaintenanceCoordinator.class)).hasSize(1);
assertThat(context.getBeansOfType(MigrationMaintenanceOrchestrator.class)).hasSize(1);
assertThat(context.getBeansOfType(MigrationSourceGuard.class))
.containsOnlyKeys("embeddedH2SourceGuard");
assertThat(context.getBean(MigrationSourceGuard.class)).isInstanceOf(EmbeddedH2SourceGuard.class);
assertThat(context.getBeansOfType(DeploymentSingletonAuthority.class))
.containsOnlyKeys("unavailableDeploymentSingletonAuthority");
assertThatThrownBy(() -> context.getBean(MigrationMaintenanceOrchestrator.class)
.acquire("normal-proof", Duration.ZERO))
.isInstanceOfSatisfying(MigrationMaintenanceException.class, exception ->
assertThat(exception.code())
.isEqualTo(MigrationMaintenanceErrorCode.MIGRATION_DEPLOYMENT_AUTHORITY_UNAVAILABLE));
List<MetadataMaintenanceParticipant> participants = context.getBeanProvider(
MetadataMaintenanceParticipant.class).orderedStream().toList();
assertThat(participants)
@@ -30,10 +30,14 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import org.apache.hertzbeat.common.runtime.BusinessRuntimeGate;
import org.apache.hertzbeat.common.runtime.RuntimeMode;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.manager.setup.api.SetupApiContract.SetupPhase;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceErrorCode;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceException;
import org.apache.hertzbeat.manager.maintenance.MigrationMaintenanceOrchestrator;
import org.apache.hertzbeat.manager.setup.api.SetupApiContract.SetupErrorCode;
import org.apache.hertzbeat.manager.setup.api.SetupApiContract.SetupOperationState;
import org.apache.hertzbeat.manager.setup.runtime.SetupRuntimeTransition;
@@ -167,6 +171,15 @@ class StartupRuntimeBoundaryContextTest {
assertTrue(context.containsBeanDefinition("periodicAlertRuleScheduler"));
assertTrue(context.containsBeanDefinition("manageServer"));
assertFalse(context.containsBeanDefinition("collectorLifecycleMaintenanceParticipant"));
MigrationMaintenanceOrchestrator orchestrator =
context.getBean(MigrationMaintenanceOrchestrator.class);
try {
orchestrator.acquire("gated-proof", Duration.ZERO);
org.junit.jupiter.api.Assertions.fail("Gated runtime must fail closed");
} catch (MigrationMaintenanceException exception) {
assertEquals(MigrationMaintenanceErrorCode.MIGRATION_DEPLOYMENT_AUTHORITY_UNAVAILABLE,
exception.code());
}
assertTrue(context.containsBeanDefinition("otlpGrpcMetricsService"));
assertTrue(context.containsBeanDefinition("alarmGroupReduce"));
assertTrue(context.containsBeanDefinition("alarmInhibitReduce"));