Skip to content

Commit 745e2d4

Browse files
committed
PR feedback
1 parent 17f75a6 commit 745e2d4

4 files changed

Lines changed: 85 additions & 6 deletions

File tree

src/config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export function resolveOptions(argv, env) {
9191
}
9292

9393
/**
94+
* Parse a comma-separated signal list. Throws on any unrecognized
95+
* token (including bad case like "Logs") or an empty result, so a
96+
* typo fails loudly instead of silently disabling all uploads.
97+
*
9498
* @param {string} value comma-separated signal list
9599
* @returns {ReadonlyArray<Signal>}
96100
*/
@@ -99,9 +103,15 @@ function parseSignals(value) {
99103
const out = []
100104
for (const part of value.split(',')) {
101105
const trimmed = part.trim()
106+
if (trimmed === '') continue
102107
if (trimmed === 'logs' || trimmed === 'traces' || trimmed === 'metrics') {
103108
out.push(trimmed)
109+
} else {
110+
throw new Error(`invalid upload signal "${trimmed}", expected one of: logs, traces, metrics`)
104111
}
105112
}
113+
if (out.length === 0) {
114+
throw new Error('upload signals list is empty, expected one or more of: logs, traces, metrics')
115+
}
106116
return out
107117
}

src/upload/uploader.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,30 @@ export async function uploadJob(job, options, connector, outputDir, committed) {
125125

126126
/**
127127
* Upload every eligible job — used both by the daily timer and by
128-
* startup catch-up.
128+
* startup catch-up. Per-job failures (transient 5xx, permanent 4xx,
129+
* malformed JSONL, etc.) are logged and isolated so one bad file does
130+
* not abort the whole run; the next tick will retry the failed jobs.
129131
*
130132
* @param {ResolvedUploadOptions} options
131133
* @param {StorageConnector} connector
132134
* @param {string} outputDir
133135
* @param {string} today YYYY-MM-DD UTC
134-
* @returns {Promise<Array<{ job: UploadJob, uploaded: boolean, key: string, rows: number, size: number }>>}
136+
* @returns {Promise<Array<{ job: UploadJob, uploaded: boolean, key: string, rows: number, size: number, error?: Error }>>}
135137
*/
136138
export async function uploadPending(options, connector, outputDir, today) {
137139
const committed = readLedger(outputDir)
138140
const jobs = discoverJobs(outputDir, today, options)
139-
/** @type {Array<{ job: UploadJob, uploaded: boolean, key: string, rows: number, size: number }>} */
141+
/** @type {Array<{ job: UploadJob, uploaded: boolean, key: string, rows: number, size: number, error?: Error }>} */
140142
const results = []
141143
for (const job of jobs) {
142-
const result = await uploadJob(job, options, connector, outputDir, committed)
143-
results.push({ job, ...result })
144+
try {
145+
const result = await uploadJob(job, options, connector, outputDir, committed)
146+
results.push({ job, ...result })
147+
} catch (err) {
148+
const error = err instanceof Error ? err : new Error(String(err))
149+
console.error(`[collectivus] upload failed for ${job.service}/${job.signal}/${job.date}: ${error.message}`)
150+
results.push({ job, uploaded: false, key: '', rows: 0, size: 0, error })
151+
}
144152
}
145153
return results
146154
}

test/config.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,24 @@ describe('resolveOptions', () => {
6969
outputDir: '/tmp/x',
7070
})
7171
})
72+
73+
it('throws on unrecognized upload signals to fail loudly on typos', () => {
74+
expect(() => resolveOptions([], {
75+
COLLECTIVUS_UPLOAD_BUCKET: 'b',
76+
COLLECTIVUS_UPLOAD_SIGNALS: 'Logs',
77+
})).toThrow(/invalid upload signal "Logs"/)
78+
expect(() => resolveOptions(['--upload-signals=loggs'], {})).toThrow(/invalid upload signal "loggs"/)
79+
})
80+
81+
it('throws when upload signals list resolves to empty', () => {
82+
expect(() => resolveOptions(['--upload-signals='], {})).toThrow(/upload signals list is empty/)
83+
expect(() => resolveOptions(['--upload-signals', ', ,'], {})).toThrow(/upload signals list is empty/)
84+
})
85+
86+
it('parses a valid comma-separated upload-signals list', () => {
87+
const opts = resolveOptions(['--upload-signals=logs,traces'], {
88+
COLLECTIVUS_UPLOAD_BUCKET: 'b',
89+
})
90+
expect(opts.upload?.signals).toEqual(['logs', 'traces'])
91+
})
7292
})

test/upload/uploader.test.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parquetReadObjects } from 'hyparquet'
2-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import fs from 'node:fs'
44
import os from 'node:os'
55
import path from 'node:path'
@@ -108,6 +108,47 @@ describe('uploadPending', () => {
108108
])
109109
})
110110

111+
it('isolates per-job failures so one bad object does not abort the run', async () => {
112+
writeJsonl('svc-bad', 'logs', yesterday, [
113+
{ serviceName: 'svc-bad', body: 'x', resource: {}, scope: { attributes: {} }, attributes: {} },
114+
])
115+
writeJsonl('svc-good', 'logs', yesterday, [
116+
{ serviceName: 'svc-good', body: 'y', resource: {}, scope: { attributes: {} }, attributes: {} },
117+
])
118+
119+
const memory = memoryConnector()
120+
/** @type {import('../../src/upload/upload.d.ts').StorageConnector} */
121+
const connector = {
122+
scheme: 'flaky',
123+
async putObject(key, body, contentType) {
124+
await memory.putObject(key, body, contentType)
125+
},
126+
headObject(key) {
127+
if (key.includes('svc-bad')) return Promise.reject(new Error('s3 HEAD returned 503'))
128+
return memory.headObject(key)
129+
},
130+
}
131+
132+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
133+
const results = await uploadPending(
134+
{ bucket: 'b', prefix: 'collectivus', time: '00:10', signals: ['logs', 'traces', 'metrics'], catchupDays: 7, region: 'us-east-1' },
135+
connector,
136+
outputDir,
137+
today
138+
)
139+
errSpy.mockRestore()
140+
141+
expect(results).toHaveLength(2)
142+
const bad = results.find((r) => r.job.service === 'svc-bad')
143+
const good = results.find((r) => r.job.service === 'svc-good')
144+
expect(bad?.uploaded).toBe(false)
145+
expect(bad?.error?.message).toMatch(/503/)
146+
expect(good?.uploaded).toBe(true)
147+
expect([...memory.store.keys()]).toEqual([
148+
`collectivus/svc-good/logs/date=${yesterday}/data.parquet`,
149+
])
150+
})
151+
111152
it('writes a ledger entry per uploaded file', async () => {
112153
writeJsonl('svc-a', 'logs', yesterday, [
113154
{ serviceName: 'svc-a', body: 'a', resource: {}, scope: { attributes: {} }, attributes: {} },

0 commit comments

Comments
 (0)