test(license): RetentionRuntimeRecomputeIT — TTL recompute on license change
Install license with max_log_retention_days=30, env.configured=60 → effective=30; verify ClickHouse logs table reflects toIntervalDay(30). Replace with max=7 → effective=7; verify TTL recomputed. Polls system.tables.create_table_query up to 5s for the @Async listener to apply. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package com.cameleer.server.app.license;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.core.license.LicenseGate;
|
||||
import com.cameleer.server.core.license.LicenseState;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test for Spec §4.3 — when a license changes, {@link RetentionPolicyApplier}
|
||||
* re-issues {@code ALTER TABLE … MODIFY TTL … WHERE environment = '<slug>'} statements
|
||||
* against ClickHouse so per-env retention reflects the new {@code min(licenseCap, env.configured)}.
|
||||
*
|
||||
* <p><b>Strategy:</b> the applier is an {@code @Async @EventListener}. To avoid timing
|
||||
* flakiness, this IT calls {@link RetentionPolicyApplier#onLicenseChanged} synchronously
|
||||
* after seeding the {@link LicenseGate} via
|
||||
* {@link TestSecurityHelper#installSyntheticUnsignedLicense(Map)}. The async dispatch
|
||||
* and the SQL-shape correctness are already covered by {@code RetentionPolicyApplierTest}
|
||||
* (unit, mocked CH); this IT adds the missing real-CH ALTER round-trip.</p>
|
||||
*
|
||||
* <p><b>Env retention setup:</b> V1 seeds the default env with retention=1 day. To make
|
||||
* the license cap the binding constraint of {@code min(cap, configured)}, this test
|
||||
* raises the {@code default} env's {@code log_retention_days} to 60 in {@code @BeforeEach}
|
||||
* via raw JDBC (the {@code EnvironmentRepository} interface deliberately does not expose
|
||||
* retention-day setters — admin endpoints exist for them, but the controller plumbing is
|
||||
* out of scope here). Restored to 1 in {@code @AfterEach}.</p>
|
||||
*
|
||||
* <p><b>Assertion target:</b> {@code system.tables.create_table_query} in ClickHouse
|
||||
* reflects the most recent {@code MODIFY TTL} clause as a row-level TTL with WHERE
|
||||
* predicate (CH 22.3+; project runs 24.12). Polls up to 5s for any other ALTERs that
|
||||
* the runtime may have queued.</p>
|
||||
*/
|
||||
class RetentionRuntimeRecomputeIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
@Qualifier("clickHouseJdbcTemplate")
|
||||
JdbcTemplate clickHouseJdbc;
|
||||
|
||||
@Autowired
|
||||
TestSecurityHelper securityHelper;
|
||||
|
||||
@Autowired
|
||||
LicenseGate gate;
|
||||
|
||||
@Autowired
|
||||
RetentionPolicyApplier applier;
|
||||
|
||||
@BeforeEach
|
||||
void seedHighEnvRetention() {
|
||||
// Sibling ITs may have left a license in the gate — clear so default tier is the baseline.
|
||||
securityHelper.clearTestLicense();
|
||||
// Raise default env's log retention to 60 so license cap becomes the binding constraint.
|
||||
jdbcTemplate.update(
|
||||
"UPDATE environments SET log_retention_days = 60 WHERE slug = 'default'");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreDefaults() {
|
||||
securityHelper.clearTestLicense();
|
||||
jdbcTemplate.update(
|
||||
"UPDATE environments SET log_retention_days = 1 WHERE slug = 'default'");
|
||||
// Re-fire applier with cleared gate (default tier, log cap = 1) so subsequent ITs
|
||||
// observe the schema's seeded TTL, not whatever this test last set.
|
||||
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingLicenseRecomputesLogsTtl() throws Exception {
|
||||
// (1) Install license with cap = 30. With env.configured = 60, effective = min(30, 60) = 30.
|
||||
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 30));
|
||||
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
|
||||
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(30);
|
||||
|
||||
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
|
||||
|
||||
awaitTtlInterval("logs", 30, 5_000);
|
||||
|
||||
// (2) Replace license: cap = 7. effective = min(7, 60) = 7.
|
||||
securityHelper.clearTestLicense();
|
||||
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 7));
|
||||
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(7);
|
||||
|
||||
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
|
||||
|
||||
awaitTtlInterval("logs", 7, 5_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls {@code system.tables.create_table_query} until it contains the expected
|
||||
* day-interval fragment for the given environment, or the deadline elapses. The
|
||||
* synchronous applier call already completed before this is invoked — the poll
|
||||
* just guards against any latent ClickHouse internal propagation delay between
|
||||
* the ALTER returning and {@code system.tables} reflecting the change.
|
||||
*
|
||||
* <p>ClickHouse normalises {@code INTERVAL N DAY} in the stored {@code TTL}
|
||||
* clause to {@code toIntervalDay(N)} when serialising back to {@code create_table_query},
|
||||
* so we match the canonical form. The {@code WHERE environment = '<slug>'}
|
||||
* predicate is included so a stale TTL for a different env can't satisfy the
|
||||
* assertion.</p>
|
||||
*/
|
||||
private void awaitTtlInterval(String table, int days, long timeoutMs) throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
String fragment = "toIntervalDay(" + days + ") WHERE environment = 'default'";
|
||||
String last = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
last = clickHouseJdbc.queryForObject(
|
||||
"SELECT create_table_query FROM system.tables "
|
||||
+ "WHERE name = ? AND database = currentDatabase()",
|
||||
String.class, table);
|
||||
if (last != null && last.contains(fragment)) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new AssertionError(
|
||||
"Timed out waiting for " + table + " TTL to contain '" + fragment
|
||||
+ "'. Last create_table_query:\n" + last);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user