|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `bulkWrite` — the shared batched-write helper used by BOTH the seed loader |
| 5 | + * (`@objectstack/metadata-protocol`) and the data-import runner |
| 6 | + * (`@objectstack/rest`), so neither reimplements batching, transient-error |
| 7 | + * retry, or per-row degradation. See framework#2678. |
| 8 | + * |
| 9 | + * ObjectQL's engine already does the efficient thing when handed an ARRAY — |
| 10 | + * one `driver.bulkCreate` round-trip plus parent-deduplicated summary |
| 11 | + * recompute (`engine.insert(object, rows[])`) — but seed/import fed it one |
| 12 | + * record at a time, so neither got the benefit. This module re-chunks rows |
| 13 | + * into batches and drives them through a caller-supplied batch-write |
| 14 | + * function, adding: |
| 15 | + * |
| 16 | + * - transient-error retry (network blip / timeout) with exponential |
| 17 | + * backoff, so a dropped connection doesn't silently drop the row (the |
| 18 | + * 2026-07-06 HotCRM incident: a turso `fetch failed` mid-seed dropped rows |
| 19 | + * silently because nothing retried); |
| 20 | + * - per-row degradation when a batch fails for a non-transient (logical / |
| 21 | + * validation) reason, so one bad row can't fail the other N-1 — needed |
| 22 | + * because `driver.bulkCreate` is a single multi-row statement/`Promise.all` |
| 23 | + * on every driver in this repo (sql, memory, mongodb): one bad row fails |
| 24 | + * the whole call; |
| 25 | + * - a stable per-row result keyed by the row's original index, so callers |
| 26 | + * can reassemble output in input order even though rows are processed in |
| 27 | + * batches (and a batch's flush may be interleaved with other, immediate, |
| 28 | + * per-row work such as updates). |
| 29 | + */ |
| 30 | + |
| 31 | +export interface BulkWriteRowResult<TRecord = any> { |
| 32 | + /** Index into the original `rows` array passed to {@link bulkWrite}. */ |
| 33 | + index: number; |
| 34 | + ok: boolean; |
| 35 | + record?: TRecord; |
| 36 | + error?: unknown; |
| 37 | +} |
| 38 | + |
| 39 | +export interface RetryOptions { |
| 40 | + /** Max attempts for one write (batch or single-row), including the first. Default 3. */ |
| 41 | + maxRetries?: number; |
| 42 | + /** Base backoff in ms; doubled each retry, plus jitter. Default 200. */ |
| 43 | + backoffBaseMs?: number; |
| 44 | + /** Classifies an error as transient (worth retrying) vs logical (the row/batch is just bad). */ |
| 45 | + isTransientError?: (err: unknown) => boolean; |
| 46 | + /** Injectable sleep, for deterministic tests. */ |
| 47 | + sleep?: (ms: number) => Promise<void>; |
| 48 | +} |
| 49 | + |
| 50 | +export interface BulkWriteOptions<TRow, TRecord = any> extends RetryOptions { |
| 51 | + /** Rows per batch. Default 200 (framework#2678 suggests 100-500). */ |
| 52 | + batchSize?: number; |
| 53 | + /** |
| 54 | + * Write one batch. MUST resolve to one record per input row, in the SAME |
| 55 | + * order as `batch` — {@link bulkWrite} correlates `records[i]` back to |
| 56 | + * `batch[i]` positionally (this is how every `bulkCreate` implementation in |
| 57 | + * this repo already behaves: sql's single `INSERT ... VALUES (...), (...) |
| 58 | + * RETURNING *`, memory's `Promise.all`, mongodb's ordered `insertMany`). |
| 59 | + */ |
| 60 | + writeBatch: (batch: TRow[]) => Promise<TRecord[]>; |
| 61 | + /** Write a single row — used only to degrade a failed batch. */ |
| 62 | + writeOne: (row: TRow) => Promise<TRecord>; |
| 63 | +} |
| 64 | + |
| 65 | +const DEFAULT_BATCH_SIZE = 200; |
| 66 | +const DEFAULT_MAX_RETRIES = 3; |
| 67 | +const DEFAULT_BACKOFF_BASE_MS = 200; |
| 68 | + |
| 69 | +/** |
| 70 | + * Transient-error signatures shared by common HTTP/TCP-backed drivers |
| 71 | + * (turso/libsql's fetch-based transport included). Deliberately excludes |
| 72 | + * anything that looks like a validation/constraint error — those must NOT be |
| 73 | + * retried, only degraded to per-row. |
| 74 | + */ |
| 75 | +const TRANSIENT_PATTERNS: RegExp[] = [ |
| 76 | + /fetch failed/i, |
| 77 | + /network/i, |
| 78 | + /timed?\s*out/i, |
| 79 | + /timeout/i, |
| 80 | + /socket hang ?up/i, |
| 81 | + /connection.*(closed|reset|refused|terminated|aborted)/i, |
| 82 | + /\b(502|503|504)\b/, |
| 83 | + /server.*unavailable/i, |
| 84 | + /too many connections/i, |
| 85 | +]; |
| 86 | + |
| 87 | +const TRANSIENT_CODES = /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|EPIPE|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND)$/i; |
| 88 | + |
| 89 | +export function defaultIsTransientError(err: unknown): boolean { |
| 90 | + const code = (err as { code?: unknown } | null)?.code; |
| 91 | + if (typeof code === 'string' && TRANSIENT_CODES.test(code)) return true; |
| 92 | + const message = (err as { message?: unknown } | null)?.message; |
| 93 | + const text = typeof message === 'string' ? message : String(err ?? ''); |
| 94 | + return TRANSIENT_PATTERNS.some((re) => re.test(text)); |
| 95 | +} |
| 96 | + |
| 97 | +const defaultSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); |
| 98 | + |
| 99 | +interface ResolvedRetryOptions { |
| 100 | + maxRetries: number; |
| 101 | + backoffBaseMs: number; |
| 102 | + isTransientError: (err: unknown) => boolean; |
| 103 | + sleep: (ms: number) => Promise<void>; |
| 104 | +} |
| 105 | + |
| 106 | +async function withRetry<T>(fn: () => Promise<T>, opts: ResolvedRetryOptions): Promise<T> { |
| 107 | + let lastError: unknown; |
| 108 | + for (let attempt = 1; attempt <= opts.maxRetries; attempt++) { |
| 109 | + try { |
| 110 | + return await fn(); |
| 111 | + } catch (err) { |
| 112 | + lastError = err; |
| 113 | + if (attempt >= opts.maxRetries || !opts.isTransientError(err)) throw err; |
| 114 | + const jitter = Math.floor(Math.random() * 50); |
| 115 | + await opts.sleep(opts.backoffBaseMs * 2 ** (attempt - 1) + jitter); |
| 116 | + } |
| 117 | + } |
| 118 | + // Unreachable — the loop above always returns or throws — but keeps TS's |
| 119 | + // control-flow analysis happy about a guaranteed return type. |
| 120 | + throw lastError; |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Retry a single write (e.g. an `engine.update()` call the seed loader or |
| 125 | + * import runner makes outside the batched-insert path) with the same |
| 126 | + * transient-error backoff {@link bulkWrite} applies to batches — so a |
| 127 | + * network blip doesn't drop an update the way it used to drop an insert. |
| 128 | + */ |
| 129 | +export async function withTransientRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> { |
| 130 | + return withRetry(fn, { |
| 131 | + maxRetries: Math.max(1, opts.maxRetries ?? DEFAULT_MAX_RETRIES), |
| 132 | + backoffBaseMs: opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS, |
| 133 | + isTransientError: opts.isTransientError ?? defaultIsTransientError, |
| 134 | + sleep: opts.sleep ?? defaultSleep, |
| 135 | + }); |
| 136 | +} |
| 137 | + |
| 138 | +/** |
| 139 | + * Write `rows` through `opts.writeBatch` in chunks of `opts.batchSize`, |
| 140 | + * retrying a whole-batch transient failure with backoff, and degrading to |
| 141 | + * per-row `opts.writeOne` calls (each itself retried) when a batch fails for |
| 142 | + * a non-transient reason — so one bad row can't drop the rest of the batch. |
| 143 | + * |
| 144 | + * Returns one {@link BulkWriteRowResult} per input row, indexed to match |
| 145 | + * `rows`' original order. |
| 146 | + */ |
| 147 | +export async function bulkWrite<TRow, TRecord = any>( |
| 148 | + rows: TRow[], |
| 149 | + opts: BulkWriteOptions<TRow, TRecord>, |
| 150 | +): Promise<BulkWriteRowResult<TRecord>[]> { |
| 151 | + const batchSize = Math.max(1, opts.batchSize ?? DEFAULT_BATCH_SIZE); |
| 152 | + const retryOpts: ResolvedRetryOptions = { |
| 153 | + maxRetries: Math.max(1, opts.maxRetries ?? DEFAULT_MAX_RETRIES), |
| 154 | + backoffBaseMs: opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS, |
| 155 | + isTransientError: opts.isTransientError ?? defaultIsTransientError, |
| 156 | + sleep: opts.sleep ?? defaultSleep, |
| 157 | + }; |
| 158 | + |
| 159 | + const results: BulkWriteRowResult<TRecord>[] = new Array(rows.length); |
| 160 | + |
| 161 | + for (let start = 0; start < rows.length; start += batchSize) { |
| 162 | + const batch = rows.slice(start, start + batchSize); |
| 163 | + try { |
| 164 | + const records = await withRetry(() => opts.writeBatch(batch), retryOpts); |
| 165 | + for (let i = 0; i < batch.length; i++) { |
| 166 | + results[start + i] = { index: start + i, ok: true, record: records[i] }; |
| 167 | + } |
| 168 | + } catch (batchErr) { |
| 169 | + // A single-row "batch" already IS the per-row attempt — its failure |
| 170 | + // (after transient retry) is the row's final outcome; calling |
| 171 | + // `writeOne` again would just repeat the identical work. |
| 172 | + if (batch.length === 1) { |
| 173 | + results[start] = { index: start, ok: false, error: batchErr }; |
| 174 | + continue; |
| 175 | + } |
| 176 | + // The batch failed even after transient retry, or failed for a logical |
| 177 | + // reason retry wouldn't fix. Degrade to per-row so one bad row can't |
| 178 | + // fail the other rows in this batch. Each row still gets its own |
| 179 | + // transient retry — the batch-level failure doesn't tell us which row |
| 180 | + // (if any) was actually the transient one. |
| 181 | + for (let i = 0; i < batch.length; i++) { |
| 182 | + const idx = start + i; |
| 183 | + try { |
| 184 | + const record = await withRetry(() => opts.writeOne(batch[i]), retryOpts); |
| 185 | + results[idx] = { index: idx, ok: true, record }; |
| 186 | + } catch (err) { |
| 187 | + results[idx] = { index: idx, ok: false, error: err }; |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + return results; |
| 194 | +} |
0 commit comments