Fix P0 issues in financial ingestion architecture
- Wrap snapshot updates in transactions with error context for each child table - Add sidecar retry with exponential backoff (3 attempts, 2s base, 10s max, 30% jitter) - Add HTTP timeout (30s per request) and SEC rate limiting (10 req/s) in Rust - Add XBRL validation with status reporting (checks root element, tag balance)
This commit is contained in:
22
lib/server/utils/index.ts
Normal file
22
lib/server/utils/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
normalizeTicker,
|
||||
normalizeTickerOrNull,
|
||||
normalizeTags,
|
||||
normalizeTagsOrNull,
|
||||
normalizeOptionalString,
|
||||
normalizeRecord,
|
||||
normalizePositiveInteger,
|
||||
nowIso,
|
||||
todayIso
|
||||
} from './normalize';
|
||||
|
||||
export {
|
||||
asRecord,
|
||||
asOptionalRecord,
|
||||
asPositiveNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
asEnum
|
||||
} from './validation';
|
||||
|
||||
export { withRetry, type RetryOptions } from './retry';
|
||||
51
lib/server/utils/normalize.ts
Normal file
51
lib/server/utils/normalize.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export function normalizeTicker(ticker: string): string {
|
||||
return ticker.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function normalizeTickerOrNull(ticker: unknown): string | null {
|
||||
if (typeof ticker !== 'string') return null;
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function normalizeTags(tags?: unknown): string[] {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const entry of tags) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
const tag = entry.trim();
|
||||
if (tag) unique.add(tag);
|
||||
}
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
export function normalizeTagsOrNull(tags?: unknown): string[] | null {
|
||||
const result = normalizeTags(tags);
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
export function normalizeOptionalString(value?: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function normalizeRecord(value?: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizePositiveInteger(value?: unknown): number | null {
|
||||
if (value === null || value === undefined || !Number.isFinite(value as number)) return null;
|
||||
const normalized = Math.trunc(value as number);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
59
lib/server/utils/retry.ts
Normal file
59
lib/server/utils/retry.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface RetryOptions {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
jitterFactor: number;
|
||||
retryableErrors: RegExp[];
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 2000,
|
||||
maxDelayMs: 10000,
|
||||
jitterFactor: 0.3,
|
||||
retryableErrors: [
|
||||
/timeout/i,
|
||||
/ECONNRESET/,
|
||||
/ETIMEDOUT/,
|
||||
/ENOTFOUND/,
|
||||
/exit code 1/,
|
||||
/signal/,
|
||||
/killed/
|
||||
]
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: Partial<RetryOptions>
|
||||
): Promise<T> {
|
||||
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
const isRetryable = opts.retryableErrors.some(
|
||||
(pattern) => pattern.test(lastError!.message)
|
||||
);
|
||||
|
||||
if (!isRetryable || attempt === opts.maxRetries - 1) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const baseDelay = opts.baseDelayMs * Math.pow(2, attempt);
|
||||
const jitter = Math.random() * opts.jitterFactor * baseDelay;
|
||||
const delay = Math.min(baseDelay + jitter, opts.maxDelayMs);
|
||||
|
||||
console.warn(
|
||||
`[retry] Attempt ${attempt + 1}/${opts.maxRetries} failed, retrying in ${Math.round(delay)}ms: ${lastError.message}`
|
||||
);
|
||||
|
||||
await Bun.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
56
lib/server/utils/validation.ts
Normal file
56
lib/server/utils/validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asPositiveNumber(value: unknown): number | null {
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',')
|
||||
: [];
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const entry of source) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
const tag = entry.trim();
|
||||
if (tag) unique.add(tag);
|
||||
}
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
export function asEnum<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user