Задача Егора 2ч.
This commit is contained in:
@@ -3,6 +3,7 @@ package com.magistr.app.config.tenant;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantLifecycleException;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -10,24 +11,35 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Периодически перечитывает tenants.json (mounted Secret).
|
||||
* Если Secret был обновлён через K8s API, этот компонент
|
||||
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
||||
*
|
||||
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
||||
* Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
|
||||
* Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
|
||||
*/
|
||||
@Component
|
||||
public class TenantConfigWatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||
private static final String MISSING_FAILURE_KEY = "missing";
|
||||
private static final String READ_FAILURE_KEY = "read";
|
||||
|
||||
static final long RETRY_BASE_DELAY_MILLIS = 30_000L;
|
||||
static final long RETRY_MAX_DELAY_MILLIS = 300_000L;
|
||||
|
||||
private final TenantLifecycleService tenantLifecycleService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
@@ -39,9 +51,23 @@ public class TenantConfigWatcher {
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||
/** Хеш означает только полностью применённое содержимое файла. */
|
||||
private String lastConfigHash = "";
|
||||
private ConfigSnapshot lastAppliedSnapshot;
|
||||
|
||||
private String failedRevisionKey = "";
|
||||
private int consecutiveFailures;
|
||||
private long retryNotBeforeMillis;
|
||||
|
||||
/**
|
||||
* Fence хранит семантические снимки, а не байтовые представления JSON.
|
||||
* Поэтому форматирование файла не может ошибочно снять или установить fence.
|
||||
*/
|
||||
private ConfigSnapshot expectedCommittedSnapshot;
|
||||
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
|
||||
|
||||
private boolean configurationUnavailable;
|
||||
private LongSupplier currentTimeMillis = System::currentTimeMillis;
|
||||
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
@@ -49,73 +75,135 @@ public class TenantConfigWatcher {
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||
public void watchForChanges() {
|
||||
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
|
||||
}
|
||||
|
||||
private void watchForChangesSerialized() {
|
||||
long now = currentTimeMillis.getAsLong();
|
||||
Path path = Path.of(tenantsConfigPath);
|
||||
if (!Files.exists(path)) {
|
||||
if (tenantsConfigRequired) {
|
||||
handleRequiredMissingFile(now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRetryDeferred(READ_FAILURE_KEY, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
|
||||
String failureKey = READ_FAILURE_KEY;
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) {
|
||||
if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
if (!configurationUnavailable) {
|
||||
log.error("Обязательный файл конфигурации тенантов недоступен");
|
||||
}
|
||||
configurationUnavailable = true;
|
||||
}
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
failureKey = hash;
|
||||
Projection projection = parseProjection(content, hash);
|
||||
|
||||
// Даже неизменившееся содержимое сначала разбирается: fence никогда не
|
||||
// принимает решение по сырым байтам до проверки структуры JSON.
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||
return;
|
||||
}
|
||||
if (isRetryDeferred(hash, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
if (!hash.equals(failedRevisionKey)) {
|
||||
resetRetryState();
|
||||
}
|
||||
if (shouldDefer(projection.snapshot())) {
|
||||
return;
|
||||
}
|
||||
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
return; // Ничего не изменилось
|
||||
}
|
||||
|
||||
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||
syncTenants(newTenants);
|
||||
lastConfigHash = hash;
|
||||
configurationUnavailable = false;
|
||||
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
||||
log.debug("Технические детали синхронизации конфига тенантов", e);
|
||||
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
|
||||
syncTenants(projection.tenants());
|
||||
markApplied(projection);
|
||||
} catch (Exception failure) {
|
||||
recordFailure(failureKey, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет хеш конфига после ручного обновления Secret с этого же пода.
|
||||
* Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
|
||||
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
|
||||
* с неизвестного baseline.
|
||||
*/
|
||||
public void refreshHash() {
|
||||
tenantLifecycleService.executeSerialized(this::refreshHashSerialized);
|
||||
public void prepareForApiMutation() {
|
||||
tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
|
||||
}
|
||||
|
||||
private void refreshHashSerialized() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (file.exists()) {
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
lastConfigHash = configHash(content);
|
||||
} else if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
private void prepareForApiMutationSerialized() {
|
||||
Path path = Path.of(tenantsConfigPath);
|
||||
if (!Files.exists(path)) {
|
||||
if (!tenantsConfigRequired) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали обновления хеша тенантов", e);
|
||||
TenantLifecycleException failure = new TenantLifecycleException(
|
||||
"Обязательный файл конфигурации тенантов недоступен"
|
||||
);
|
||||
recordFailure(MISSING_FAILURE_KEY, failure);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
String failureKey = READ_FAILURE_KEY;
|
||||
try {
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
failureKey = hash;
|
||||
Projection projection = parseProjection(content, hash);
|
||||
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||
return;
|
||||
}
|
||||
if (shouldDefer(projection.snapshot())) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncTenants(projection.tenants());
|
||||
markApplied(projection);
|
||||
} catch (Exception failure) {
|
||||
recordFailure(failureKey, failure);
|
||||
if (failure instanceof TenantLifecycleException lifecycleFailure) {
|
||||
throw lifecycleFailure;
|
||||
}
|
||||
throw new TenantLifecycleException(
|
||||
"Не удалось подготовить актуальную конфигурацию тенантов перед изменением",
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает snapshot fence только после подтверждённой записи в общее
|
||||
* хранилище. Локальные и no-op операции не создают ложных ожиданий от mount.
|
||||
*/
|
||||
public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) {
|
||||
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||
tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt));
|
||||
}
|
||||
|
||||
private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) {
|
||||
if (!receipt.persisted() || !receipt.changed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigSnapshot previous = snapshotOf(receipt.previousTenants());
|
||||
ConfigSnapshot committed = snapshotOf(receipt.committedTenants());
|
||||
|
||||
if (lastAppliedSnapshot != null) {
|
||||
deferredSnapshots.add(lastAppliedSnapshot);
|
||||
}
|
||||
if (expectedCommittedSnapshot != null) {
|
||||
deferredSnapshots.add(expectedCommittedSnapshot);
|
||||
}
|
||||
deferredSnapshots.add(previous);
|
||||
deferredSnapshots.remove(committed);
|
||||
expectedCommittedSnapshot = committed;
|
||||
}
|
||||
|
||||
static String configHash(String content) {
|
||||
@@ -127,11 +215,158 @@ public class TenantConfigWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует in-memory тенантов с конфигом из файла.
|
||||
*/
|
||||
private void syncTenants(List<TenantConfig> newTenants) {
|
||||
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants);
|
||||
private Projection parseProjection(String content, String hash) throws IOException {
|
||||
List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
|
||||
if (tenants == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Корневое значение конфигурации тенантов должно быть массивом"
|
||||
);
|
||||
}
|
||||
if (tenantsConfigRequired && tenants.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Обязательная конфигурация тенантов не может быть пустой"
|
||||
);
|
||||
}
|
||||
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
|
||||
}
|
||||
|
||||
private ConfigSnapshot snapshotOf(List<TenantConfig> tenants) {
|
||||
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||
List<NormalizedTenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||
for (TenantConfig tenant : tenants) {
|
||||
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана");
|
||||
String domain = normalizeDomain(tenant.getDomain());
|
||||
String name = tenant.getName() == null || tenant.getName().isBlank()
|
||||
? domain
|
||||
: tenant.getName().trim();
|
||||
String url = tenant.getUrl() == null ? null : tenant.getUrl().trim();
|
||||
normalized.add(new NormalizedTenantConfig(
|
||||
name,
|
||||
domain,
|
||||
url,
|
||||
tenant.getUsername(),
|
||||
tenant.getPassword()
|
||||
));
|
||||
}
|
||||
normalized.sort(NormalizedTenantConfig.ORDER);
|
||||
return new ConfigSnapshot(normalized);
|
||||
}
|
||||
|
||||
private String normalizeDomain(String domain) {
|
||||
return domain == null ? null : domain.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void syncTenants(List<TenantConfig> tenants) {
|
||||
tenantLifecycleService.synchronizeFromPersistedConfig(tenants);
|
||||
}
|
||||
|
||||
private boolean shouldDefer(ConfigSnapshot snapshot) {
|
||||
return expectedCommittedSnapshot != null && deferredSnapshots.contains(snapshot);
|
||||
}
|
||||
|
||||
private void clearFenceWhenExpectedSnapshotIsAlreadyApplied(ConfigSnapshot snapshot) {
|
||||
if (snapshot.equals(expectedCommittedSnapshot)) {
|
||||
clearSnapshotFence();
|
||||
}
|
||||
}
|
||||
|
||||
private void markApplied(Projection projection) {
|
||||
lastConfigHash = projection.hash();
|
||||
lastAppliedSnapshot = projection.snapshot();
|
||||
configurationUnavailable = false;
|
||||
resetRetryState();
|
||||
clearSnapshotFence();
|
||||
}
|
||||
|
||||
private void handleRequiredMissingFile(long now) {
|
||||
if (isRetryDeferred(MISSING_FAILURE_KEY, now)) {
|
||||
logDeferredRetry(now);
|
||||
return;
|
||||
}
|
||||
recordFailure(
|
||||
MISSING_FAILURE_KEY,
|
||||
new TenantLifecycleException("Обязательный файл конфигурации тенантов недоступен")
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isRetryDeferred(String failureKey, long now) {
|
||||
return failureKey.equals(failedRevisionKey) && now < retryNotBeforeMillis;
|
||||
}
|
||||
|
||||
private void logDeferredRetry(long now) {
|
||||
log.debug("Повторная синхронизация tenants.json отложена ещё на {} мс",
|
||||
Math.max(0L, retryNotBeforeMillis - now));
|
||||
}
|
||||
|
||||
private void recordFailure(String failureKey, Exception failure) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
long retryDelay = registerFailure(failureKey);
|
||||
log.error("Ошибка синхронизации конфигурации тенантов: попытка={}, "
|
||||
+ "следующий повтор не ранее чем через {} мс, errorType={}",
|
||||
consecutiveFailures,
|
||||
retryDelay,
|
||||
failure.getClass().getSimpleName());
|
||||
log.debug("Технические детали синхронизации конфигурации тенантов", failure);
|
||||
}
|
||||
|
||||
private long registerFailure(String failureKey) {
|
||||
if (failureKey.equals(failedRevisionKey) && consecutiveFailures > 0) {
|
||||
consecutiveFailures = Math.min(consecutiveFailures + 1, 31);
|
||||
} else {
|
||||
failedRevisionKey = failureKey;
|
||||
consecutiveFailures = 1;
|
||||
}
|
||||
long delay = retryDelayMillis(consecutiveFailures);
|
||||
retryNotBeforeMillis = safeAdd(currentTimeMillis.getAsLong(), delay);
|
||||
return delay;
|
||||
}
|
||||
|
||||
static long retryDelayMillis(int failureCount) {
|
||||
long delay = RETRY_BASE_DELAY_MILLIS;
|
||||
for (int attempt = 1; attempt < failureCount && delay < RETRY_MAX_DELAY_MILLIS; attempt++) {
|
||||
delay = Math.min(delay * 2, RETRY_MAX_DELAY_MILLIS);
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
private long safeAdd(long value, long increment) {
|
||||
return value > Long.MAX_VALUE - increment ? Long.MAX_VALUE : value + increment;
|
||||
}
|
||||
|
||||
private void resetRetryState() {
|
||||
failedRevisionKey = "";
|
||||
consecutiveFailures = 0;
|
||||
retryNotBeforeMillis = 0L;
|
||||
}
|
||||
|
||||
private void clearSnapshotFence() {
|
||||
expectedCommittedSnapshot = null;
|
||||
deferredSnapshots.clear();
|
||||
}
|
||||
|
||||
private record Projection(String hash, List<TenantConfig> tenants, ConfigSnapshot snapshot) {
|
||||
}
|
||||
|
||||
private record ConfigSnapshot(List<NormalizedTenantConfig> tenants) {
|
||||
private ConfigSnapshot {
|
||||
tenants = List.copyOf(tenants);
|
||||
}
|
||||
}
|
||||
|
||||
private record NormalizedTenantConfig(
|
||||
String name,
|
||||
String domain,
|
||||
String url,
|
||||
String username,
|
||||
String password
|
||||
) {
|
||||
private static final Comparator<String> NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo);
|
||||
private static final Comparator<NormalizedTenantConfig> ORDER =
|
||||
Comparator.comparing(NormalizedTenantConfig::domain, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::name, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::url, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::username, NULL_SAFE_TEXT)
|
||||
.thenComparing(NormalizedTenantConfig::password, NULL_SAFE_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.TenantLifecycleException;
|
||||
import com.magistr.app.service.TenantLifecycleMutationResult;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -90,8 +91,11 @@ public class DatabaseController {
|
||||
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
||||
try {
|
||||
return tenantLifecycleService.executeSerialized(() -> {
|
||||
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config);
|
||||
tenantConfigWatcher.refreshHash();
|
||||
tenantConfigWatcher.prepareForApiMutation();
|
||||
TenantLifecycleMutationResult mutation =
|
||||
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
|
||||
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||
TenantConfig saved = mutation.tenant();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
|
||||
@@ -111,8 +115,11 @@ public class DatabaseController {
|
||||
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
|
||||
try {
|
||||
return tenantLifecycleService.executeSerialized(() -> {
|
||||
TenantConfig removed = tenantLifecycleService.removeTenant(domain);
|
||||
tenantConfigWatcher.refreshHash();
|
||||
tenantConfigWatcher.prepareForApiMutation();
|
||||
TenantLifecycleMutationResult mutation =
|
||||
tenantLifecycleService.removeTenantWithReceipt(domain);
|
||||
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||
TenantConfig removed = mutation.tenant();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Результат успешной lifecycle-мутации тенанта вместе с квитанцией персистенции.
|
||||
*
|
||||
* <p>Конфигурация копируется при создании и чтении, чтобы вызывающий код не мог изменить
|
||||
* уже активированный runtime-снимок через изменяемый DTO.</p>
|
||||
*/
|
||||
public final class TenantLifecycleMutationResult {
|
||||
|
||||
private final TenantConfig tenant;
|
||||
private final TenantSecretUpdateReceipt receipt;
|
||||
|
||||
public TenantLifecycleMutationResult(TenantConfig tenant, TenantSecretUpdateReceipt receipt) {
|
||||
this.tenant = copy(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана"));
|
||||
this.receipt = Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||
}
|
||||
|
||||
public TenantConfig tenant() {
|
||||
return copy(tenant);
|
||||
}
|
||||
|
||||
public TenantSecretUpdateReceipt receipt() {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private static TenantConfig copy(TenantConfig source) {
|
||||
return new TenantConfig(
|
||||
source.getName(),
|
||||
source.getDomain(),
|
||||
source.getUrl(),
|
||||
source.getUsername(),
|
||||
source.getPassword()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,9 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Set;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TenantLifecycleService {
|
||||
@@ -72,6 +71,13 @@ public class TenantLifecycleService {
|
||||
* Добавляет или обновляет tenant без разрыва действующего подключения.
|
||||
*/
|
||||
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
|
||||
return addOrUpdateTenantWithReceipt(requestedConfig).tenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет или обновляет tenant и возвращает подтверждённый результат персистенции.
|
||||
*/
|
||||
public synchronized TenantLifecycleMutationResult addOrUpdateTenantWithReceipt(TenantConfig requestedConfig) {
|
||||
TenantConfig config = validateAndNormalize(requestedConfig);
|
||||
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
||||
@@ -96,7 +102,7 @@ public class TenantLifecycleService {
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
||||
return copyConfig(config);
|
||||
return new TenantLifecycleMutationResult(config, receipt);
|
||||
} finally {
|
||||
if (!activated) {
|
||||
closeCandidateDataSource(candidate);
|
||||
@@ -108,6 +114,13 @@ public class TenantLifecycleService {
|
||||
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
|
||||
*/
|
||||
public synchronized TenantConfig removeTenant(String requestedDomain) {
|
||||
return removeTenantWithReceipt(requestedDomain).tenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет tenant и возвращает подтверждённый результат персистенции.
|
||||
*/
|
||||
public synchronized TenantLifecycleMutationResult removeTenantWithReceipt(String requestedDomain) {
|
||||
String domain = validateAndNormalizeDomain(requestedDomain);
|
||||
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
||||
TenantConfig existing = oldConfigs.get(domain);
|
||||
@@ -132,13 +145,14 @@ public class TenantLifecycleService {
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
||||
retireDataSource(removed.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' удалён", domain);
|
||||
return copyConfig(existing);
|
||||
return new TenantLifecycleMutationResult(existing, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
|
||||
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
|
||||
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13.
|
||||
* Для нового или изменённого домена подключение сначала полностью подготавливается,
|
||||
* проверяется и мигрируется, после чего атомарно заменяет прежний runtime-снимок.
|
||||
*/
|
||||
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
|
||||
if (requestedConfigs == null) {
|
||||
@@ -146,47 +160,55 @@ public class TenantLifecycleService {
|
||||
}
|
||||
List<TenantConfig> configs = requestedConfigs.stream()
|
||||
.map(this::validateAndNormalize)
|
||||
.sorted(Comparator.comparing(TenantConfig::getDomain))
|
||||
.toList();
|
||||
Set<String> desiredDomains = configs.stream()
|
||||
.map(TenantConfig::getDomain)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(configs);
|
||||
if (desiredDomains.size() != configs.size()) {
|
||||
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||
}
|
||||
|
||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>();
|
||||
for (TenantConfig config : configs) {
|
||||
if (!routingDataSource.hasTenant(config.getDomain())) {
|
||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||
activatePersistedConfig(config);
|
||||
if (desiredConfigs.put(config.getDomain(), config) != null) {
|
||||
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||
}
|
||||
}
|
||||
|
||||
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) {
|
||||
if (!desiredDomains.contains(existingDomain)) {
|
||||
Map<String, TenantConfig> currentConfigs = routingDataSource.snapshotTenantConfigs();
|
||||
readinessRegistry.replaceDesired(configs);
|
||||
|
||||
for (TenantConfig config : configs) {
|
||||
TenantConfig currentConfig = currentConfigs.get(config.getDomain());
|
||||
if (currentConfig == null) {
|
||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||
activatePersistedConfig(config);
|
||||
} else if (!sameConfig(currentConfig, config)) {
|
||||
log.info("Заменяем подключение тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||
activatePersistedConfig(config);
|
||||
} else {
|
||||
recoverPersistedReadinessIfNeeded(config);
|
||||
}
|
||||
}
|
||||
|
||||
for (String existingDomain : new ArrayList<>(currentConfigs.keySet())) {
|
||||
if (!desiredConfigs.containsKey(existingDomain)) {
|
||||
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
|
||||
TenantRoutingDataSource.TenantState removed =
|
||||
routingDataSource.removeTenantAtomically(existingDomain);
|
||||
retireDataSource(removed == null ? null : removed.dataSource());
|
||||
}
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
}
|
||||
|
||||
private void activatePersistedConfig(TenantConfig config) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
TenantRoutingDataSource.TenantState previous;
|
||||
readinessRegistry.markMigrationStarted(config);
|
||||
try {
|
||||
candidate = prepareCandidate(config);
|
||||
verifyConnection(candidate);
|
||||
migrateCandidate(candidate);
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
||||
previous = routingDataSource.swapTenant(config, candidate);
|
||||
activated = true;
|
||||
readinessRegistry.markMigrationSucceeded(config);
|
||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
} catch (RuntimeException activationFailure) {
|
||||
readinessRegistry.markMigrationFailed(config);
|
||||
throw activationFailure;
|
||||
@@ -195,6 +217,29 @@ public class TenantLifecycleService {
|
||||
closeCandidateDataSource(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// После swap новый pool уже является рабочим. Ошибка публикации readiness не должна
|
||||
// ошибочно помечать успешно мигрированный pool как FAILED: следующий retry восстановит
|
||||
// состояние по активному подключению без повторной замены DataSource.
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
readinessRegistry.markMigrationSucceeded(config);
|
||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||
}
|
||||
|
||||
private void recoverPersistedReadinessIfNeeded(TenantConfig config) {
|
||||
TenantReadinessRegistry.TenantStatus status =
|
||||
readinessRegistry.snapshot().get(config.getDomain());
|
||||
if (status == null
|
||||
|| status.migrationState() == TenantReadinessRegistry.MigrationState.SUCCEEDED) {
|
||||
return;
|
||||
}
|
||||
if (!routingDataSource.testConnection(config.getDomain())) {
|
||||
throw new TenantLifecycleException(
|
||||
"Активное подключение тенанта не прошло проверку после предыдущей синхронизации"
|
||||
);
|
||||
}
|
||||
readinessRegistry.markMigrationSucceeded(config);
|
||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||
}
|
||||
|
||||
private HikariDataSource prepareCandidate(TenantConfig config) {
|
||||
@@ -398,9 +443,24 @@ public class TenantLifecycleService {
|
||||
);
|
||||
}
|
||||
|
||||
private boolean sameConfig(TenantConfig first, TenantConfig second) {
|
||||
TenantConfig normalizedFirst = validateAndNormalize(first);
|
||||
TenantConfig normalizedSecond = validateAndNormalize(second);
|
||||
return Objects.equals(normalizedFirst.getName(), normalizedSecond.getName())
|
||||
&& Objects.equals(normalizedFirst.getDomain(), normalizedSecond.getDomain())
|
||||
&& Objects.equals(normalizedFirst.getUrl(), normalizedSecond.getUrl())
|
||||
&& Objects.equals(normalizedFirst.getUsername(), normalizedSecond.getUsername())
|
||||
&& Objects.equals(normalizedFirst.getPassword(), normalizedSecond.getPassword());
|
||||
}
|
||||
|
||||
private void retireDataSource(DataSource dataSource) {
|
||||
if (dataSource != null) {
|
||||
retiredPoolService.retire(dataSource);
|
||||
try {
|
||||
retiredPoolService.retire(dataSource);
|
||||
} catch (RuntimeException retirementFailure) {
|
||||
logTechnicalFailure("Не удалось передать прежний tenant pool на отложенное закрытие",
|
||||
retirementFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user