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