Skip to content

Commit 46e63b7

Browse files
committed
Add PricingPolicyService + admin REST + LISTEN/NOTIFY cache invalidation
PR-I1 (service half). Built off #6460 — sibling of #6464 (lineage primitives), both stacked on payg-i2-document-classifier so they can review independently. Lookup precedence: - PaygTeamExtensions.pricingPolicyId override → load that policy - else load the pricing_policy row with is_default = TRUE - if the override points at a deleted row, fall back to default (logs a warn — safety net for racing deletes) Cache: - 30s Caffeine, keyed by teamId, max 10k entries — correctness floor. - Invalidated on PolicyChangedEvent from any source (admin mutation here, or cross-instance via the Postgres LISTEN runner). - Admin reads bypass the cache (getEffectivePolicyUncached) so admins always see their own writes. Writes (service layer, transactional, fire-after-commit event): - create(draft) — rejects pre-set policy_id or is_default=true (promotion must go through setDefault so the partial unique idx is freed first). - setDefault(id) — clearDefaultFlag + flip; idempotent no-op if already default (no event fired in that case). - setTeamOverride(teamId, policyId|null) — validate policy exists before save. Admin REST surface — /api/v1/admin/payg/... - GET /policies, GET /policies/{id}, POST /policies - POST /policies/{id}/set-default - PUT /teams/{teamId}/policy-override - GET /teams/{teamId}/effective-policy (bypasses cache) All gated by @PreAuthorize("hasRole('ADMIN')"). Validation errors return 400, unknown rows return 404. LISTEN/NOTIFY runner (PolicyChangeListener): - Opens a dedicated raw JDBC connection via DriverManager (not HikariCP — Hikari would evict idle LISTEN connections) and polls getNotifications() on a daemon thread. - Reconnects with 5s backoff on SQLException; the 30s TTL is the correctness floor during outages. - Disabled by setting payg.policy.listen.enabled=false (default on). - PgJDBC moved to compileOnly on :saas; the runtime artifact is still bundled via :proprietary. V11 migration: seed the V1 default policy (25 pages/unit, 5 MiB/unit, min charge 1, file cap 1000) + step limits per JobSource (WEB/API/DESKTOP_APP 10, PIPELINE 20). Idempotent — only inserts when no default exists. Tests: 17 PricingPolicyServiceTest + 14 PricingPolicyAdminControllerTest, all passing. Coverage moves 10.87% → 12.40% LINE and 11.89% → 13.85% INSTRUCTION. Counterpart Supabase migration (NOTIFY trigger function + per-table triggers on pricing_policy*, payg_team_extensions overrides, wallet_policy; plus the default-policy seed) ships on a separate SaaS PR on payg-i1-pricing-policy-service.
1 parent 83ea07e commit 46e63b7

10 files changed

Lines changed: 1277 additions & 0 deletions

File tree

