Repository navigation
Expand file tree
/
Copy pathUsageLimitSources.ts
More file actions
200 lines (186 loc) · 7.63 KB
/
Copy pathUsageLimitSources.ts
File metadata and controls
200 lines (186 loc) · 7.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import {
DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
UsageLimitSourceError,
type UsageLimitSourceConsumeResetCreditInput,
type ProviderConsumeResetCreditResult,
type ServerSettings,
UsageLimitSourceConfig,
settingsSecretLiteral,
type UsageLimitSourceId,
type UsageLimitSourceSnapshot,
} from "@t3tools/contracts";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as SecretResolverLayer from "../secrets/SecretResolver.ts";
import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts";
import * as Settings from "../serverSettings.ts";
import { makeCliproxyApi } from "./cliproxyApi.ts";
export class UsageLimitSources extends Context.Service<
UsageLimitSources,
{
readonly current: Effect.Effect<ReadonlyArray<UsageLimitSourceSnapshot>>;
readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>;
readonly refresh: Effect.Effect<void>;
readonly consumeResetCredit: (
input: UsageLimitSourceConsumeResetCreditInput,
) => Effect.Effect<ProviderConsumeResetCreditResult, UsageLimitSourceError>;
}
>()("t3/usage/UsageLimitSources") {}
function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
if (config.label) return config.label;
const fallback = id.trim() || "Usage limit source";
try {
return new URL(config.url).host || fallback;
} catch {
return fallback;
}
}
function missingManagementKey(config: UsageLimitSourceConfig): string {
return typeof config.managementKey === "string"
? "No management key configured."
: "Could not read the management key from its secret provider.";
}
/** @public */
export const make = Effect.gen(function* () {
const api = yield* makeCliproxyApi;
const settingsService = yield* Settings.ServerSettingsService;
const secrets = yield* SecretResolver.SecretResolver;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* (
config: UsageLimitSourceConfig,
) {
const resolved = yield* resolveSettingsSecrets(secrets, UsageLimitSourceConfig, config);
return settingsSecretLiteral(resolved.managementKey);
});
const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
const changes = yield* Effect.acquireRelease(
PubSub.unbounded<ReadonlyArray<UsageLimitSourceSnapshot>>(),
PubSub.shutdown,
);
const readSource = Effect.fn("UsageLimitSources.readSource")(function* (
id: UsageLimitSourceId,
config: UsageLimitSourceConfig,
) {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const;
const managementKey = yield* readManagementKey(config);
if (managementKey.length === 0) {
return { ...base, accounts: [], error: missingManagementKey(config) };
}
const accounts = yield* api
.readAccounts({ url: config.url, managementKey })
.pipe(Effect.result);
if (accounts._tag === "Failure") {
yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });
return { ...base, accounts: [], error: accounts.failure.detail };
}
return { ...base, accounts: accounts.success };
});
const publish = Effect.fn("UsageLimitSources.publish")(function* (
next: ReadonlyArray<UsageLimitSourceSnapshot>,
) {
const changed = yield* Ref.modify(stateRef, (previous) =>
Equal.equals(previous, next) ? [false, previous] : [true, next],
);
if (changed) yield* PubSub.publish(changes, next);
});
// Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes.
const refreshLock = yield* Semaphore.make(1);
const refresh = Effect.gen(function* () {
const settings = yield* settingsService.getSettings.pipe(
Effect.orElseSucceed((): ServerSettings | null => null),
);
const entries = Object.entries(settings?.usageLimitSources ?? {}).filter(
([, config]) => config.enabled,
);
const snapshots = yield* Effect.forEach(
entries,
([id, config]) => readSource(id as UsageLimitSourceId, config),
{ concurrency: 4 },
);
yield* publish(snapshots);
}).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true }));
// Shares the refresh lock so a stale in-flight read cannot overwrite a redemption.
const consumeResetCredit = Effect.fn("UsageLimitSources.consumeResetCredit")(function* (
input: UsageLimitSourceConsumeResetCreditInput,
) {
const settings = yield* settingsService.getSettings.pipe(
Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })),
);
const config = settings.usageLimitSources[input.sourceId];
if (!config?.enabled) {
return yield* new UsageLimitSourceError({
detail: "The usage limit source is missing or disabled.",
});
}
const managementKey = yield* readManagementKey(config);
if (managementKey.length === 0) {
return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) });
}
const result = yield* api.consume(
{ url: config.url, managementKey },
input.accountId,
input.creditId,
);
const snapshot = yield* readSource(input.sourceId, config);
const previous = yield* Ref.get(stateRef);
yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source)));
return result;
}, refreshLock.withPermits(1));
yield* settingsService.streamChanges.pipe(
Stream.map((settings) => settings.usageLimitSources),
Stream.changes,
Stream.runForEach(() => refresh),
Effect.forkScoped,
);
const interval = settingsService.getSettings.pipe(
Effect.map(
(settings) => resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval,
),
Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL),
);
yield* Effect.forever(
interval.pipe(
Effect.flatMap((wait) => Effect.sleep(Duration.isPositive(wait) ? wait : "60 seconds")),
Effect.andThen(interval),
Effect.flatMap((current) =>
Duration.isPositive(current)
? backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })
: Effect.succeed(false),
),
Effect.flatMap((shouldRun) => (shouldRun ? refresh : Effect.void)),
Effect.ignoreCause({ log: true }),
),
).pipe(Effect.forkScoped);
yield* refresh.pipe(Effect.forkScoped);
return {
current: Ref.get(stateRef),
consumeResetCredit,
refresh,
get streamChanges() {
return Stream.unwrap(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(changes);
const snapshot = yield* Ref.get(stateRef);
return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)).pipe(
Stream.changes,
);
}),
);
},
} satisfies UsageLimitSources["Service"];
});
export const layer = Layer.effect(UsageLimitSources, make).pipe(
Layer.provide(SecretResolverLayer.layer),
);