mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
fix(host-health): retain trial ownership until requests settle
This commit is contained in:
1 parent
812ec69fd4
commit
0011878b09
14 files changed
+606
-54
No files matched your search
@@ -15,7 +15,6 @@ const HOST = 'http://portal.example.com:8080';
|
||||
const GUARD_DISABLED_ENV = 'IPTVNATOR_DISABLE_CONNECTIVITY_GUARD';
|
||||
const OPEN_DURATION_MS = 30_000;
|
||||
const FAILURE_WINDOW_MS = 120_000;
|
||||
const TRIAL_TIMEOUT_MS = 45_000;
|
||||
|
||||
function timeoutError(code = 'ETIMEDOUT') {
|
||||
return Object.assign(new Error('timeout of 30000ms exceeded'), { code });
|
||||
@@ -431,6 +430,32 @@ describe('HostConnectivityGuard', () => {
|
||||
expect(guard.check(HOST).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a live trial exclusive beyond 45 seconds and the idle TTL', () => {
|
||||
const trial = expectAllowed();
|
||||
advance(45_001);
|
||||
expectBlocked();
|
||||
advance(600_001);
|
||||
guard.check('http://another.example');
|
||||
expectBlocked();
|
||||
guard.reportSuccess(trial);
|
||||
expect(expectAllowed().trial).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'reportSuccess',
|
||||
'reportFailure',
|
||||
'reportInconclusive',
|
||||
] as const)(
|
||||
'releases a completed trial via %s while the environment override is disabled',
|
||||
(report) => {
|
||||
const trial = expectAllowed();
|
||||
process.env[GUARD_DISABLED_ENV] = '1';
|
||||
guard[report](trial);
|
||||
delete process.env[GUARD_DISABLED_ENV];
|
||||
expect(expectAllowed().trial).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('closes the breaker when the trial succeeds', () => {
|
||||
const trial = expectAllowed();
|
||||
guard.reportSuccess(trial);
|
||||
@@ -461,12 +486,9 @@ describe('HostConnectivityGuard', () => {
|
||||
});
|
||||
|
||||
it('lets only the request holding the slot release it', () => {
|
||||
// A trial can outlive its own 45 s window: the validated-redirect
|
||||
// transport gives each of up to five hops its own 30 s budget. Once
|
||||
// a replacement has been admitted, the abandoned request's late
|
||||
// report must not hand the slot to a third request.
|
||||
// Cleanup may run again after a replacement has acquired the slot.
|
||||
const abandoned = expectAllowed();
|
||||
advance(TRIAL_TIMEOUT_MS);
|
||||
guard.reportInconclusive(abandoned);
|
||||
const replacement = expectAllowed();
|
||||
expect(replacement.trial).toBe(true);
|
||||
|
||||
@@ -475,11 +497,24 @@ describe('HostConnectivityGuard', () => {
|
||||
expectBlocked();
|
||||
});
|
||||
|
||||
it('recovers when a trial never reports back', () => {
|
||||
expectAllowed();
|
||||
it('keeps the replacement slot when the old owner reports a late failure', () => {
|
||||
const old = expectAllowed();
|
||||
guard.reportInconclusive(old);
|
||||
const replacement = expectAllowed();
|
||||
guard.reportFailure(old);
|
||||
advance(OPEN_DURATION_MS + 1);
|
||||
expectBlocked();
|
||||
guard.reportInconclusive(old);
|
||||
expectBlocked();
|
||||
guard.reportSuccess(replacement);
|
||||
expect(expectAllowed().trial).toBe(false);
|
||||
});
|
||||
|
||||
advance(TRIAL_TIMEOUT_MS);
|
||||
it('recovers when the owner releases a trial without an outcome report', () => {
|
||||
const trial = expectAllowed();
|
||||
expectBlocked();
|
||||
// The request owner's finally is independent of outcome reporting.
|
||||
guard.reportInconclusive(trial);
|
||||
|
||||
const replacement = expectAllowed();
|
||||
expect(replacement.trial).toBe(true);
|
||||
|
||||
@@ -46,12 +46,6 @@ export const OPEN_DURATION_MS = 30_000;
|
||||
* 30 s timeouts must fit inside it, hence comfortably above 60 s.
|
||||
*/
|
||||
const FAILURE_WINDOW_MS = 120_000;
|
||||
/**
|
||||
* Safety net for a half-open trial that never reports back. Above the longest
|
||||
* request timeout (30 s) plus margin, so it only fires if a caller leaked the
|
||||
* token — without it a lost report would keep the breaker open forever.
|
||||
*/
|
||||
const TRIAL_TIMEOUT_MS = 45_000;
|
||||
/** Hosts tracked at once; portals per user are few, this is just a bound. */
|
||||
const MAX_TRACKED_HOSTS = 256;
|
||||
/** Idle records are forgotten; they hold nothing worth remembering. */
|
||||
@@ -111,11 +105,8 @@ export interface HostRequestToken {
|
||||
/**
|
||||
* Which half-open slot this request holds, when it holds one.
|
||||
*
|
||||
* A trial can outlive its own window — `requestWithValidatedRedirects`
|
||||
* gives each of up to five redirect hops its own 30 s budget — and once a
|
||||
* replacement has been admitted, the abandoned request must not be able to
|
||||
* free the replacement's slot. `trial: true` alone cannot tell the two
|
||||
* apart, so the owner is identified explicitly.
|
||||
* Cleanup from a released owner can arrive after another trial has been
|
||||
* admitted. Only this identity (plus epoch) may release the current slot.
|
||||
*/
|
||||
readonly trialId: number;
|
||||
}
|
||||
@@ -133,7 +124,7 @@ interface HostState {
|
||||
/** Latest guard-wide admission id when this host's last failure counted. */
|
||||
lastFailureAdmissionId: number;
|
||||
openUntil: number;
|
||||
trialStartedAt: number | null;
|
||||
trialInFlight: boolean;
|
||||
/** Monotonic id of the half-open slot; see `HostRequestToken.trialId`. */
|
||||
trialId: number;
|
||||
epoch: number;
|
||||
@@ -247,13 +238,10 @@ export class HostConnectivityGuard {
|
||||
// let one request through and hold the rest back until it settles.
|
||||
let trial = false;
|
||||
if (state.openUntil > 0) {
|
||||
const trialStale =
|
||||
state.trialStartedAt !== null &&
|
||||
now - state.trialStartedAt >= TRIAL_TIMEOUT_MS;
|
||||
if (state.trialStartedAt !== null && !trialStale) {
|
||||
if (state.trialInFlight) {
|
||||
return { allowed: false, retryAfterMs: 0 };
|
||||
}
|
||||
state.trialStartedAt = now;
|
||||
state.trialInFlight = true;
|
||||
state.trialId += 1;
|
||||
trial = true;
|
||||
}
|
||||
@@ -276,21 +264,29 @@ export class HostConnectivityGuard {
|
||||
*/
|
||||
reportSuccess(token: HostRequestToken): void {
|
||||
const state = this.states.get(token.endpoint);
|
||||
if (!state || isHostConnectivityGuardDisabled()) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (isHostConnectivityGuardDisabled()) {
|
||||
this.releaseTrial(state, token);
|
||||
return;
|
||||
}
|
||||
|
||||
state.consecutiveFailures = 0;
|
||||
state.lastFailureAt = 0;
|
||||
state.openUntil = 0;
|
||||
state.trialStartedAt = null;
|
||||
state.trialInFlight = false;
|
||||
state.lastTouchedAt = this.now();
|
||||
}
|
||||
|
||||
/** The host did not answer at all. */
|
||||
reportFailure(token: HostRequestToken): void {
|
||||
const state = this.states.get(token.endpoint);
|
||||
if (!state || isHostConnectivityGuardDisabled()) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (isHostConnectivityGuardDisabled()) {
|
||||
this.releaseTrial(state, token);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,7 +303,7 @@ export class HostConnectivityGuard {
|
||||
// through the streak rules below and leaves the replacement alone.
|
||||
const wasTrial = this.ownsTrial(state, token);
|
||||
if (wasTrial) {
|
||||
state.trialStartedAt = null;
|
||||
state.trialInFlight = false;
|
||||
}
|
||||
state.lastTouchedAt = now;
|
||||
|
||||
@@ -354,10 +350,13 @@ export class HostConnectivityGuard {
|
||||
/**
|
||||
* The request failed for a reason that says nothing about the host. Only
|
||||
* releases the half-open slot, so the next request can be the trial.
|
||||
* Owners MUST also call this in finally, independently of outcome reporting.
|
||||
* It is idempotent and still releases while the guard is disabled by the environment;
|
||||
* no elapsed-time expiry can distinguish a leak from an active transfer.
|
||||
*/
|
||||
reportInconclusive(token: HostRequestToken): void {
|
||||
const state = this.states.get(token.endpoint);
|
||||
if (!state || isHostConnectivityGuardDisabled()) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -376,7 +375,7 @@ export class HostConnectivityGuard {
|
||||
state.consecutiveFailures = 0;
|
||||
state.lastFailureAt = 0;
|
||||
state.openUntil = 0;
|
||||
state.trialStartedAt = null;
|
||||
state.trialInFlight = false;
|
||||
state.epoch += 1;
|
||||
state.lastTouchedAt = now;
|
||||
}
|
||||
@@ -409,7 +408,7 @@ export class HostConnectivityGuard {
|
||||
private ownsTrial(state: HostState, token: HostRequestToken): boolean {
|
||||
return (
|
||||
token.trial &&
|
||||
state.trialStartedAt !== null &&
|
||||
state.trialInFlight &&
|
||||
state.epoch === token.epoch &&
|
||||
state.trialId === token.trialId
|
||||
);
|
||||
@@ -417,7 +416,7 @@ export class HostConnectivityGuard {
|
||||
|
||||
private releaseTrial(state: HostState, token: HostRequestToken): void {
|
||||
if (this.ownsTrial(state, token)) {
|
||||
state.trialStartedAt = null;
|
||||
state.trialInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +432,7 @@ export class HostConnectivityGuard {
|
||||
lastFailureAt: 0,
|
||||
lastFailureAdmissionId: 0,
|
||||
openUntil: 0,
|
||||
trialStartedAt: null,
|
||||
trialInFlight: false,
|
||||
trialId: 0,
|
||||
epoch: 0,
|
||||
lastTouchedAt: now,
|
||||
@@ -447,7 +446,7 @@ export class HostConnectivityGuard {
|
||||
if (
|
||||
now - state.lastTouchedAt > IDLE_TTL_MS &&
|
||||
state.openUntil <= now &&
|
||||
state.trialStartedAt === null
|
||||
!state.trialInFlight
|
||||
) {
|
||||
this.states.delete(endpoint);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user