app/saas/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ dependencies {
4040

4141
api 'org.flywaydb:flyway-core'
4242
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
43+
// PgJDBC is bundled at runtime via :proprietary. We need `org.postgresql.PGConnection` at
44+
// compile time for the LISTEN/NOTIFY runner (PricingPolicyService cache invalidation) —
45+
// `compileOnly` keeps the artifact out of our build output.
46+
compileOnly 'org.postgresql:postgresql:42.7.11'
4347

4448
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
4549
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package stirling.software.saas.payg.policy;
2+
3+
import java.sql.Connection;
4+
import java.sql.DriverManager;
5+
import java.sql.SQLException;
6+
import java.sql.Statement;
7+
import java.util.concurrent.ExecutorService;
8+
import java.util.concurrent.Executors;
9+
import java.util.concurrent.TimeUnit;
10+
import java.util.concurrent.atomic.AtomicBoolean;
11+
12+
import org.postgresql.PGConnection;
13+
import org.postgresql.PGNotification;
14+
import org.springframework.beans.factory.annotation.Value;
15+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
16+
import org.springframework.context.ApplicationEventPublisher;
17+
import org.springframework.context.annotation.Profile;
18+
import org.springframework.stereotype.Component;
19+
20+
import jakarta.annotation.PostConstruct;
21+
import jakarta.annotation.PreDestroy;
22+
23+
import lombok.extern.slf4j.Slf4j;
24+
25+
/**
26+
* Listens on the Postgres {@code policy_changed} channel and publishes a {@link PolicyChangedEvent}
27+
* when a {@code pricing_policy*} row or a {@code payg_team_extensions} override changes. Triggers
28+
* on the database side call {@code pg_notify('policy_changed', ...)}; this runner translates them
29+
* into Spring events that {@link PricingPolicyService} (and any other cache holder) listens for.
30+
*
31+
* <h2>Why a dedicated raw JDBC connection</h2>
32+
*
33+
* <p>PgJDBC's {@code LISTEN} binds notifications to a specific {@link Connection} for that
34+
* connection's lifetime. HikariCP would eventually evict an idle connection (Hikari's {@code
35+
* idleTimeout} / {@code maxLifetime}), losing notifications without us noticing — so we open our
36+
* own raw connection via {@link DriverManager} and hold it. Loss of one DB connection is an
37+
* acceptable cost; if it ever becomes contentious we can carve out a tiny separate {@code
38+
* DataSource}.
39+
*
40+
* <h2>Failure mode</h2>
41+
*
42+
* <p>If the connection drops (network blip, Postgres restart), the polling loop catches the {@link
43+
* SQLException}, logs, sleeps {@value #RECONNECT_BACKOFF_MS} ms, and retries. The 30s {@link
44+
* PricingPolicyService} cache TTL acts as a correctness floor during outages — admin mutations
45+
* still propagate within at most 30 seconds even if LISTEN is wholly broken.
46+
*
47+
* <h2>Disabling</h2>
48+
*
49+
* <p>Set {@code payg.policy.listen.enabled=false} to skip the runner entirely (useful in tests and
50+
* single-instance dev where the 30s TTL is more than enough).
51+
*/
52+
@Component
53+
@Profile("saas")
54+
@ConditionalOnProperty(
55+
prefix = "payg.policy.listen",
56+
name = "enabled",
57+
havingValue = "true",
58+
matchIfMissing = true)
59+
@Slf4j
60+
public class PolicyChangeListener {
61+
62+
static final String CHANNEL = "policy_changed";
63+
private static final long POLL_TIMEOUT_MS = 5_000L;
64+
private static final long RECONNECT_BACKOFF_MS = 5_000L;
65+
66+
private final String jdbcUrl;
67+
private final String username;
68+
private final String password;
69+
private final ApplicationEventPublisher eventPublisher;
70+
71+
private final AtomicBoolean running = new AtomicBoolean(false);
72+
private ExecutorService executor;
73+
private Connection listenConnection;
74+
75+
public PolicyChangeListener(
76+
@Value("${spring.datasource.url}") String jdbcUrl,
77+
@Value("${spring.datasource.username}") String username,
78+
@Value("${spring.datasource.password:}") String password,
79+
ApplicationEventPublisher eventPublisher) {
80+
this.jdbcUrl = jdbcUrl;
81+
this.username = username;
82+
this.password = password;
83+
this.eventPublisher = eventPublisher;
84+
}
85+
86+
@PostConstruct
87+
void start() {
88+
if (jdbcUrl == null || jdbcUrl.isBlank()) {
89+
log.warn(
90+
"spring.datasource.url is empty; PolicyChangeListener will not start."
91+
+ " PricingPolicyService falls back to its 30s TTL.");
92+
return;
93+
}
94+
running.set(true);
95+
executor =
96+
Executors.newSingleThreadExecutor(
97+
r -> {
98+
Thread t = new Thread(r, "payg-policy-listen");
99+
t.setDaemon(true);
100+
return t;
101+
});
102+
executor.submit(this::pollLoop);
103+
log.info("PolicyChangeListener started on channel '{}'.", CHANNEL);
104+
}
105+
106+
@PreDestroy
107+
void stop() {
108+
running.set(false);
109+
closeConnectionQuietly();
110+
if (executor != null) {
111+
executor.shutdownNow();
112+
try {
113+
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
114+
log.warn("PolicyChangeListener executor did not terminate within 5s.");
115+
}
116+
} catch (InterruptedException e) {
117+
Thread.currentThread().interrupt();
118+
}
119+
}
120+
log.info("PolicyChangeListener stopped.");
121+
}
122+
123+
private void pollLoop() {
124+
while (running.get()) {
125+
try {
126+
if (listenConnection == null || listenConnection.isClosed()) {
127+
listenConnection = openListenConnection();
128+
}
129+
drainNotifications(listenConnection);
130+
} catch (SQLException e) {
131+
log.warn(
132+
"PolicyChangeListener IO error ({}). Reconnecting in {}ms.",
133+
e.getMessage(),
134+
RECONNECT_BACKOFF_MS);
135+
closeConnectionQuietly();
136+
sleepQuietly(RECONNECT_BACKOFF_MS);
137+
} catch (RuntimeException e) {
138+
// Don't let a bug in the polling loop kill the thread silently.
139+
log.error("PolicyChangeListener unexpected error; restarting after backoff.", e);
140+
closeConnectionQuietly();
141+
sleepQuietly(RECONNECT_BACKOFF_MS);
142+
}
143+
}
144+
}
145+
146+
private Connection openListenConnection() throws SQLException {
147+
Connection conn = DriverManager.getConnection(jdbcUrl, username, password);
148+
try (Statement st = conn.createStatement()) {
149+
st.execute("LISTEN " + CHANNEL);
150+
}
151+
return conn;
152+
}
153+
154+
private void drainNotifications(Connection conn) throws SQLException {
155+
PGConnection pg = conn.unwrap(PGConnection.class);
156+
PGNotification[] notifications = pg.getNotifications((int) POLL_TIMEOUT_MS);
157+
if (notifications == null) {
158+
return;
159+
}
160+
for (PGNotification n : notifications) {
161+
String payload = n.getParameter();
162+
log.debug("PolicyChangeListener received notification: '{}'.", payload);
163+
try {
164+
eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
165+
} catch (RuntimeException publishError) {
166+
// A bad listener mustn't break this loop or stop future notifications.
167+
log.warn(
168+
"PolicyChangedEvent listener threw ({}); continuing.",
169+
publishError.getMessage());
170+
}
171+
}
172+
}
173+
174+
private void closeConnectionQuietly() {
175+
if (listenConnection != null) {
176+
try {
177+
listenConnection.close();
178+
} catch (SQLException e) {
179+
log.debug("Ignoring close error on listen connection: {}", e.getMessage());
180+
}
181+
listenConnection = null;
182+
}
183+
}
184+
185+
private static void sleepQuietly(long ms) {
186+
try {
187+
Thread.sleep(ms);
188+
} catch (InterruptedException e) {
189+
Thread.currentThread().interrupt();
190+
}
191+
}
192+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package stirling.software.saas.payg.policy;
2+
3+
import org.springframework.context.ApplicationEvent;
4+
5+
/**
6+
* Fires when a {@code pricing_policy*} or {@code payg_team_extensions.pricing_policy_id} row
7+
* changes — published by the Postgres LISTEN runner (see {@link PolicyChangeListener}) or by admin
8+
* REST mutations directly. {@link PricingPolicyService} listens and invalidates its cache.
9+
*
10+
* <p>The {@code payload} echoes whatever the {@code pg_notify} channel carried (kept loose because
11+
* the invalidation strategy is "blow the whole cache" — payload detail doesn't change behaviour).
12+
* Field is exposed for logging/observability only.
13+
*/
14+
public class PolicyChangedEvent extends ApplicationEvent {
15+
16+
private static final long serialVersionUID = 1L;
17+
18+
private final String payload;
19+
20+
public PolicyChangedEvent(Object source, String payload) {
21+
super(source);
22+
this.payload = payload;
23+
}
24+
25+
public String getPayload() {
26+
return payload;
27+
}
28+
}

0 commit comments

Comments
 (0)