fix: provisioning race condition and noisy ClickHouse logs
Some checks failed
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m30s
SonarQube Analysis / sonarqube (push) Failing after 2m22s

Defer provisionAsync() until after the transaction commits using
TransactionSynchronization.afterCommit(). Previously the @Async thread
raced the @Transactional commit — findById returned null because the
tenant INSERT wasn't visible yet.

Downgrade ClickHouse UNKNOWN_TABLE errors to DEBUG level in
InfrastructureService. These are expected on fresh installs before any
cameleer-server has created the tables.

Make the onboarding slug field read-only (derived from org name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-25 22:05:48 +02:00
parent dee1f39554
commit 171ed1a6ab
4 changed files with 57 additions and 18 deletions

View File

@@ -224,7 +224,11 @@ public class InfrastructureService {
.put(table, cnt);
}
} catch (Exception e) {
log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e);
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
} else {
log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e);
}
}
}
} catch (Exception e) {
@@ -256,8 +260,12 @@ public class InfrastructureService {
result.add(new ChTableStats(table, rs.getLong("cnt")));
}
} catch (Exception e) {
log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}",
table, tenantId, e.getMessage(), e);
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
} else {
log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}",
table, tenantId, e.getMessage(), e);
}
}
}
} catch (Exception e) {

View File

@@ -26,6 +26,8 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.util.List;
@@ -116,9 +118,19 @@ public class VendorTenantService {
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
null, null, "SUCCESS", null);
// 4. Provision server asynchronously (Docker containers, health check, config push)
// 4. Provision server asynchronously AFTER transaction commits
// (the async thread needs the tenant row to be visible)
if (tenantProvisioner.isAvailable()) {
self.provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId);
UUID tenantId = tenant.getId();
String slug = tenant.getSlug();
String tierName = tenant.getTier().name();
String token = license.getToken();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
self.provisionAsync(tenantId, slug, tierName, token, actorId);
}
});
}
return tenant;

View File

@@ -17,11 +17,14 @@ import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.Instant;
@@ -89,6 +92,28 @@ class VendorTenantServiceTest {
tenantService, tenantRepository, licenseService,
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService);
// Enable transaction synchronization so afterCommit callbacks can be registered
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.initSynchronization();
}
}
@AfterEach
void tearDown() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
/** Simulate transaction commit — runs all registered afterCommit callbacks. */
private void flushAfterCommit() {
var syncs = TransactionSynchronizationManager.getSynchronizations();
for (TransactionSynchronization sync : syncs) {
sync.afterCommit();
}
TransactionSynchronizationManager.clearSynchronization();
TransactionSynchronizationManager.initSynchronization();
}
// --- Helpers ---
@@ -155,8 +180,9 @@ class VendorTenantServiceTest {
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
vendorTenantService.createAndProvision(request, actorId);
flushAfterCommit();
// provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests)
// provisionAsync runs via afterCommit callback (synchronously in unit tests)
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.ACTIVE);
assertThat(tenant.getServerEndpoint()).isEqualTo("http://server:8080");
assertThat(tenant.getProvisionError()).isNull();
@@ -178,8 +204,9 @@ class VendorTenantServiceTest {
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
vendorTenantService.createAndProvision(request, actorId);
flushAfterCommit();
// provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests)
// provisionAsync runs via afterCommit callback (synchronously in unit tests)
assertThat(tenant.getProvisionError()).isEqualTo("Docker failure");
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
verify(tenantRepository, times(2)).save(tenant);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { api } from '../api/client';
@@ -14,16 +14,10 @@ interface TenantResponse {
export function OnboardingPage() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slugTouched) {
setSlug(toSlug(name));
}
}, [name, slugTouched]);
const slug = toSlug(name);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -78,10 +72,8 @@ export function OnboardingPage() {
<Input
id="onboard-slug"
value={slug}
onChange={(e) => { setSlugTouched(true); setSlug(e.target.value); }}
placeholder="acme-corp"
disabled={loading}
required
readOnly
/>
</FormField>