feat(self-operated): persist continuous collection and frozen coverage

This commit is contained in:
2026-10-13 21:13:31 +08:00
parent c2bb769676
commit 242a76221e
45 changed files with 1021 additions and 118 deletions
@@ -12,7 +12,8 @@ import java.time.Instant;
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback) {
@DefaultValue("5m") Duration cooldownFallback,
@DefaultValue("false") boolean continuousBusinessCollectionEnabled) {
public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
@@ -62,7 +62,7 @@ public final class LingxingEgressGate {
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
|| (!continuousBusiness() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
}
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
@@ -106,10 +106,14 @@ public final class LingxingEgressGate {
if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank()
|| !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())))
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
private boolean continuousBusiness() {
return properties.collectionMode() == CollectionMode.BUSINESS && properties.continuousBusinessCollectionEnabled();
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
@@ -44,13 +44,14 @@ baiyue:
request-timeout: 15s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
# Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
@@ -7,12 +7,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*;
class LingxingEgressPropertiesTest {
@Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() {
String prefix = "baiyue.integration.lingxing-wms.egress";
var input = new java.util.HashMap<String, String>();
input.put(prefix + ".collection-mode", "BUSINESS");
var source = new org.springframework.boot.context.properties.source.MapConfigurationPropertySource(input);
var binder = new org.springframework.boot.context.properties.bind.Binder(source);
var target = org.springframework.boot.context.properties.bind.Bindable.of(LingxingEgressProperties.class);
assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isFalse();
source.put(prefix + ".continuous-business-collection-enabled", "true");
assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isTrue();
}
private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) {
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown);
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false);
}
@Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() {
for (long millis : new long[]{1, 1500, 2000, 3000, 5000, 300000}) {
for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval())
.isEqualTo(Duration.ofMillis(millis));
}
@@ -54,7 +54,88 @@ class LingxingEgressGateTest {
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) {
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null);
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false);
}
private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) {
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until,
URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis);
}
@Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1));
assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
clock.advanceMillis(86400000);
var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1,
clock.instant().plusSeconds(31), true, true);
assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThat(ledger.completions).hasSize(2);
}
@Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() {
for (String grant : Arrays.asList(null, "", "other-grant")) {
var gate = continuousGate(CollectionMode.BUSINESS, grant, null);
assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
for (Instant until : Arrays.asList(null, NOW)) {
var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until);
assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
assertThat(ledger.authorizations).isZero();
}
@Test void continuousBusinessStillPausesOnUnconfirmedCompletion() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
ledger.failComplete = true;
assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED");
ledger.failComplete = false; clock.advanceMillis(86400000);
assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void disabledContinuousBusinessStillRequiresUnexpiredGrant() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false),
ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> gate.execute(businessContext("disabled"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
assertThat(ledger.authorizations).isZero();
}
@Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true),
ledger, clock, clock::advanceMillis);
gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200);
assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1));
}
@Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero());
}
@Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); }));
assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null);
assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_AUTHORIZATION_DENIED");
assertThat(ledger.completions).hasSize(1);
ledger.denial = null;
assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse();
assertThat(ledger.completions).hasSize(2);
}
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) {
@@ -72,8 +153,9 @@ class LingxingEgressGateTest {
}
}
@Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse();
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
@@ -193,7 +275,7 @@ class LingxingEgressGateTest {
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
@@ -43,7 +43,7 @@ class LingxingHttpBudgetAuthorityTest {
@AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken);
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), mapper, serviceToken);
}
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
@@ -86,7 +86,7 @@ class LingxingHttpBudgetAuthorityTest {
}
@Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token");
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token");
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
}
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
@@ -254,7 +254,7 @@ class LingxingWmsClientTest {
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null);
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false);
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
};