@edgedev/firebase 2.2.80 → 2.2.81
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/package.json +1 -1
- package/src/functions.js +3 -1
- package/src/index.js +2 -0
- package/src/kv/kvClient.js +69 -8
- package/src/kv/kvMirror.js +190 -21
- package/src/kv/kvRetryWorker.js +138 -0
package/package.json
CHANGED
package/src/functions.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// START @edge/firebase functions
|
|
2
|
+
const { kvMirrorRetryWorker } = require('./kv/kvRetryWorker')
|
|
3
|
+
exports.kvMirrorRetryWorker = kvMirrorRetryWorker
|
|
2
4
|
exports.edgeFirebase = require('./edgeFirebase')
|
|
3
5
|
exports.cms = require('./cms')
|
|
4
|
-
// END @edge/firebase functions
|
|
6
|
+
// END @edge/firebase functions
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require('dotenv').config({ path: process.env.NODE_ENV === 'production' ? '.env.prod' : '.env.dev' })
|
|
2
2
|
|
|
3
3
|
// START @edge/firebase functions
|
|
4
|
+
const { kvMirrorRetryWorker } = require('./kv/kvRetryWorker')
|
|
5
|
+
exports.kvMirrorRetryWorker = kvMirrorRetryWorker
|
|
4
6
|
exports.edgeFirebase = require('./edgeFirebase')
|
|
5
7
|
exports.cms = require('./cms')
|
|
6
8
|
// END @edge/firebase functions
|
package/src/kv/kvClient.js
CHANGED
|
@@ -11,6 +11,56 @@ if (!accountId || !namespaceId || !apiKey) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const base = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`
|
|
14
|
+
const MAX_RETRIES = Number(process.env.KV_HTTP_MAX_RETRIES || 5)
|
|
15
|
+
const BASE_DELAY_MS = Number(process.env.KV_HTTP_BASE_DELAY_MS || 250)
|
|
16
|
+
const MAX_DELAY_MS = Number(process.env.KV_HTTP_MAX_DELAY_MS || 5000)
|
|
17
|
+
|
|
18
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
19
|
+
|
|
20
|
+
function parseRetryAfterMs(retryAfterHeader) {
|
|
21
|
+
if (!retryAfterHeader)
|
|
22
|
+
return 0
|
|
23
|
+
const raw = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader
|
|
24
|
+
const asNumber = Number(raw)
|
|
25
|
+
if (Number.isFinite(asNumber) && asNumber >= 0)
|
|
26
|
+
return asNumber * 1000
|
|
27
|
+
const asDate = Date.parse(String(raw))
|
|
28
|
+
if (!Number.isNaN(asDate))
|
|
29
|
+
return Math.max(0, asDate - Date.now())
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldRetryError(err) {
|
|
34
|
+
const status = Number(err?.response?.status || 0)
|
|
35
|
+
if (status === 429 || status === 408)
|
|
36
|
+
return true
|
|
37
|
+
if (status >= 500 && status <= 599)
|
|
38
|
+
return true
|
|
39
|
+
if (!status && (err?.code || err?.message))
|
|
40
|
+
return true
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function requestWithRetry(run, label = 'kv-request') {
|
|
45
|
+
let attempt = 0
|
|
46
|
+
for (;;) {
|
|
47
|
+
try {
|
|
48
|
+
return await run()
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (!shouldRetryError(err) || attempt >= MAX_RETRIES)
|
|
52
|
+
throw err
|
|
53
|
+
const retryAfterMs = parseRetryAfterMs(err?.response?.headers?.['retry-after'])
|
|
54
|
+
const expo = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * (2 ** attempt))
|
|
55
|
+
const jitter = Math.floor(Math.random() * 200)
|
|
56
|
+
const delayMs = Math.max(retryAfterMs, expo + jitter)
|
|
57
|
+
const status = err?.response?.status || 'network'
|
|
58
|
+
console.warn(`[kvClient] retrying ${label} (attempt ${attempt + 1}/${MAX_RETRIES}, status ${status}) in ${delayMs}ms`)
|
|
59
|
+
await sleep(delayMs)
|
|
60
|
+
attempt += 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
14
64
|
|
|
15
65
|
function parseJsonIfNeeded(body) {
|
|
16
66
|
if (body === null || body === undefined) {
|
|
@@ -61,7 +111,7 @@ async function put(key, value, opts = undefined) {
|
|
|
61
111
|
form.append('metadata', JSON.stringify(opts.metadata))
|
|
62
112
|
const formHeaders = form.getHeaders()
|
|
63
113
|
Object.assign(headers, formHeaders)
|
|
64
|
-
const res = await axios.put(url, form, { headers })
|
|
114
|
+
const res = await requestWithRetry(() => axios.put(url, form, { headers }), `put:${key}`)
|
|
65
115
|
if (res.status !== 200) {
|
|
66
116
|
throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
|
|
67
117
|
}
|
|
@@ -77,7 +127,7 @@ async function put(key, value, opts = undefined) {
|
|
|
77
127
|
headers['Content-Type'] = 'text/plain'
|
|
78
128
|
}
|
|
79
129
|
|
|
80
|
-
const res = await axios.put(url, data, { headers })
|
|
130
|
+
const res = await requestWithRetry(() => axios.put(url, data, { headers }), `put:${key}`)
|
|
81
131
|
if (res.status !== 200) {
|
|
82
132
|
throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
|
|
83
133
|
}
|
|
@@ -99,11 +149,11 @@ async function putIndexMeta(key, metadata, opts = undefined) {
|
|
|
99
149
|
async function get(key, type = 'text') {
|
|
100
150
|
const url = `${base}/values/${encodeURIComponent(key)}`
|
|
101
151
|
try {
|
|
102
|
-
const res = await axios.get(url, {
|
|
152
|
+
const res = await requestWithRetry(() => axios.get(url, {
|
|
103
153
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
104
154
|
responseType: 'text',
|
|
105
155
|
transformResponse: [x => x],
|
|
106
|
-
})
|
|
156
|
+
}), `get:${key}`)
|
|
107
157
|
if (type === 'json') {
|
|
108
158
|
return parseJsonIfNeeded(res.data)
|
|
109
159
|
}
|
|
@@ -119,9 +169,17 @@ async function get(key, type = 'text') {
|
|
|
119
169
|
|
|
120
170
|
async function del(key) {
|
|
121
171
|
const url = `${base}/values/${encodeURIComponent(key)}`
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
172
|
+
try {
|
|
173
|
+
await requestWithRetry(
|
|
174
|
+
() => axios.delete(url, { headers: { Authorization: `Bearer ${apiKey}` } }),
|
|
175
|
+
`del:${key}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (err?.response?.status === 404)
|
|
180
|
+
return
|
|
181
|
+
throw err
|
|
182
|
+
}
|
|
125
183
|
}
|
|
126
184
|
|
|
127
185
|
/**
|
|
@@ -139,7 +197,10 @@ async function listKeys({ prefix = '', limit = 1000, cursor = '' } = {}) {
|
|
|
139
197
|
if (cursor) {
|
|
140
198
|
u.searchParams.set('cursor', cursor)
|
|
141
199
|
}
|
|
142
|
-
const res = await
|
|
200
|
+
const res = await requestWithRetry(
|
|
201
|
+
() => axios.get(u.toString(), { headers: { Authorization: `Bearer ${apiKey}` } }),
|
|
202
|
+
`list:${prefix || 'all'}`,
|
|
203
|
+
)
|
|
143
204
|
return res.data
|
|
144
205
|
}
|
|
145
206
|
|
package/src/kv/kvMirror.js
CHANGED
|
@@ -5,13 +5,115 @@
|
|
|
5
5
|
// - Index-key metadata always includes { canonical: <canonicalKey> }.
|
|
6
6
|
// - Keeps a small manifest per canonical key to clean up all index keys on delete.
|
|
7
7
|
|
|
8
|
-
const { onDocumentWritten } = require('../config.js')
|
|
8
|
+
const { onDocumentWritten, db, Firestore, logger } = require('../config.js')
|
|
9
9
|
const kv = require('./kvClient')
|
|
10
10
|
|
|
11
11
|
function json(x) {
|
|
12
12
|
return JSON.stringify(x)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const KV_RETRY_TOPIC = process.env.KV_RETRY_TOPIC || 'kv-mirror-retry'
|
|
16
|
+
const INDEX_WRITE_CONCURRENCY = Number(process.env.KV_MIRROR_INDEX_CONCURRENCY || 20)
|
|
17
|
+
|
|
18
|
+
function toSortedUniqueStrings(values = []) {
|
|
19
|
+
return [...new Set((Array.isArray(values) ? values : [])
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(String))]
|
|
22
|
+
.sort()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stableStringify(value) {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return `[${value.map(stableStringify).join(',')}]`
|
|
28
|
+
}
|
|
29
|
+
if (value && typeof value === 'object') {
|
|
30
|
+
const keys = Object.keys(value).sort()
|
|
31
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function areSameStrings(a = [], b = []) {
|
|
37
|
+
if (a.length !== b.length)
|
|
38
|
+
return false
|
|
39
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
40
|
+
if (a[i] !== b[i])
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeConcurrency(value) {
|
|
47
|
+
const n = Number(value)
|
|
48
|
+
if (!Number.isFinite(n) || n < 1)
|
|
49
|
+
return 1
|
|
50
|
+
return Math.floor(n)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runWithConcurrency(items, limit, worker) {
|
|
54
|
+
const values = Array.isArray(items) ? items : []
|
|
55
|
+
if (!values.length)
|
|
56
|
+
return
|
|
57
|
+
const max = normalizeConcurrency(limit)
|
|
58
|
+
let cursor = 0
|
|
59
|
+
const workers = Array.from({ length: Math.min(max, values.length) }, async () => {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const idx = cursor
|
|
62
|
+
cursor += 1
|
|
63
|
+
if (idx >= values.length)
|
|
64
|
+
return
|
|
65
|
+
await worker(values[idx], idx)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
await Promise.all(workers)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function enqueueKvRetry(payload, minuteDelay = 1) {
|
|
72
|
+
const safePayload = payload && typeof payload === 'object' ? payload : {}
|
|
73
|
+
await db.collection('topic-queue').add({
|
|
74
|
+
topic: KV_RETRY_TOPIC,
|
|
75
|
+
payload: {
|
|
76
|
+
...safePayload,
|
|
77
|
+
attempt: Number(safePayload.attempt || 0),
|
|
78
|
+
},
|
|
79
|
+
minuteDelay: Number(minuteDelay || 0),
|
|
80
|
+
retry: 0,
|
|
81
|
+
timestamp: Firestore.FieldValue.serverTimestamp(),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function safeKvOperation({
|
|
86
|
+
run,
|
|
87
|
+
payload,
|
|
88
|
+
label,
|
|
89
|
+
}) {
|
|
90
|
+
try {
|
|
91
|
+
await run()
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const message = String(err?.message || err || 'KV operation failed')
|
|
96
|
+
logger.warn('KV operation failed; queued for retry', {
|
|
97
|
+
label,
|
|
98
|
+
error: message.slice(0, 500),
|
|
99
|
+
key: payload?.key,
|
|
100
|
+
op: payload?.op,
|
|
101
|
+
})
|
|
102
|
+
try {
|
|
103
|
+
await enqueueKvRetry(payload)
|
|
104
|
+
}
|
|
105
|
+
catch (queueErr) {
|
|
106
|
+
logger.error('Failed to enqueue KV retry', {
|
|
107
|
+
label,
|
|
108
|
+
key: payload?.key,
|
|
109
|
+
op: payload?.op,
|
|
110
|
+
error: String(queueErr?.message || queueErr || 'enqueue failed').slice(0, 500),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
15
117
|
function slugIndexValue(value, maxLength = 80) {
|
|
16
118
|
return String(value ?? '')
|
|
17
119
|
.trim()
|
|
@@ -53,6 +155,10 @@ function createKvMirrorHandler({
|
|
|
53
155
|
const data = after?.exists ? after.data() : null
|
|
54
156
|
|
|
55
157
|
const canonicalKey = makeCanonicalKey(params, data)
|
|
158
|
+
if (!canonicalKey) {
|
|
159
|
+
logger.warn('KV mirror skipped due to missing canonical key', { document })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
56
162
|
const indexingEnabled = typeof makeIndexKeys === 'function'
|
|
57
163
|
const manifestKey = indexingEnabled ? `idx:manifest:${canonicalKey}` : null
|
|
58
164
|
|
|
@@ -65,16 +171,25 @@ function createKvMirrorHandler({
|
|
|
65
171
|
catch (_) {
|
|
66
172
|
prev = null
|
|
67
173
|
}
|
|
68
|
-
const keys =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
174
|
+
const keys = toSortedUniqueStrings([
|
|
175
|
+
...(Array.isArray(prev?.indexKeys) ? prev.indexKeys : []),
|
|
176
|
+
canonicalKey,
|
|
177
|
+
manifestKey,
|
|
178
|
+
])
|
|
179
|
+
await runWithConcurrency(keys, INDEX_WRITE_CONCURRENCY, async (key) => {
|
|
180
|
+
await safeKvOperation({
|
|
181
|
+
run: () => kv.del(key),
|
|
182
|
+
payload: { op: 'del', key, source: 'kvMirror' },
|
|
183
|
+
label: `del:${key}`,
|
|
184
|
+
})
|
|
185
|
+
})
|
|
75
186
|
}
|
|
76
187
|
else {
|
|
77
|
-
await
|
|
188
|
+
await safeKvOperation({
|
|
189
|
+
run: () => kv.del(canonicalKey),
|
|
190
|
+
payload: { op: 'del', key: canonicalKey, source: 'kvMirror' },
|
|
191
|
+
label: `del:${canonicalKey}`,
|
|
192
|
+
})
|
|
78
193
|
}
|
|
79
194
|
return
|
|
80
195
|
}
|
|
@@ -85,15 +200,25 @@ function createKvMirrorHandler({
|
|
|
85
200
|
? { ...customMetaCandidate, canonical: canonicalKey }
|
|
86
201
|
: baseMeta
|
|
87
202
|
|
|
88
|
-
|
|
203
|
+
const serializedData = serialize(data)
|
|
204
|
+
await safeKvOperation({
|
|
205
|
+
run: () => kv.put(canonicalKey, serializedData, { metadata: metaValue }),
|
|
206
|
+
payload: {
|
|
207
|
+
op: 'put',
|
|
208
|
+
key: canonicalKey,
|
|
209
|
+
value: serializedData,
|
|
210
|
+
opts: { metadata: metaValue },
|
|
211
|
+
source: 'kvMirror',
|
|
212
|
+
},
|
|
213
|
+
label: `put:${canonicalKey}`,
|
|
214
|
+
})
|
|
89
215
|
|
|
90
216
|
if (!indexingEnabled) {
|
|
91
217
|
return
|
|
92
218
|
}
|
|
93
219
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
.map(String)
|
|
220
|
+
const resolvedIndexKeys = await Promise.resolve(makeIndexKeys(params, data))
|
|
221
|
+
const nextIndexKeys = toSortedUniqueStrings(resolvedIndexKeys || [])
|
|
97
222
|
|
|
98
223
|
let prev = null
|
|
99
224
|
try {
|
|
@@ -103,16 +228,49 @@ function createKvMirrorHandler({
|
|
|
103
228
|
prev = null
|
|
104
229
|
}
|
|
105
230
|
|
|
106
|
-
const oldIndexKeys = Array.isArray(prev?.indexKeys) ? prev.indexKeys : []
|
|
107
|
-
const
|
|
231
|
+
const oldIndexKeys = toSortedUniqueStrings(Array.isArray(prev?.indexKeys) ? prev.indexKeys : [])
|
|
232
|
+
const previousMetaHash = typeof prev?.metadataHash === 'string' ? prev.metadataHash : ''
|
|
233
|
+
const currentMetaHash = stableStringify(metaValue)
|
|
234
|
+
const { toRemove, toAdd } = setDiff(oldIndexKeys, nextIndexKeys)
|
|
235
|
+
const shouldRewriteAllIndexKeys = previousMetaHash !== currentMetaHash
|
|
236
|
+
const keysToUpsert = shouldRewriteAllIndexKeys ? nextIndexKeys : toAdd
|
|
108
237
|
|
|
109
|
-
|
|
110
|
-
|
|
238
|
+
await runWithConcurrency(keysToUpsert, INDEX_WRITE_CONCURRENCY, async (key) => {
|
|
239
|
+
await safeKvOperation({
|
|
240
|
+
run: () => kv.putIndexMeta(key, metaValue),
|
|
241
|
+
payload: {
|
|
242
|
+
op: 'putIndexMeta',
|
|
243
|
+
key,
|
|
244
|
+
metadata: metaValue,
|
|
245
|
+
source: 'kvMirror',
|
|
246
|
+
},
|
|
247
|
+
label: `putIndexMeta:${key}`,
|
|
248
|
+
})
|
|
249
|
+
})
|
|
111
250
|
|
|
112
|
-
|
|
113
|
-
|
|
251
|
+
await runWithConcurrency(toRemove, INDEX_WRITE_CONCURRENCY, async (key) => {
|
|
252
|
+
await safeKvOperation({
|
|
253
|
+
run: () => kv.del(key),
|
|
254
|
+
payload: { op: 'del', key, source: 'kvMirror' },
|
|
255
|
+
label: `del:${key}`,
|
|
256
|
+
})
|
|
257
|
+
})
|
|
114
258
|
|
|
115
|
-
|
|
259
|
+
const manifestUnchanged = areSameStrings(oldIndexKeys, nextIndexKeys)
|
|
260
|
+
&& previousMetaHash === currentMetaHash
|
|
261
|
+
if (!manifestUnchanged) {
|
|
262
|
+
const manifestValue = { indexKeys: nextIndexKeys, metadataHash: currentMetaHash }
|
|
263
|
+
await safeKvOperation({
|
|
264
|
+
run: () => kv.put(manifestKey, manifestValue),
|
|
265
|
+
payload: {
|
|
266
|
+
op: 'put',
|
|
267
|
+
key: manifestKey,
|
|
268
|
+
value: manifestValue,
|
|
269
|
+
source: 'kvMirror',
|
|
270
|
+
},
|
|
271
|
+
label: `put:${manifestKey}`,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
116
274
|
})
|
|
117
275
|
}
|
|
118
276
|
|
|
@@ -121,6 +279,7 @@ function createKvMirrorHandlerFromFields({
|
|
|
121
279
|
uniqueKey,
|
|
122
280
|
indexKeys = [],
|
|
123
281
|
metadataKeys = [],
|
|
282
|
+
metaKeyTruncate = {},
|
|
124
283
|
serialize = json,
|
|
125
284
|
}) {
|
|
126
285
|
if (!uniqueKey || typeof uniqueKey !== 'string') {
|
|
@@ -193,8 +352,18 @@ function createKvMirrorHandlerFromFields({
|
|
|
193
352
|
const makeMetadata = (data) => {
|
|
194
353
|
const meta = {}
|
|
195
354
|
const keys = Array.isArray(metadataKeys) ? metadataKeys : []
|
|
355
|
+
const truncateMap = metaKeyTruncate && typeof metaKeyTruncate === 'object'
|
|
356
|
+
? metaKeyTruncate
|
|
357
|
+
: {}
|
|
196
358
|
for (const key of keys) {
|
|
197
|
-
|
|
359
|
+
const raw = data?.[key] ?? ''
|
|
360
|
+
const truncateLength = Number(truncateMap?.[key])
|
|
361
|
+
if (Number.isFinite(truncateLength) && truncateLength >= 0 && typeof raw === 'string') {
|
|
362
|
+
meta[key] = raw.slice(0, Math.floor(truncateLength))
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
meta[key] = raw
|
|
366
|
+
}
|
|
198
367
|
}
|
|
199
368
|
return meta
|
|
200
369
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const { onMessagePublished, db, Firestore, logger } = require('../config.js')
|
|
2
|
+
const kv = require('./kvClient')
|
|
3
|
+
|
|
4
|
+
const KV_RETRY_TOPIC = process.env.KV_RETRY_TOPIC || 'kv-mirror-retry'
|
|
5
|
+
const KV_RETRY_MAX_ATTEMPTS = Number(process.env.KV_RETRY_MAX_ATTEMPTS || 8)
|
|
6
|
+
const KV_RETRY_BASE_MIN_DELAY = Number(process.env.KV_RETRY_BASE_MIN_DELAY || 1)
|
|
7
|
+
const KV_RETRY_MAX_MIN_DELAY = Number(process.env.KV_RETRY_MAX_MIN_DELAY || 60)
|
|
8
|
+
|
|
9
|
+
function toPositiveInt(value, fallback = 1) {
|
|
10
|
+
const n = Number(value)
|
|
11
|
+
if (!Number.isFinite(n) || n < 1)
|
|
12
|
+
return fallback
|
|
13
|
+
return Math.floor(n)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseTopicPayload(event) {
|
|
17
|
+
const message = event?.data?.message || {}
|
|
18
|
+
if (message.json && typeof message.json === 'object')
|
|
19
|
+
return message.json
|
|
20
|
+
if (message.data) {
|
|
21
|
+
try {
|
|
22
|
+
const text = Buffer.from(message.data, 'base64').toString('utf8')
|
|
23
|
+
const parsed = JSON.parse(text)
|
|
24
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
25
|
+
}
|
|
26
|
+
catch (_) {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function computeRetryDelayMinutes(attempt) {
|
|
34
|
+
const safeAttempt = toPositiveInt(attempt, 1)
|
|
35
|
+
const base = toPositiveInt(KV_RETRY_BASE_MIN_DELAY, 1)
|
|
36
|
+
const max = toPositiveInt(KV_RETRY_MAX_MIN_DELAY, 60)
|
|
37
|
+
return Math.min(max, base * (2 ** Math.max(0, safeAttempt - 1)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function enqueueKvRetry(payload, minuteDelay = 0) {
|
|
41
|
+
await db.collection('topic-queue').add({
|
|
42
|
+
topic: KV_RETRY_TOPIC,
|
|
43
|
+
payload,
|
|
44
|
+
minuteDelay: Number(minuteDelay || 0),
|
|
45
|
+
retry: 0,
|
|
46
|
+
timestamp: Firestore.FieldValue.serverTimestamp(),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runKvOperation(payload) {
|
|
51
|
+
const op = String(payload?.op || '')
|
|
52
|
+
const key = String(payload?.key || '')
|
|
53
|
+
if (!op || !key)
|
|
54
|
+
throw new Error('Invalid KV retry payload: missing op/key')
|
|
55
|
+
|
|
56
|
+
if (op === 'put')
|
|
57
|
+
return kv.put(key, payload.value, payload.opts)
|
|
58
|
+
if (op === 'putIndexMeta')
|
|
59
|
+
return kv.putIndexMeta(key, payload.metadata, payload.opts)
|
|
60
|
+
if (op === 'del')
|
|
61
|
+
return kv.del(key)
|
|
62
|
+
|
|
63
|
+
throw new Error(`Unsupported KV retry operation: ${op}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const kvMirrorRetryWorker = onMessagePublished(
|
|
67
|
+
{
|
|
68
|
+
topic: KV_RETRY_TOPIC,
|
|
69
|
+
retry: false,
|
|
70
|
+
timeoutSeconds: 180,
|
|
71
|
+
memory: '512MiB',
|
|
72
|
+
concurrency: 20,
|
|
73
|
+
},
|
|
74
|
+
async (event) => {
|
|
75
|
+
const payload = parseTopicPayload(event)
|
|
76
|
+
const op = String(payload?.op || '')
|
|
77
|
+
const key = String(payload?.key || '')
|
|
78
|
+
const attempt = Number(payload?.attempt || 0)
|
|
79
|
+
|
|
80
|
+
if (!op || !key) {
|
|
81
|
+
logger.warn('KV retry worker received invalid payload', { payload })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await runKvOperation(payload)
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const nextAttempt = attempt + 1
|
|
90
|
+
const maxAttempts = toPositiveInt(KV_RETRY_MAX_ATTEMPTS, 8)
|
|
91
|
+
const errorMessage = String(err?.message || err || 'KV retry failed')
|
|
92
|
+
|
|
93
|
+
if (nextAttempt > maxAttempts) {
|
|
94
|
+
logger.error('KV retry exhausted max attempts', { op, key, attempt: nextAttempt, error: errorMessage.slice(0, 500) })
|
|
95
|
+
try {
|
|
96
|
+
await db.collection('kv-retry-dead').add({
|
|
97
|
+
topic: KV_RETRY_TOPIC,
|
|
98
|
+
payload: {
|
|
99
|
+
...payload,
|
|
100
|
+
attempt: nextAttempt,
|
|
101
|
+
},
|
|
102
|
+
error: errorMessage.slice(0, 1000),
|
|
103
|
+
timestamp: Firestore.FieldValue.serverTimestamp(),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
catch (deadErr) {
|
|
107
|
+
logger.error('KV retry dead-letter write failed', {
|
|
108
|
+
op,
|
|
109
|
+
key,
|
|
110
|
+
attempt: nextAttempt,
|
|
111
|
+
error: String(deadErr?.message || deadErr || 'dead-letter write failed').slice(0, 500),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const minuteDelay = computeRetryDelayMinutes(nextAttempt)
|
|
118
|
+
try {
|
|
119
|
+
await enqueueKvRetry({
|
|
120
|
+
...payload,
|
|
121
|
+
attempt: nextAttempt,
|
|
122
|
+
}, minuteDelay)
|
|
123
|
+
logger.warn('KV retry requeued', { op, key, attempt: nextAttempt, minuteDelay, error: errorMessage.slice(0, 500) })
|
|
124
|
+
}
|
|
125
|
+
catch (queueErr) {
|
|
126
|
+
logger.error('KV retry requeue write failed', {
|
|
127
|
+
op,
|
|
128
|
+
key,
|
|
129
|
+
attempt: nextAttempt,
|
|
130
|
+
minuteDelay,
|
|
131
|
+
error: String(queueErr?.message || queueErr || 'requeue write failed').slice(0, 500),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
module.exports = { kvMirrorRetryWorker }
|