@koralabs/kora-labs-common 6.13.4 → 6.14.0
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/chain/index.d.ts +1 -0
- package/chain/index.js +1 -0
- package/chain/transport/fetchProviderJson.d.ts +7 -3
- package/chain/transport/fetchProviderJson.js +38 -6
- package/chain/transport/rateLimit.d.ts +21 -0
- package/chain/transport/rateLimit.js +101 -0
- package/cronLock/cqlCronLock.d.ts +49 -0
- package/cronLock/cqlCronLock.js +199 -0
- package/cronLock/index.d.ts +1 -0
- package/cronLock/index.js +19 -0
- package/package.json +7 -2
- package/txBuild/blockfrost.d.ts +66 -0
- package/txBuild/blockfrost.js +209 -0
- package/txBuild/fees.d.ts +32 -0
- package/txBuild/fees.js +62 -0
- package/txBuild/index.d.ts +3 -0
- package/txBuild/index.js +21 -0
- package/txBuild/scriptTx.d.ts +79 -0
- package/txBuild/scriptTx.js +197 -0
- package/utils/cbor/index.d.ts +2 -0
- package/utils/cbor/index.js +27 -24
package/chain/index.d.ts
CHANGED
package/chain/index.js
CHANGED
|
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
// NOT re-exported from the root index — this is a server-side chain-access layer and pulls Node-only
|
|
19
19
|
// deps. Keeping it off the root barrel preserves the isomorphic root (same rule the `aws` module follows).
|
|
20
20
|
__exportStar(require("./transport/fetchProviderJson"), exports);
|
|
21
|
+
__exportStar(require("./transport/rateLimit"), exports);
|
|
21
22
|
__exportStar(require("./failover/interfaces"), exports);
|
|
22
23
|
__exportStar(require("./failover/ChainProviderFailover"), exports);
|
|
23
24
|
__exportStar(require("./cache/ShortTermCache"), exports);
|
|
@@ -1,26 +1,30 @@
|
|
|
1
|
+
import { HeaderReader } from './rateLimit';
|
|
1
2
|
export type ProviderName = 'Koios' | 'Blockfrost';
|
|
2
3
|
type ProviderFetcher = (url: string, init?: {
|
|
3
4
|
method?: string;
|
|
4
5
|
headers?: Record<string, string>;
|
|
5
|
-
body?: string;
|
|
6
|
+
body?: string | Uint8Array;
|
|
6
7
|
}) => Promise<{
|
|
7
8
|
ok: boolean;
|
|
8
9
|
status: number;
|
|
9
10
|
statusText: string;
|
|
10
11
|
text: () => Promise<string>;
|
|
12
|
+
headers?: HeaderReader;
|
|
11
13
|
}>;
|
|
12
14
|
export interface ProviderRequestOptions {
|
|
13
15
|
provider: ProviderName;
|
|
14
16
|
url: string;
|
|
15
17
|
method?: string;
|
|
16
18
|
headers?: Record<string, string>;
|
|
17
|
-
body?: string;
|
|
19
|
+
body?: string | Uint8Array;
|
|
18
20
|
maxRetries?: number;
|
|
19
21
|
retryBaseDelayMs?: number;
|
|
20
22
|
maxRps?: number;
|
|
21
23
|
rateLimitKey?: string;
|
|
24
|
+
/** Longest server-stated rate-limit wait to sit out in-process; longer waits throw RateLimitedError. */
|
|
25
|
+
maxRateLimitWaitMs?: number;
|
|
22
26
|
fetcher?: ProviderFetcher;
|
|
23
27
|
}
|
|
24
28
|
export declare const isRetriableError: (provider: ProviderName, error: any) => boolean;
|
|
25
|
-
export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, fetcher }: ProviderRequestOptions) => Promise<T>;
|
|
29
|
+
export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, maxRateLimitWaitMs, fetcher }: ProviderRequestOptions) => Promise<T>;
|
|
26
30
|
export {};
|
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.fetchProviderJson = exports.isRetriableError = void 0;
|
|
4
4
|
const utils_1 = require("../../utils");
|
|
5
|
+
const rateLimit_1 = require("./rateLimit");
|
|
5
6
|
const DEFAULT_MAX_RPS = 5;
|
|
6
7
|
const DEFAULT_MAX_RETRIES = 2;
|
|
7
8
|
const DEFAULT_RETRY_BASE_DELAY_MS = 500;
|
|
8
|
-
const
|
|
9
|
+
const DEFAULT_MAX_RATE_LIMIT_WAIT_MS = 30000;
|
|
10
|
+
const MAX_RATE_LIMIT_WAITS = 3;
|
|
11
|
+
// 429 is deliberately absent: a rate limit is retried only after the wait the server states (see
|
|
12
|
+
// fetchProviderJson), never on a blind backoff.
|
|
13
|
+
const RETRIABLE_STATUS_CODES = new Set([403, 408, 425, 500, 502, 503, 504]);
|
|
9
14
|
const RETRIABLE_MESSAGE_SNIPPETS = [
|
|
10
15
|
'terminated',
|
|
11
16
|
'socket',
|
|
12
17
|
'econnreset',
|
|
13
18
|
'fetch failed',
|
|
14
19
|
'gateway timeout',
|
|
15
|
-
'too many requests',
|
|
16
20
|
'payload too large',
|
|
17
21
|
'timed out acquiring connection from connection pool',
|
|
18
|
-
'rate limit',
|
|
19
22
|
'service unavailable',
|
|
20
23
|
'temporarily unavailable'
|
|
21
24
|
];
|
|
@@ -126,16 +129,25 @@ const isRetriableError = (provider, error) => {
|
|
|
126
129
|
};
|
|
127
130
|
exports.isRetriableError = isRetriableError;
|
|
128
131
|
const defaultFetch = (url, init) => globalThis.fetch(url, init);
|
|
129
|
-
const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, fetcher = defaultFetch }) => {
|
|
132
|
+
const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, maxRateLimitWaitMs = DEFAULT_MAX_RATE_LIMIT_WAIT_MS, fetcher = defaultFetch }) => {
|
|
130
133
|
var _a, _b, _c, _d;
|
|
131
134
|
let attempt = 0;
|
|
135
|
+
let rateLimitWaits = 0;
|
|
132
136
|
// Retry loop: exits only via return (success) or throw (non-retriable / retries exhausted).
|
|
133
137
|
// eslint-disable-next-line no-constant-condition
|
|
134
138
|
while (true) {
|
|
139
|
+
// A wait recorded by ANY earlier call on this key (this process) is honored before calling again.
|
|
140
|
+
await (0, rateLimit_1.waitForRateLimit)(rateLimitKey, maxRateLimitWaitMs);
|
|
141
|
+
let rateLimited;
|
|
135
142
|
try {
|
|
136
143
|
const response = await runRateLimitedTask(rateLimitKey, maxRps, () => fetcher(url, { method, headers, body }));
|
|
137
144
|
const responseText = await response.text();
|
|
138
145
|
const { value: parsed, parseError } = parseJson(responseText);
|
|
146
|
+
if (response.ok && isQuotaExhausted(response.headers)) {
|
|
147
|
+
const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, undefined);
|
|
148
|
+
if (waitMs !== null)
|
|
149
|
+
(0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
|
|
150
|
+
}
|
|
139
151
|
if (parseError && responseText) {
|
|
140
152
|
throw createProviderError({
|
|
141
153
|
provider,
|
|
@@ -149,9 +161,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
|
|
|
149
161
|
if (!response.ok || payloadError) {
|
|
150
162
|
const status = Number((_b = (_a = payloadError === null || payloadError === void 0 ? void 0 : payloadError.status_code) !== null && _a !== void 0 ? _a : payloadError === null || payloadError === void 0 ? void 0 : payloadError.status) !== null && _b !== void 0 ? _b : response.status);
|
|
151
163
|
const statusText = response.statusText || `${(_d = (_c = payloadError === null || payloadError === void 0 ? void 0 : payloadError.error) !== null && _c !== void 0 ? _c : payloadError === null || payloadError === void 0 ? void 0 : payloadError.message) !== null && _d !== void 0 ? _d : 'Request failed'}`;
|
|
152
|
-
|
|
164
|
+
const error = createProviderError({ provider, status, statusText, payload: payloadError !== null && payloadError !== void 0 ? payloadError : parsed, responseText });
|
|
165
|
+
const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, parsed);
|
|
166
|
+
if (status === 429 || waitMs !== null) {
|
|
167
|
+
rateLimited = { waitMs, error };
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
return parsed;
|
|
153
175
|
}
|
|
154
|
-
return parsed;
|
|
155
176
|
}
|
|
156
177
|
catch (error) {
|
|
157
178
|
if (attempt >= maxRetries || !(0, exports.isRetriableError)(provider, error)) {
|
|
@@ -159,7 +180,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
|
|
|
159
180
|
}
|
|
160
181
|
attempt++;
|
|
161
182
|
await delay(retryBaseDelayMs * attempt);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Rate limited: never call again before the stated time; with no stated time, stop.
|
|
186
|
+
const { waitMs, error } = rateLimited;
|
|
187
|
+
if (waitMs === null)
|
|
188
|
+
throw error;
|
|
189
|
+
(0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
|
|
190
|
+
// Sitting out a stated wait is not a blind retry, so it does not spend `maxRetries` — but it is bounded.
|
|
191
|
+
if (++rateLimitWaits > MAX_RATE_LIMIT_WAITS || waitMs > maxRateLimitWaitMs) {
|
|
192
|
+
throw new rateLimit_1.RateLimitedError(rateLimitKey, waitMs, error.message);
|
|
162
193
|
}
|
|
163
194
|
}
|
|
164
195
|
};
|
|
165
196
|
exports.fetchProviderJson = fetchProviderJson;
|
|
197
|
+
const isQuotaExhausted = (headers) => { var _a, _b; return ((_a = headers === null || headers === void 0 ? void 0 : headers.get('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' || ((_b = headers === null || headers === void 0 ? void 0 : headers.get('ratelimit-remaining')) === null || _b === void 0 ? void 0 : _b.trim()) === '0'; };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface HeaderReader {
|
|
2
|
+
get(name: string): string | null | undefined;
|
|
3
|
+
}
|
|
4
|
+
export declare class RateLimitedError extends Error {
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly retryAfterMs: number;
|
|
7
|
+
constructor(key: string, retryAfterMs: number, detail?: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
|
|
11
|
+
* date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
|
|
12
|
+
* and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
|
|
13
|
+
*/
|
|
14
|
+
export declare const statedWaitMs: (headers: HeaderReader | undefined, body: unknown, now?: number) => number | null;
|
|
15
|
+
/** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
|
|
16
|
+
export declare const recordRateLimit: (key: string, waitMs: number, now?: number) => void;
|
|
17
|
+
export declare const rateLimitWaitRemainingMs: (key: string, now?: number) => number;
|
|
18
|
+
/** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
|
|
19
|
+
export declare const waitForRateLimit: (key: string, maxWaitMs: number) => Promise<void>;
|
|
20
|
+
/** Test seam: forget every recorded limit. */
|
|
21
|
+
export declare const resetRateLimits: () => void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Rate-limit signals are law: every provider call honors the wait a server states, remembers it for
|
|
3
|
+
// the whole process (so other callers don't hit the same limit), and never retries early. When the
|
|
4
|
+
// stated wait is too long to hold the caller, it stops and hands the wait back (RateLimitedError).
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resetRateLimits = exports.waitForRateLimit = exports.rateLimitWaitRemainingMs = exports.recordRateLimit = exports.statedWaitMs = exports.RateLimitedError = void 0;
|
|
7
|
+
class RateLimitedError extends Error {
|
|
8
|
+
constructor(key, retryAfterMs, detail = '') {
|
|
9
|
+
super(`Rate limited by ${key}: retry after ${Math.ceil(retryAfterMs / 1000)}s${detail ? ` (${detail})` : ''}`);
|
|
10
|
+
this.key = key;
|
|
11
|
+
this.retryAfterMs = retryAfterMs;
|
|
12
|
+
this.name = 'RateLimitedError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.RateLimitedError = RateLimitedError;
|
|
16
|
+
const blockedUntilByKey = new Map();
|
|
17
|
+
const toMs = (seconds) => Math.max(0, Math.ceil(seconds * 1000));
|
|
18
|
+
const parseSeconds = (raw) => {
|
|
19
|
+
if (raw === null || raw === undefined || raw.trim() === '')
|
|
20
|
+
return null;
|
|
21
|
+
const value = Number(raw.trim().replace(/s$/i, ''));
|
|
22
|
+
return Number.isFinite(value) ? value : null;
|
|
23
|
+
};
|
|
24
|
+
/** `Retry-After` is either delta-seconds or an HTTP date. */
|
|
25
|
+
const parseRetryAfter = (raw, now) => {
|
|
26
|
+
const seconds = parseSeconds(raw);
|
|
27
|
+
if (seconds !== null)
|
|
28
|
+
return toMs(seconds);
|
|
29
|
+
if (!raw)
|
|
30
|
+
return null;
|
|
31
|
+
const date = Date.parse(raw);
|
|
32
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : null;
|
|
33
|
+
};
|
|
34
|
+
/** `X-RateLimit-Reset` is an epoch (seconds or ms) on most APIs, a delta on a few; disambiguate by size. */
|
|
35
|
+
const parseReset = (raw, now) => {
|
|
36
|
+
const value = parseSeconds(raw);
|
|
37
|
+
if (value === null)
|
|
38
|
+
return null;
|
|
39
|
+
if (value > 1e12)
|
|
40
|
+
return Math.max(0, value - now); // epoch ms
|
|
41
|
+
if (value > 1e9)
|
|
42
|
+
return Math.max(0, value * 1000 - now); // epoch seconds
|
|
43
|
+
return toMs(value); // delta seconds
|
|
44
|
+
};
|
|
45
|
+
const bodyWaitMs = (body) => {
|
|
46
|
+
var _a, _b;
|
|
47
|
+
if (!body || typeof body !== 'object')
|
|
48
|
+
return null;
|
|
49
|
+
const record = body;
|
|
50
|
+
// Discord: { retry_after: seconds }
|
|
51
|
+
const retryAfter = parseSeconds(record.retry_after === undefined ? undefined : String(record.retry_after));
|
|
52
|
+
if (retryAfter !== null)
|
|
53
|
+
return toMs(retryAfter);
|
|
54
|
+
// Google: { error: { details: [{ '@type': '...RetryInfo', retryDelay: '1.5s' }] } }
|
|
55
|
+
const details = (_b = (_a = record.error) === null || _a === void 0 ? void 0 : _a.details) !== null && _b !== void 0 ? _b : [];
|
|
56
|
+
const retryInfo = details.find((d) => typeof (d === null || d === void 0 ? void 0 : d['@type']) === 'string' && d['@type'].endsWith('RetryInfo'));
|
|
57
|
+
const delay = parseSeconds(retryInfo === null || retryInfo === void 0 ? void 0 : retryInfo.retryDelay);
|
|
58
|
+
return delay === null ? null : toMs(delay);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
|
|
62
|
+
* date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
|
|
63
|
+
* and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
|
|
64
|
+
*/
|
|
65
|
+
const statedWaitMs = (headers, body, now = Date.now()) => {
|
|
66
|
+
var _a;
|
|
67
|
+
const header = (name) => { var _a; return (_a = headers === null || headers === void 0 ? void 0 : headers.get(name)) !== null && _a !== void 0 ? _a : null; };
|
|
68
|
+
const candidates = [
|
|
69
|
+
parseRetryAfter(header('retry-after'), now),
|
|
70
|
+
parseSeconds(header('x-ratelimit-reset-after')) === null ? null : toMs(parseSeconds(header('x-ratelimit-reset-after'))),
|
|
71
|
+
parseSeconds(header('ratelimit-reset')) === null ? null : toMs(parseSeconds(header('ratelimit-reset'))),
|
|
72
|
+
((_a = header('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' ? parseReset(header('x-ratelimit-reset'), now) : null,
|
|
73
|
+
bodyWaitMs(body)
|
|
74
|
+
].filter((ms) => ms !== null);
|
|
75
|
+
return candidates.length > 0 ? Math.max(...candidates) : null;
|
|
76
|
+
};
|
|
77
|
+
exports.statedWaitMs = statedWaitMs;
|
|
78
|
+
/** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
|
|
79
|
+
const recordRateLimit = (key, waitMs, now = Date.now()) => {
|
|
80
|
+
var _a;
|
|
81
|
+
const until = now + waitMs;
|
|
82
|
+
if (until > ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0))
|
|
83
|
+
blockedUntilByKey.set(key, until);
|
|
84
|
+
};
|
|
85
|
+
exports.recordRateLimit = recordRateLimit;
|
|
86
|
+
const rateLimitWaitRemainingMs = (key, now = Date.now()) => { var _a; return Math.max(0, ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0) - now); };
|
|
87
|
+
exports.rateLimitWaitRemainingMs = rateLimitWaitRemainingMs;
|
|
88
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
|
+
/** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
|
|
90
|
+
const waitForRateLimit = async (key, maxWaitMs) => {
|
|
91
|
+
const remaining = (0, exports.rateLimitWaitRemainingMs)(key);
|
|
92
|
+
if (remaining === 0)
|
|
93
|
+
return;
|
|
94
|
+
if (remaining > maxWaitMs)
|
|
95
|
+
throw new RateLimitedError(key, remaining);
|
|
96
|
+
await sleep(remaining);
|
|
97
|
+
};
|
|
98
|
+
exports.waitForRateLimit = waitForRateLimit;
|
|
99
|
+
/** Test seam: forget every recorded limit. */
|
|
100
|
+
const resetRateLimits = () => blockedUntilByKey.clear();
|
|
101
|
+
exports.resetRateLimits = resetRateLimits;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface CronLockTopology {
|
|
2
|
+
network: string;
|
|
3
|
+
nodeCode: string;
|
|
4
|
+
mintNodes: string[];
|
|
5
|
+
localDataCenter: string;
|
|
6
|
+
contactPoints: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface CqlExecutor {
|
|
9
|
+
execute(query: string, params?: unknown[], options?: Record<string, unknown>): Promise<{
|
|
10
|
+
rows: unknown[];
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export type AcquireResult = {
|
|
14
|
+
status: 'acquired';
|
|
15
|
+
token: string;
|
|
16
|
+
} | {
|
|
17
|
+
status: 'held';
|
|
18
|
+
} | {
|
|
19
|
+
status: 'peerUnavailable';
|
|
20
|
+
token: string;
|
|
21
|
+
message: string;
|
|
22
|
+
} | {
|
|
23
|
+
status: 'unavailable';
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
/** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
|
|
27
|
+
export declare const resolveTopology: (env?: NodeJS.ProcessEnv) => CronLockTopology;
|
|
28
|
+
/**
|
|
29
|
+
* Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
|
|
30
|
+
* their job needs (it must exceed the job's maximum run time); the default honors
|
|
31
|
+
* STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
|
|
32
|
+
*/
|
|
33
|
+
export declare const leaseMs: (override?: number) => number;
|
|
34
|
+
export declare const parseOffline: (raw: string | undefined | null) => string[];
|
|
35
|
+
type Mode = 'serial' | 'localSerial' | 'standDown';
|
|
36
|
+
export declare const resolveMode: (offline: string[], topology: CronLockTopology) => Mode;
|
|
37
|
+
/** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
|
|
38
|
+
export declare const isPeerUnavailableError: (error: unknown) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The directly testable LWT operation. Each invocation owns a unique token, so
|
|
41
|
+
* concurrent ticks on one node cannot both treat the node identity as ownership.
|
|
42
|
+
*/
|
|
43
|
+
export declare const acquireWithExecutor: (executor: CqlExecutor, name: string, offline: string | null | undefined, topology: CronLockTopology, token?: string, lease?: number) => Promise<AcquireResult>;
|
|
44
|
+
export declare const acquire: (name: string, offline?: string | null, options?: {
|
|
45
|
+
leaseMs?: number;
|
|
46
|
+
}) => Promise<AcquireResult>;
|
|
47
|
+
/** Release only the exact acquisition token owned by this invocation. */
|
|
48
|
+
export declare const release: (name: string, token: string, offline?: string | null) => Promise<void>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.release = exports.acquire = exports.acquireWithExecutor = exports.isPeerUnavailableError = exports.resolveMode = exports.parseOffline = exports.leaseMs = exports.resolveTopology = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Cross-datacenter cron mutex for the self-hosted, multi-DC Scylla topology.
|
|
6
|
+
* (Moved from minting.handle.me src/helpers/cronLock/cqlCronLock.ts @ 6ecb9c3 so every box cron
|
|
7
|
+
* — minting.handle.me, hal-minting-engine — shares one implementation.)
|
|
8
|
+
*
|
|
9
|
+
* Minting runs active/active across boxes, each in its own Scylla datacenter with
|
|
10
|
+
* RF=1 per DC. Alternator conditional writes use LOCAL_SERIAL, so each DC can win
|
|
11
|
+
* independently. CQL SERIAL is the cross-DC mutex.
|
|
12
|
+
*
|
|
13
|
+
* Operators declare unavailable boxes through the offline set:
|
|
14
|
+
* - all boxes online: SERIAL / QUORUM;
|
|
15
|
+
* - this box offline: stand down;
|
|
16
|
+
* - this box is the only declared-online box: LOCAL_SERIAL / LOCAL_QUORUM;
|
|
17
|
+
* - an undeclared peer outage: the caller normally fails closed. StateData may
|
|
18
|
+
* use its DC-local fallback only for mintPaidSessions and only after a definite
|
|
19
|
+
* CQL UNAVAILABLE response (never after a timeout or unknown failure).
|
|
20
|
+
*
|
|
21
|
+
* The row TTL recovers crashed holders. Unique per-invocation owner tokens prevent
|
|
22
|
+
* overlapping ticks on one box from mistaking node identity for lock ownership.
|
|
23
|
+
*/
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const cassandra_driver_1 = require("cassandra-driver");
|
|
26
|
+
const logger_1 = require("../logger");
|
|
27
|
+
const KEYSPACE = 'kora_locks';
|
|
28
|
+
const LOCK_TABLE = 'cron_lock';
|
|
29
|
+
const DEFAULT_LEASE_MS = 30 * 60 * 1000;
|
|
30
|
+
const required = (env, name) => {
|
|
31
|
+
var _a;
|
|
32
|
+
const value = (_a = env[name]) === null || _a === void 0 ? void 0 : _a.trim();
|
|
33
|
+
if (!value)
|
|
34
|
+
throw new Error(`${name} is required for cron-lock topology`);
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
const parseList = (value) => [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
38
|
+
/** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
|
|
39
|
+
const resolveTopology = (env = process.env) => {
|
|
40
|
+
const network = required(env, 'NETWORK').toLowerCase();
|
|
41
|
+
const nodeCode = required(env, 'KORA_NODE_CODE').toLowerCase();
|
|
42
|
+
const mintNodes = parseList(required(env, 'KORA_MINT_NODES'));
|
|
43
|
+
const localDataCenter = required(env, 'KORA_SCYLLA_DC');
|
|
44
|
+
const contactPoints = parseList(required(env, 'KORA_CQL_CONTACT_POINTS'));
|
|
45
|
+
if (!mintNodes.includes(nodeCode)) {
|
|
46
|
+
throw new Error(`KORA_NODE_CODE '${nodeCode}' is not present in KORA_MINT_NODES`);
|
|
47
|
+
}
|
|
48
|
+
if (contactPoints.length === 0) {
|
|
49
|
+
throw new Error('KORA_CQL_CONTACT_POINTS must contain at least one contact point');
|
|
50
|
+
}
|
|
51
|
+
return { network, nodeCode, mintNodes, localDataCenter, contactPoints };
|
|
52
|
+
};
|
|
53
|
+
exports.resolveTopology = resolveTopology;
|
|
54
|
+
/**
|
|
55
|
+
* Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
|
|
56
|
+
* their job needs (it must exceed the job's maximum run time); the default honors
|
|
57
|
+
* STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
|
|
58
|
+
*/
|
|
59
|
+
const leaseMs = (override) => {
|
|
60
|
+
if (override !== undefined) {
|
|
61
|
+
if (!Number.isFinite(override) || override <= 0)
|
|
62
|
+
throw new Error(`Invalid cron-lock lease ${override}`);
|
|
63
|
+
return override;
|
|
64
|
+
}
|
|
65
|
+
const parsed = Number(process.env.STALE_EXECUTING_CRON_LOCK_MS);
|
|
66
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LEASE_MS;
|
|
67
|
+
};
|
|
68
|
+
exports.leaseMs = leaseMs;
|
|
69
|
+
const leaseSeconds = (override) => Math.ceil((0, exports.leaseMs)(override) / 1000);
|
|
70
|
+
const scopeName = (name, topology) => `${topology.network}:${name}`;
|
|
71
|
+
let client = null;
|
|
72
|
+
let clientTopologyKey = '';
|
|
73
|
+
let ensured = false;
|
|
74
|
+
const getClient = (topology) => {
|
|
75
|
+
const topologyKey = JSON.stringify([topology.contactPoints, topology.localDataCenter]);
|
|
76
|
+
if (!client || clientTopologyKey !== topologyKey) {
|
|
77
|
+
client = new cassandra_driver_1.Client({
|
|
78
|
+
contactPoints: topology.contactPoints,
|
|
79
|
+
localDataCenter: topology.localDataCenter,
|
|
80
|
+
socketOptions: { connectTimeout: 8000, readTimeout: 12000 }
|
|
81
|
+
});
|
|
82
|
+
clientTopologyKey = topologyKey;
|
|
83
|
+
ensured = false;
|
|
84
|
+
}
|
|
85
|
+
return client;
|
|
86
|
+
};
|
|
87
|
+
const ensureSchema = async (executor) => {
|
|
88
|
+
if (ensured)
|
|
89
|
+
return;
|
|
90
|
+
await executor.execute(`CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} WITH replication = ` +
|
|
91
|
+
`{'class':'NetworkTopologyStrategy','kora-dc1':1,'kora-dc2':1}`);
|
|
92
|
+
await executor.execute(`CREATE TABLE IF NOT EXISTS ${KEYSPACE}.${LOCK_TABLE} (name text PRIMARY KEY, owner text, acquired_at timestamp)`);
|
|
93
|
+
ensured = true;
|
|
94
|
+
};
|
|
95
|
+
const parseOffline = (raw) => {
|
|
96
|
+
const value = (raw !== null && raw !== void 0 ? raw : '').trim().toLowerCase();
|
|
97
|
+
if (!value || value === 'none')
|
|
98
|
+
return [];
|
|
99
|
+
return parseList(value);
|
|
100
|
+
};
|
|
101
|
+
exports.parseOffline = parseOffline;
|
|
102
|
+
const resolveMode = (offline, topology) => {
|
|
103
|
+
const unknownNodes = offline.filter((node) => !topology.mintNodes.includes(node));
|
|
104
|
+
if (unknownNodes.length > 0) {
|
|
105
|
+
throw new Error(`Offline set contains unknown mint node(s): ${unknownNodes.join(',')}`);
|
|
106
|
+
}
|
|
107
|
+
if (offline.includes(topology.nodeCode))
|
|
108
|
+
return 'standDown';
|
|
109
|
+
const online = topology.mintNodes.filter((node) => !offline.includes(node));
|
|
110
|
+
return online.length === 1 ? 'localSerial' : 'serial';
|
|
111
|
+
};
|
|
112
|
+
exports.resolveMode = resolveMode;
|
|
113
|
+
const createToken = (topology) => `${topology.nodeCode}:${(0, node_crypto_1.randomUUID)()}`;
|
|
114
|
+
/** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
|
|
115
|
+
const isPeerUnavailableError = (error) => error instanceof cassandra_driver_1.errors.ResponseError && error.code === cassandra_driver_1.types.responseErrorCodes.unavailableException;
|
|
116
|
+
exports.isPeerUnavailableError = isPeerUnavailableError;
|
|
117
|
+
/**
|
|
118
|
+
* The directly testable LWT operation. Each invocation owns a unique token, so
|
|
119
|
+
* concurrent ticks on one node cannot both treat the node identity as ownership.
|
|
120
|
+
*/
|
|
121
|
+
const acquireWithExecutor = async (executor, name, offline, topology, token = createToken(topology), lease) => {
|
|
122
|
+
const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
|
|
123
|
+
if (mode === 'standDown')
|
|
124
|
+
return { status: 'held' };
|
|
125
|
+
const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
|
|
126
|
+
const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
|
|
127
|
+
const key = scopeName(name, topology);
|
|
128
|
+
try {
|
|
129
|
+
const result = await executor.execute(`INSERT INTO ${KEYSPACE}.${LOCK_TABLE} (name, owner, acquired_at) VALUES (?, ?, ?) IF NOT EXISTS USING TTL ${leaseSeconds(lease)}`, [key, token, new Date()], { prepare: true, serialConsistency, consistency });
|
|
130
|
+
const row = result.rows[0];
|
|
131
|
+
if ((row === null || row === void 0 ? void 0 : row['[applied]']) === true)
|
|
132
|
+
return { status: 'acquired', token };
|
|
133
|
+
if ((row === null || row === void 0 ? void 0 : row.owner) !== token)
|
|
134
|
+
return { status: 'held' };
|
|
135
|
+
const refresh = await executor.execute(`UPDATE ${KEYSPACE}.${LOCK_TABLE} USING TTL ${leaseSeconds(lease)} SET owner = ?, acquired_at = ? WHERE name = ? IF owner = ?`, [token, new Date(), key, token], { prepare: true, serialConsistency, consistency });
|
|
136
|
+
const refreshRow = refresh.rows[0];
|
|
137
|
+
return (refreshRow === null || refreshRow === void 0 ? void 0 : refreshRow['[applied]']) === true ? { status: 'acquired', token } : { status: 'held' };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
if (mode === 'serial' && (0, exports.isPeerUnavailableError)(error)) {
|
|
142
|
+
return { status: 'peerUnavailable', token, message };
|
|
143
|
+
}
|
|
144
|
+
return { status: 'unavailable', message };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
exports.acquireWithExecutor = acquireWithExecutor;
|
|
148
|
+
const acquire = async (name, offline, options = {}) => {
|
|
149
|
+
try {
|
|
150
|
+
const topology = (0, exports.resolveTopology)();
|
|
151
|
+
const executor = getClient(topology);
|
|
152
|
+
await ensureSchema(executor);
|
|
153
|
+
const result = await (0, exports.acquireWithExecutor)(executor, name, offline, topology, undefined, options.leaseMs);
|
|
154
|
+
if (result.status === 'peerUnavailable') {
|
|
155
|
+
logger_1.Logger.log({
|
|
156
|
+
message: `cron lock '${name}' SERIAL unavailable because the configured peer quorum is unavailable: ${result.message}`,
|
|
157
|
+
event: 'cqlCronLock.acquire.peerUnavailable',
|
|
158
|
+
category: logger_1.LogCategory.WARN
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
else if (result.status === 'unavailable') {
|
|
162
|
+
logger_1.Logger.log({
|
|
163
|
+
message: `cron lock '${name}' failed without a positively identified peer-unavailable response: ${result.message}`,
|
|
164
|
+
event: 'cqlCronLock.acquire.unavailable',
|
|
165
|
+
category: logger_1.LogCategory.WARN
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger_1.Logger.log({
|
|
172
|
+
message: `cron lock '${name}' is unavailable: ${error.message}`,
|
|
173
|
+
event: 'cqlCronLock.acquire.unavailable',
|
|
174
|
+
category: logger_1.LogCategory.WARN
|
|
175
|
+
});
|
|
176
|
+
return { status: 'unavailable', message: error.message };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
exports.acquire = acquire;
|
|
180
|
+
/** Release only the exact acquisition token owned by this invocation. */
|
|
181
|
+
const release = async (name, token, offline) => {
|
|
182
|
+
try {
|
|
183
|
+
const topology = (0, exports.resolveTopology)();
|
|
184
|
+
const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
|
|
185
|
+
if (mode === 'standDown')
|
|
186
|
+
return;
|
|
187
|
+
const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
|
|
188
|
+
const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
|
|
189
|
+
await getClient(topology).execute(`DELETE FROM ${KEYSPACE}.${LOCK_TABLE} WHERE name = ? IF owner = ?`, [scopeName(name, topology), token], { prepare: true, serialConsistency, consistency });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
logger_1.Logger.log({
|
|
193
|
+
message: `cron lock '${name}' release best-effort failed (TTL will reclaim): ${error.message}`,
|
|
194
|
+
event: 'cqlCronLock.release.softfail',
|
|
195
|
+
category: logger_1.LogCategory.INFO
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
exports.release = release;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './cqlCronLock';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
// Subpath module: `@koralabs/kora-labs-common/cronLock`. Node-only (cassandra-driver, optional peer
|
|
18
|
+
// dependency). NOT re-exported from the root index.
|
|
19
|
+
__exportStar(require("./cqlCronLock"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koralabs/kora-labs-common",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.14.0",
|
|
4
4
|
"description": "Kora Labs Common Utilities",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
|
50
50
|
"@typescript-eslint/parser": "^8.67.0",
|
|
51
51
|
"bip39": "^3.1.0",
|
|
52
|
+
"cassandra-driver": "^4.10.0",
|
|
52
53
|
"eslint": "^8.57.1",
|
|
53
54
|
"jest": "^30.4.2",
|
|
54
55
|
"ts-jest": "^29.4.11",
|
|
@@ -74,8 +75,9 @@
|
|
|
74
75
|
"@aiken-lang/merkle-patricia-forestry": "^1.2.0",
|
|
75
76
|
"@cardano-sdk/core": "^0.46.12 || ^0.47.0",
|
|
76
77
|
"@cardano-sdk/crypto": "^0.4.5",
|
|
78
|
+
"@emurgo/cardano-message-signing-nodejs": "^1.1.0",
|
|
77
79
|
"bip39": "^3.1.0",
|
|
78
|
-
"
|
|
80
|
+
"cassandra-driver": "^4.9.0"
|
|
79
81
|
},
|
|
80
82
|
"peerDependenciesMeta": {
|
|
81
83
|
"@aiken-lang/merkle-patricia-forestry": {
|
|
@@ -92,6 +94,9 @@
|
|
|
92
94
|
},
|
|
93
95
|
"@emurgo/cardano-message-signing-nodejs": {
|
|
94
96
|
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"cassandra-driver": {
|
|
99
|
+
"optional": true
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Cardano } from '@cardano-sdk/core';
|
|
2
|
+
import { ProviderRequestOptions } from '../chain/transport/fetchProviderJson';
|
|
3
|
+
import { ExUnits } from './fees';
|
|
4
|
+
import { RedeemerKey, ScriptTxProtocolParameters } from './scriptTx';
|
|
5
|
+
type BlockfrostAmount = {
|
|
6
|
+
unit: string;
|
|
7
|
+
quantity: string;
|
|
8
|
+
}[];
|
|
9
|
+
export interface BlockfrostScript {
|
|
10
|
+
hash: string;
|
|
11
|
+
language: Cardano.PlutusLanguageVersion | 'native';
|
|
12
|
+
/** Serialized size (the ledger's reference-script size). */
|
|
13
|
+
size: number;
|
|
14
|
+
script?: Cardano.Script;
|
|
15
|
+
}
|
|
16
|
+
export declare class BlockfrostEvaluationError extends Error {
|
|
17
|
+
readonly failure: unknown;
|
|
18
|
+
constructor(failure: unknown);
|
|
19
|
+
}
|
|
20
|
+
export declare const valueFromBlockfrost: (amount: BlockfrostAmount) => Cardano.Value;
|
|
21
|
+
/** Additional-UTxO shape Blockfrost's evaluator accepts: Ogmios value `{ ada: { lovelace }, [policy]: { [asset]: qty } }`. */
|
|
22
|
+
export declare const toOgmiosUtxo: ([txIn, txOut]: Cardano.Utxo) => ({
|
|
23
|
+
txId: any;
|
|
24
|
+
index: number;
|
|
25
|
+
} | {
|
|
26
|
+
datumHash?: any;
|
|
27
|
+
datum?: any;
|
|
28
|
+
address: Tagged<string, T>;
|
|
29
|
+
value: Record<string, Record<string, number>>;
|
|
30
|
+
txId?: undefined;
|
|
31
|
+
index?: undefined;
|
|
32
|
+
})[];
|
|
33
|
+
export declare class BlockfrostTxClient {
|
|
34
|
+
private readonly config;
|
|
35
|
+
private readonly scripts;
|
|
36
|
+
private readonly host;
|
|
37
|
+
constructor(config: {
|
|
38
|
+
network: string;
|
|
39
|
+
blockfrostApiKey: string;
|
|
40
|
+
fetcher?: ProviderRequestOptions['fetcher'];
|
|
41
|
+
});
|
|
42
|
+
private request;
|
|
43
|
+
private getOrNull;
|
|
44
|
+
getProtocolParameters(): Promise<ScriptTxProtocolParameters>;
|
|
45
|
+
getLatestBlock(): Promise<{
|
|
46
|
+
slot: number;
|
|
47
|
+
time: number;
|
|
48
|
+
hash: string;
|
|
49
|
+
height: number;
|
|
50
|
+
}>;
|
|
51
|
+
/** Slot of a POSIX time (ms), from the tip (1-second slots since Shelley on every network). */
|
|
52
|
+
slotAt(posixMs: number): Promise<number>;
|
|
53
|
+
getScript(hash: string): Promise<BlockfrostScript>;
|
|
54
|
+
private toUtxo;
|
|
55
|
+
/** Every UTxO at `address` (paginated). */
|
|
56
|
+
getAddressUtxos(address: string): Promise<Cardano.Utxo[]>;
|
|
57
|
+
/** UTxOs at `address` holding `unit`. */
|
|
58
|
+
getAddressAssetUtxos(address: string, unit: string): Promise<Cardano.Utxo[]>;
|
|
59
|
+
/** The UTxO `txHash#index`; throws `already spent` when it has been consumed. */
|
|
60
|
+
getUtxo(ref: string): Promise<Cardano.Utxo>;
|
|
61
|
+
/** Node (Ogmios) evaluation of every redeemer; `additionalUtxos` supplies inputs not yet on chain. */
|
|
62
|
+
evaluateTx(cbor: string, additionalUtxos?: Cardano.Utxo[]): Promise<Map<RedeemerKey, ExUnits>>;
|
|
63
|
+
/** Submit a signed tx; returns its hash. */
|
|
64
|
+
submitTx(cbor: string): Promise<string>;
|
|
65
|
+
}
|
|
66
|
+
export {};
|