Add adaptive sync pipeline: freshness cache, backpressure, staged batching
Three-layer defense against Concelier overload during bulk advisory sync:
Layer 1 — Freshness query cache (30s TTL):
GET /advisory-sources, /advisory-sources/summary, and
/{id}/freshness now cache their results in IMemoryCache for 30s.
Eliminates the expensive 4-table LEFT JOIN with computed freshness
on every call during sync storms.
Layer 2 — Backpressure on sync endpoint (429 + Retry-After):
POST /{sourceId}/sync checks active job count via GetActiveRunsAsync().
When active runs >= MaxConcurrentJobs, returns 429 Too Many Requests
with Retry-After: 30 header. Clients get a clear signal to back off.
Layer 3 — Staged sync-all with inter-batch delay:
POST /sync now triggers sources in batches of MaxConcurrentJobs
(default: 6) with SyncBatchDelaySeconds (default: 5s) between batches.
21 sources → 4 batches over ~15s instead of 21 instant triggers.
Each batch triggers in parallel (Task.WhenAll), then delays.
New config: JobScheduler:SyncBatchDelaySeconds (default: 5)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,20 +35,41 @@ const SOURCES_WITH_JOBS = [
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Advisory Sync — Job Triggering', () => {
|
||||
for (const sourceId of SOURCES_WITH_JOBS) {
|
||||
test(`sync ${sourceId} returns accepted (not no_job_defined)`, async ({ apiRequest }) => {
|
||||
const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/sync`);
|
||||
expect(resp.status()).toBeLessThan(500);
|
||||
const body = await resp.json();
|
||||
test('sync-all triggers jobs for enabled sources (batched)', async ({ apiRequest }) => {
|
||||
// Use the batched POST /sync endpoint instead of triggering 21 individual syncs.
|
||||
// This exercises the staged batching pipeline (MaxConcurrentJobs per batch).
|
||||
const resp = await apiRequest.post('/api/v1/advisory-sources/sync', { timeout: 60_000 });
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = await resp.json();
|
||||
|
||||
expect(body.sourceId).toBe(sourceId);
|
||||
// Must be "accepted" or "already_running" — NOT "no_job_defined"
|
||||
expect(
|
||||
['accepted', 'already_running'],
|
||||
`${sourceId} sync should trigger a real job, got: ${body.outcome}`,
|
||||
).toContain(body.outcome);
|
||||
});
|
||||
}
|
||||
expect(body.totalSources).toBeGreaterThanOrEqual(1);
|
||||
expect(body.results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check that sources with registered jobs got "accepted" or "already_running"
|
||||
// (not "no_job_defined"). Some may get 429 backpressure — that's valid.
|
||||
for (const sourceId of SOURCES_WITH_JOBS) {
|
||||
const result = body.results.find((r: any) => r.sourceId === sourceId);
|
||||
if (result) {
|
||||
expect(
|
||||
['accepted', 'already_running'],
|
||||
`${sourceId} sync should trigger a real job, got: ${result.outcome}`,
|
||||
).toContain(result.outcome);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('individual source sync returns accepted or backpressure', async ({ apiRequest }) => {
|
||||
// Test a single source sync to verify the endpoint works
|
||||
const resp = await apiRequest.post('/api/v1/advisory-sources/osv/sync');
|
||||
expect(resp.status()).toBeLessThan(500);
|
||||
|
||||
if (resp.status() === 429) return; // Valid backpressure
|
||||
if (resp.status() === 409) return; // Already running
|
||||
|
||||
const body = await resp.json();
|
||||
expect(body.sourceId).toBe('osv');
|
||||
expect(['accepted', 'already_running']).toContain(body.outcome);
|
||||
});
|
||||
|
||||
test('sync unknown source returns 404', async ({ apiRequest }) => {
|
||||
const resp = await apiRequest.post('/api/v1/advisory-sources/nonexistent-xyz-source/sync');
|
||||
|
||||
Reference in New Issue
Block a user