@tokenbuddy/tokenbuddy 1.0.9 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/buyer-store.d.ts +13 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +21 -2
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +54 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +118 -0
- package/dist/src/credit-tracker.d.ts.map +1 -0
- package/dist/src/credit-tracker.js +220 -0
- package/dist/src/credit-tracker.js.map +1 -0
- package/dist/src/daemon.d.ts +49 -4
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +541 -405
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +86 -0
- package/dist/src/model-index.d.ts.map +1 -0
- package/dist/src/model-index.js +214 -0
- package/dist/src/model-index.js.map +1 -0
- package/dist/src/prewarm-cache.d.ts +149 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -0
- package/dist/src/prewarm-cache.js +288 -0
- package/dist/src/prewarm-cache.js.map +1 -0
- package/dist/src/prewarm-scheduler.d.ts +150 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -0
- package/dist/src/prewarm-scheduler.js +484 -0
- package/dist/src/prewarm-scheduler.js.map +1 -0
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +10 -0
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +96 -0
- package/dist/src/route-failover.d.ts.map +1 -0
- package/dist/src/route-failover.js +177 -0
- package/dist/src/route-failover.js.map +1 -0
- package/dist/src/seller-catalog.d.ts +26 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +40 -0
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +127 -0
- package/dist/src/seller-pool.d.ts.map +1 -0
- package/dist/src/seller-pool.js +243 -0
- package/dist/src/seller-pool.js.map +1 -0
- package/dist/src/stream-failover.d.ts +78 -0
- package/dist/src/stream-failover.d.ts.map +1 -0
- package/dist/src/stream-failover.js +93 -0
- package/dist/src/stream-failover.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +32 -2
- package/src/cli.ts +61 -0
- package/src/credit-tracker.test.ts +165 -0
- package/src/credit-tracker.ts +269 -0
- package/src/daemon.ts +569 -445
- package/src/model-index.test.ts +184 -0
- package/src/model-index.ts +266 -0
- package/src/prewarm-cache.test.ts +281 -0
- package/src/prewarm-cache.ts +373 -0
- package/src/prewarm-scheduler.test.ts +367 -0
- package/src/prewarm-scheduler.ts +581 -0
- package/src/provider-install.ts +10 -0
- package/src/route-failover.test.ts +193 -0
- package/src/route-failover.ts +233 -0
- package/src/seller-catalog-413.test.ts +61 -0
- package/src/seller-catalog.ts +47 -0
- package/src/seller-pool.test.ts +231 -0
- package/src/seller-pool.ts +333 -0
- package/src/stream-failover.test.ts +52 -0
- package/src/stream-failover.ts +129 -0
- package/src/thousand-seller.test.ts +151 -0
- package/tests/daemon-413-fallback.test.ts +92 -0
- package/tests/e2e.test.ts +3 -2
- package/tests/tokenbuddy.test.ts +70 -11
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreditTracker,
|
|
3
|
+
DEFAULT_FRESH_PURCHASE_THRESHOLD,
|
|
4
|
+
DEFAULT_FRESH_PURCHASE_WINDOW_MS,
|
|
5
|
+
DEFAULT_PURCHASE_BUDGET_PER_MINUTE
|
|
6
|
+
} from "../src/credit-tracker.js";
|
|
7
|
+
|
|
8
|
+
describe("CreditTracker", () => {
|
|
9
|
+
function makeClock(start = 1_000_000): { now: number; advance: (ms: number) => void } {
|
|
10
|
+
const clock = { now: start, advance: (ms: number) => { clock.now += ms; } };
|
|
11
|
+
return clock;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("recordPurchase creates an entry with the supplied balance", () => {
|
|
15
|
+
const clock = makeClock();
|
|
16
|
+
const tracker = new CreditTracker({ now: () => clock.now });
|
|
17
|
+
const entry = tracker.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
18
|
+
expect(entry.sellerId).toBe("s1");
|
|
19
|
+
expect(entry.lastPurchaseAmountMicros).toBe(1_000_000);
|
|
20
|
+
expect(entry.currentBalanceMicros).toBe(1_000_000);
|
|
21
|
+
expect(entry.leftoverCreditMicros).toBe(0);
|
|
22
|
+
expect(tracker.summary().purchasesInLastMinute).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("recordPurchase rejects non-positive amounts", () => {
|
|
26
|
+
const tracker = new CreditTracker();
|
|
27
|
+
expect(() => tracker.recordPurchase("s1", 0, 0)).toThrow();
|
|
28
|
+
expect(() => tracker.recordPurchase("s1", -1, -1)).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("recordSpend clamps to zero and is a no-op for unknown sellers", () => {
|
|
32
|
+
const tracker = new CreditTracker();
|
|
33
|
+
expect(tracker.recordSpend("missing", 100)).toBeUndefined();
|
|
34
|
+
tracker.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
35
|
+
const updated = tracker.recordSpend("s1", 600_000);
|
|
36
|
+
expect(updated?.currentBalanceMicros).toBe(600_000);
|
|
37
|
+
const zeroed = tracker.recordSpend("s1", -10);
|
|
38
|
+
expect(zeroed?.currentBalanceMicros).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("isInFreshPurchaseWindow is true only when balance ratio and time window are both satisfied", () => {
|
|
42
|
+
const clock = makeClock();
|
|
43
|
+
const tracker = new CreditTracker({ now: () => clock.now });
|
|
44
|
+
tracker.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
45
|
+
|
|
46
|
+
// Right after purchase: 100% balance, well within the window.
|
|
47
|
+
expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
|
|
48
|
+
|
|
49
|
+
// After 50% spend: ratio = 0.5, still at the default threshold.
|
|
50
|
+
tracker.recordSpend("s1", 500_000);
|
|
51
|
+
expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
|
|
52
|
+
|
|
53
|
+
// After more spend the ratio drops below threshold.
|
|
54
|
+
tracker.recordSpend("s1", 100_000);
|
|
55
|
+
expect(tracker.isInFreshPurchaseWindow("s1")).toBe(false);
|
|
56
|
+
|
|
57
|
+
// Replenish via a new purchase and verify window restarts.
|
|
58
|
+
clock.advance(1_000);
|
|
59
|
+
tracker.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
60
|
+
expect(tracker.isInFreshPurchaseWindow("s1")).toBe(true);
|
|
61
|
+
|
|
62
|
+
// Advance past the window: even a full balance no longer counts.
|
|
63
|
+
clock.advance(DEFAULT_FRESH_PURCHASE_WINDOW_MS + 1);
|
|
64
|
+
expect(tracker.isInFreshPurchaseWindow("s1")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("isInFreshPurchaseWindow returns false for sellers without any purchase", () => {
|
|
68
|
+
const tracker = new CreditTracker();
|
|
69
|
+
expect(tracker.isInFreshPurchaseWindow("never-purchased")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("canAutoPurchase enforces a per-minute budget and prunes old timestamps", () => {
|
|
73
|
+
const clock = makeClock();
|
|
74
|
+
const tracker = new CreditTracker({ now: () => clock.now });
|
|
75
|
+
|
|
76
|
+
expect(tracker.canAutoPurchase()).toBe(true);
|
|
77
|
+
tracker.recordPurchase("s1", 100, 100);
|
|
78
|
+
tracker.recordPurchase("s2", 100, 100);
|
|
79
|
+
tracker.recordPurchase("s3", 100, 100);
|
|
80
|
+
expect(tracker.canAutoPurchase()).toBe(false); // budget hit
|
|
81
|
+
|
|
82
|
+
// Advance past the 60s window: oldest timestamps fall out.
|
|
83
|
+
clock.advance(60_001);
|
|
84
|
+
expect(tracker.canAutoPurchase()).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("transferLeftoverToWasted moves the current balance into the leftover bucket and tracks waste", () => {
|
|
88
|
+
const clock = makeClock();
|
|
89
|
+
const tracker = new CreditTracker({ now: () => clock.now });
|
|
90
|
+
tracker.recordPurchase("s1", 1_000_000, 800_000);
|
|
91
|
+
const wasted = tracker.transferLeftoverToWasted("s1", "failover");
|
|
92
|
+
expect(wasted).toBe(800_000);
|
|
93
|
+
const entry = tracker.getEntry("s1");
|
|
94
|
+
expect(entry?.currentBalanceMicros).toBe(0);
|
|
95
|
+
expect(entry?.leftoverCreditMicros).toBe(800_000);
|
|
96
|
+
const summary = tracker.summary();
|
|
97
|
+
expect(summary.totalWastedMicros).toBe(800_000);
|
|
98
|
+
expect(summary.wastedSinceLastDoctorRun).toBe(800_000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("transferLeftoverToWasted is a no-op for unknown sellers or empty balances", () => {
|
|
102
|
+
const tracker = new CreditTracker();
|
|
103
|
+
expect(tracker.transferLeftoverToWasted("missing", "x")).toBe(0);
|
|
104
|
+
tracker.recordPurchase("s1", 100, 0);
|
|
105
|
+
expect(tracker.transferLeftoverToWasted("s1", "x")).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("consumeLeftover drains the leftover bucket up to the requested amount", () => {
|
|
109
|
+
const tracker = new CreditTracker();
|
|
110
|
+
tracker.recordPurchase("s1", 1_000_000, 500_000);
|
|
111
|
+
tracker.transferLeftoverToWasted("s1", "failover");
|
|
112
|
+
expect(tracker.consumeLeftover("s1", 300_000)).toBe(300_000);
|
|
113
|
+
expect(tracker.getEntry("s1")?.leftoverCreditMicros).toBe(200_000);
|
|
114
|
+
// Requesting more than available returns only the available amount.
|
|
115
|
+
expect(tracker.consumeLeftover("s1", 999_999)).toBe(200_000);
|
|
116
|
+
expect(tracker.getEntry("s1")?.leftoverCreditMicros).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("consumeLeftover is a no-op for sellers without leftover", () => {
|
|
120
|
+
const tracker = new CreditTracker();
|
|
121
|
+
expect(tracker.consumeLeftover("missing", 100)).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("summary includes per-seller entries with latest values", () => {
|
|
125
|
+
const clock = makeClock();
|
|
126
|
+
const tracker = new CreditTracker({ now: () => clock.now });
|
|
127
|
+
tracker.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
128
|
+
clock.advance(1_000);
|
|
129
|
+
tracker.recordPurchase("s2", 500_000, 500_000);
|
|
130
|
+
const summary = tracker.summary();
|
|
131
|
+
expect(summary.perSeller).toHaveLength(2);
|
|
132
|
+
const s2 = summary.perSeller.find((entry) => entry.sellerId === "s2");
|
|
133
|
+
expect(s2?.currentBalanceMicros).toBe(500_000);
|
|
134
|
+
expect(s2?.lastPurchaseAt).toBe(clock.now);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("resetDoctorRunCounter clears the wasted-since-last-doctor counter", () => {
|
|
138
|
+
const tracker = new CreditTracker();
|
|
139
|
+
tracker.recordPurchase("s1", 1_000_000, 500_000);
|
|
140
|
+
tracker.transferLeftoverToWasted("s1", "failover");
|
|
141
|
+
expect(tracker.summary().wastedSinceLastDoctorRun).toBe(500_000);
|
|
142
|
+
tracker.resetDoctorRunCounter();
|
|
143
|
+
expect(tracker.summary().wastedSinceLastDoctorRun).toBe(0);
|
|
144
|
+
// totalWastedMicros is preserved.
|
|
145
|
+
expect(tracker.summary().totalWastedMicros).toBe(500_000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("clear removes all entries and resets all counters", () => {
|
|
149
|
+
const tracker = new CreditTracker();
|
|
150
|
+
tracker.recordPurchase("s1", 100, 100);
|
|
151
|
+
tracker.transferLeftoverToWasted("s1", "failover");
|
|
152
|
+
tracker.clear();
|
|
153
|
+
const summary = tracker.summary();
|
|
154
|
+
expect(summary.totalWastedMicros).toBe(0);
|
|
155
|
+
expect(summary.wastedSinceLastDoctorRun).toBe(0);
|
|
156
|
+
expect(summary.purchasesInLastMinute).toBe(0);
|
|
157
|
+
expect(summary.perSeller).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("default constants match the v1.1 / v1.2 design", () => {
|
|
161
|
+
expect(DEFAULT_FRESH_PURCHASE_WINDOW_MS).toBe(30_000);
|
|
162
|
+
expect(DEFAULT_FRESH_PURCHASE_THRESHOLD).toBe(0.5);
|
|
163
|
+
expect(DEFAULT_PURCHASE_BUDGET_PER_MINUTE).toBe(3);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
2
|
+
|
|
3
|
+
const logger = createModuleLogger("tb-proxyd:credit-tracker");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default window after a successful purchase during which failover is
|
|
7
|
+
* softened (see buyer-driven-fallback-design.md §17.3). 30 seconds is the
|
|
8
|
+
* v1.1 starting point; v1.2 keeps the same value.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_FRESH_PURCHASE_WINDOW_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default threshold of "still considered fresh": the current balance must
|
|
14
|
+
* still cover this fraction of the last purchase amount for the entry to
|
|
15
|
+
* be treated as "刚买". 0.5 means "at least half of the purchased credit is
|
|
16
|
+
* still untouched".
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_FRESH_PURCHASE_THRESHOLD = 0.5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maximum number of automatic purchases a single session may trigger per
|
|
22
|
+
* minute. When the budget is exhausted, route-failover must `切` rather than
|
|
23
|
+
* `重买` (see §17.5).
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_PURCHASE_BUDGET_PER_MINUTE = 3;
|
|
26
|
+
|
|
27
|
+
export interface CreditTrackerOptions {
|
|
28
|
+
freshPurchaseWindowMs?: number;
|
|
29
|
+
freshPurchaseThreshold?: number;
|
|
30
|
+
purchaseBudgetPerMinute?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SellerCredit {
|
|
35
|
+
sellerId: string;
|
|
36
|
+
lastPurchaseAt: number;
|
|
37
|
+
lastPurchaseAmountMicros: number;
|
|
38
|
+
currentBalanceMicros: number;
|
|
39
|
+
leftoverCreditMicros: number;
|
|
40
|
+
lastUpdatedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Tracks per-seller credit state for the buyer-driven-fallback design's
|
|
45
|
+
* "balance protection" rules. The tracker is intentionally agnostic of the
|
|
46
|
+
* actual seller HTTP layer: callers feed in `recordPurchase`,
|
|
47
|
+
* `recordSpend`, and `transferLeftover` events as they observe them, and
|
|
48
|
+
* the tracker answers "is this seller still inside its fresh-purchase
|
|
49
|
+
* window?" / "may I auto-purchase again this minute?".
|
|
50
|
+
*
|
|
51
|
+
* The tracker is process-local: a buyer restart resets the session
|
|
52
|
+
* counters. See §17.11.4 for the rationale.
|
|
53
|
+
*/
|
|
54
|
+
export class CreditTracker {
|
|
55
|
+
private readonly freshPurchaseWindowMs: number;
|
|
56
|
+
private readonly freshPurchaseThreshold: number;
|
|
57
|
+
private readonly purchaseBudgetPerMinute: number;
|
|
58
|
+
private readonly now: () => number;
|
|
59
|
+
|
|
60
|
+
private readonly entries = new Map<string, SellerCredit>();
|
|
61
|
+
private readonly purchaseTimestamps: number[] = [];
|
|
62
|
+
private totalWastedMicros = 0;
|
|
63
|
+
private wastedSinceLastDoctorRun = 0;
|
|
64
|
+
|
|
65
|
+
constructor(options: CreditTrackerOptions = {}) {
|
|
66
|
+
this.freshPurchaseWindowMs = options.freshPurchaseWindowMs ?? DEFAULT_FRESH_PURCHASE_WINDOW_MS;
|
|
67
|
+
this.freshPurchaseThreshold = options.freshPurchaseThreshold ?? DEFAULT_FRESH_PURCHASE_THRESHOLD;
|
|
68
|
+
this.purchaseBudgetPerMinute = options.purchaseBudgetPerMinute ?? DEFAULT_PURCHASE_BUDGET_PER_MINUTE;
|
|
69
|
+
this.now = options.now ?? Date.now;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a successful purchase. Updates the seller's balance, the
|
|
74
|
+
* session-wide budget window, and the "fresh purchase" timestamps. If
|
|
75
|
+
* the seller is unknown, an entry is created.
|
|
76
|
+
*/
|
|
77
|
+
recordPurchase(sellerId: string, amountMicros: number, balanceMicros: number): SellerCredit {
|
|
78
|
+
if (!Number.isFinite(amountMicros) || amountMicros <= 0) {
|
|
79
|
+
throw new Error("recordPurchase requires a positive amountMicros");
|
|
80
|
+
}
|
|
81
|
+
const ts = this.now();
|
|
82
|
+
const previous = this.entries.get(sellerId);
|
|
83
|
+
const entry: SellerCredit = {
|
|
84
|
+
sellerId,
|
|
85
|
+
lastPurchaseAt: ts,
|
|
86
|
+
lastPurchaseAmountMicros: amountMicros,
|
|
87
|
+
currentBalanceMicros: Math.max(0, balanceMicros),
|
|
88
|
+
leftoverCreditMicros: previous?.leftoverCreditMicros ?? 0,
|
|
89
|
+
lastUpdatedAt: ts
|
|
90
|
+
};
|
|
91
|
+
this.entries.set(sellerId, entry);
|
|
92
|
+
this.purchaseTimestamps.push(ts);
|
|
93
|
+
this.prunePurchaseTimestamps(ts);
|
|
94
|
+
logger.info("credit.purchase.recorded", "seller credit purchase recorded", {
|
|
95
|
+
sellerId,
|
|
96
|
+
amountMicros,
|
|
97
|
+
balanceMicros: entry.currentBalanceMicros,
|
|
98
|
+
purchasesInLastMinute: this.purchaseTimestamps.length
|
|
99
|
+
});
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Update the seller's current balance after a successful inference. The
|
|
105
|
+
* amount spent is implicit (`previous - next`) so the caller does not
|
|
106
|
+
* have to track it. A non-positive `balanceMicros` is treated as 0.
|
|
107
|
+
*/
|
|
108
|
+
recordSpend(sellerId: string, balanceMicros: number): SellerCredit | undefined {
|
|
109
|
+
const previous = this.entries.get(sellerId);
|
|
110
|
+
if (!previous) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const next: SellerCredit = {
|
|
114
|
+
...previous,
|
|
115
|
+
currentBalanceMicros: Math.max(0, balanceMicros),
|
|
116
|
+
lastUpdatedAt: this.now()
|
|
117
|
+
};
|
|
118
|
+
this.entries.set(sellerId, next);
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Move the seller's remaining balance to the `leftoverCreditMicros`
|
|
124
|
+
* bucket. Called when failover triggers while a balance is still
|
|
125
|
+
* available. The pool's pre-warm / probe logic can later attempt to
|
|
126
|
+
* consume the leftover credit (see PR-3.1). The wasted amount is also
|
|
127
|
+
* added to the session-level counters.
|
|
128
|
+
*/
|
|
129
|
+
transferLeftoverToWasted(sellerId: string, reason: string): number {
|
|
130
|
+
const previous = this.entries.get(sellerId);
|
|
131
|
+
if (!previous) {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const wasted = previous.currentBalanceMicros;
|
|
135
|
+
if (wasted <= 0) {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const next: SellerCredit = {
|
|
139
|
+
...previous,
|
|
140
|
+
currentBalanceMicros: 0,
|
|
141
|
+
leftoverCreditMicros: previous.leftoverCreditMicros + wasted,
|
|
142
|
+
lastUpdatedAt: this.now()
|
|
143
|
+
};
|
|
144
|
+
this.entries.set(sellerId, next);
|
|
145
|
+
this.totalWastedMicros += wasted;
|
|
146
|
+
this.wastedSinceLastDoctorRun += wasted;
|
|
147
|
+
logger.warn("credit.leftover.wasted", "leftover credit logged as wasted on failover", {
|
|
148
|
+
sellerId,
|
|
149
|
+
wastedMicros: wasted,
|
|
150
|
+
reason,
|
|
151
|
+
totalWastedMicros: this.totalWastedMicros
|
|
152
|
+
});
|
|
153
|
+
return wasted;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Consume leftover credit when the seller recovers and a probe
|
|
158
|
+
* successfully burns it down. Returns the amount actually consumed.
|
|
159
|
+
*/
|
|
160
|
+
consumeLeftover(sellerId: string, amountMicros: number): number {
|
|
161
|
+
const previous = this.entries.get(sellerId);
|
|
162
|
+
if (!previous || previous.leftoverCreditMicros <= 0) {
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
const consume = Math.max(0, Math.min(amountMicros, previous.leftoverCreditMicros));
|
|
166
|
+
if (consume <= 0) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
const next: SellerCredit = {
|
|
170
|
+
...previous,
|
|
171
|
+
leftoverCreditMicros: previous.leftoverCreditMicros - consume,
|
|
172
|
+
lastUpdatedAt: this.now()
|
|
173
|
+
};
|
|
174
|
+
this.entries.set(sellerId, next);
|
|
175
|
+
logger.info("credit.leftover.consumed", "leftover credit consumed by recovery probe", {
|
|
176
|
+
sellerId,
|
|
177
|
+
consumedMicros: consume,
|
|
178
|
+
remainingMicros: next.leftoverCreditMicros
|
|
179
|
+
});
|
|
180
|
+
return consume;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns `true` when the seller is still inside the fresh-purchase
|
|
185
|
+
* window: at least `freshPurchaseThreshold` of the most recent
|
|
186
|
+
* purchase is still unused, and the window has not elapsed. Sellers
|
|
187
|
+
* with no recorded purchase are never "fresh".
|
|
188
|
+
*/
|
|
189
|
+
isInFreshPurchaseWindow(sellerId: string, now: number = this.now()): boolean {
|
|
190
|
+
const entry = this.entries.get(sellerId);
|
|
191
|
+
if (!entry || entry.lastPurchaseAmountMicros <= 0) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (now - entry.lastPurchaseAt > this.freshPurchaseWindowMs) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const ratio = entry.currentBalanceMicros / entry.lastPurchaseAmountMicros;
|
|
198
|
+
return ratio >= this.freshPurchaseThreshold;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns `true` when the per-minute auto-purchase budget is still
|
|
203
|
+
* available. Used by `route-failover` to refuse auto-repurchase and
|
|
204
|
+
* force a clean `切` once the session is at risk of over-spending.
|
|
205
|
+
*/
|
|
206
|
+
canAutoPurchase(now: number = this.now()): boolean {
|
|
207
|
+
this.prunePurchaseTimestamps(now);
|
|
208
|
+
return this.purchaseTimestamps.length < this.purchaseBudgetPerMinute;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Snapshot for `tb doctor`. Mirrors the design's "Credit Usage" block.
|
|
213
|
+
*/
|
|
214
|
+
summary(): CreditSummary {
|
|
215
|
+
return {
|
|
216
|
+
totalWastedMicros: this.totalWastedMicros,
|
|
217
|
+
wastedSinceLastDoctorRun: this.wastedSinceLastDoctorRun,
|
|
218
|
+
purchasesInLastMinute: this.purchaseTimestamps.length,
|
|
219
|
+
purchaseBudgetPerMinute: this.purchaseBudgetPerMinute,
|
|
220
|
+
freshPurchaseWindowMs: this.freshPurchaseWindowMs,
|
|
221
|
+
freshPurchaseThreshold: this.freshPurchaseThreshold,
|
|
222
|
+
perSeller: Array.from(this.entries.values()).map((entry) => ({
|
|
223
|
+
sellerId: entry.sellerId,
|
|
224
|
+
currentBalanceMicros: entry.currentBalanceMicros,
|
|
225
|
+
lastPurchaseAmountMicros: entry.lastPurchaseAmountMicros,
|
|
226
|
+
lastPurchaseAt: entry.lastPurchaseAt,
|
|
227
|
+
leftoverCreditMicros: entry.leftoverCreditMicros
|
|
228
|
+
}))
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getEntry(sellerId: string): SellerCredit | undefined {
|
|
233
|
+
return this.entries.get(sellerId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
resetDoctorRunCounter(): void {
|
|
237
|
+
this.wastedSinceLastDoctorRun = 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
clear(): void {
|
|
241
|
+
this.entries.clear();
|
|
242
|
+
this.purchaseTimestamps.length = 0;
|
|
243
|
+
this.totalWastedMicros = 0;
|
|
244
|
+
this.wastedSinceLastDoctorRun = 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private prunePurchaseTimestamps(now: number): void {
|
|
248
|
+
const cutoff = now - 60_000;
|
|
249
|
+
while (this.purchaseTimestamps.length > 0 && this.purchaseTimestamps[0] < cutoff) {
|
|
250
|
+
this.purchaseTimestamps.shift();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface CreditSummary {
|
|
256
|
+
totalWastedMicros: number;
|
|
257
|
+
wastedSinceLastDoctorRun: number;
|
|
258
|
+
purchasesInLastMinute: number;
|
|
259
|
+
purchaseBudgetPerMinute: number;
|
|
260
|
+
freshPurchaseWindowMs: number;
|
|
261
|
+
freshPurchaseThreshold: number;
|
|
262
|
+
perSeller: Array<{
|
|
263
|
+
sellerId: string;
|
|
264
|
+
currentBalanceMicros: number;
|
|
265
|
+
lastPurchaseAmountMicros: number;
|
|
266
|
+
lastPurchaseAt: number;
|
|
267
|
+
leftoverCreditMicros: number;
|
|
268
|
+
}>;
|
|
269
|
+
}
|