GCVE JSON Processor — Design Document
1. Overview
Purpose: Hourly export of all GcveIssuance records as cvelistv5-format tar.gz archives to S3, with individual raw JSON per record for new/modified entries.
Identifier scheme: GCVE identifiers use Vulnetix’s GCVE Numbering Authority (GNA) id
110— e.g.GCVE-110-OSM-2026-1. TheGcveIssuancesequence pattern isGCVE-110-{PREFIX}-{year}-%. (EarlierGCVE-VVD-…ids and thecves/GCVE-VVD/S3 path are retired in favour of the numeric GNA form.)
Schedule: Runs every 6 hours (cron(0 */6 * * ? *))
Timeout: 120 minutes (raised from 30 after measured 46/52/91-min runs; the export is resumable via its on-disk record cache)
Resources: 1024 CPU units, 4096 MB memory (terraform/go-schedules.tf is authoritative
for CPU/memory; scripts/task-manager.toml still declares the pre-raise 512/1024 and is
stale — the dashboard reads the toml, so its sizing column under-reports this task). Peak
heap is ~100 MB; ephemeral local disk holds one .tar.gz per output, typically tens of MB
because entries are gzipped as they are written.
ECS command: ["/app/gcve-json-processor", "--resume=true", "--max-age-hours=72"] —
neither flag is declared in the task-manager.toml args block, which lists only
force and limit.
What it reads:
- Read replica:
GcveIssuancetable (all rows, ordered by gcveId) - Read replica:
Kev,VulnCheckKEVCVE,CVEAliastables (read-through cache; aliases are direct/one-hop only) - S3:
gcve-archive/latest/manifest.json(previous run’s ID set for delta detection)
What it writes:
- S3:
gcve-archive/YYYY/MM/DD/HH/gcve-full.tar.gz— dated immutable snapshot - S3:
gcve-archive/latest/gcve-full.tar.gz— overwritten each run - S3:
gcve-archive/recent/gcve-new.tar.gz— records not in the previous manifest - S3:
gcve-archive/modified/gcve-modified.tar.gz— records updated in the last hour - S3:
gcve-archive/cves/GCVE-110/{year}/{gcveId}.json— individual raw JSON (new/modified only) - S3:
gcve-archive/latest/manifest.json— updated ID+metadata index
Environment variables:
DATABASE_URL— required (write-side, but used as read fallback ifDATABASE_URL_READabsent)DATABASE_URL_READ— optional read replicaS3_BUCKET_NAME— requiredSNS_TOPIC_ARN— optional (enables overtime/error SNS notifications)EXPECTED_DURATION_MINUTES— optional (default none; triggers overtime notification if exceeded)
2. Business Logic
Delta Detection (Manifest-Based)
On each run the processor compares the current GcveIssuance row set against the previous manifest:
- Load
gcve-archive/latest/manifest.jsonfrom S3 (previous run’s set of gcveIds + metadata). - A record is new if its
gcveIddoes not appear in the previous manifest. - A record is modified if its
updatedAt ≥ nowMs − 3_600_000(updated within the last hour). - If
--forceis not set andlen(allRows) == len(previousIDs)with no new or modified records, exit early without rebuilding archives.
Source Priority
Each GcveIssuance row carries a source column. BuildRecord is called with that source, so the archive entry reflects the authoritative source for each GCVE ID.
Read-through cache
Workers share an ExportCache (export.NewExportCache) — a read-through LRU for
Kev, VulnCheckKEVCVE, and each record’s direct aliases. Aliases are the
one-hop CVEAlias neighbourhood via db.LoadDirectAliases; the full transitive
closure/BFS was removed because the alias graph has hub CVEs that merged unrelated
records. A cache miss falls through to a DB query.
Batched Record Building
Holding every JSON payload plus a full-archive bytes.Buffer in memory would OOM the container (and the parent TTY when run locally) as the GcveIssuance table grows. Records are built in bounded batches instead:
- Rows are processed in batches of
batchSize = 1000. - Inside each batch a parallel
errgroup(default 20 workers) callsbuildRecord→export.BuildRecordand marshals JSON. - After
g.Wait()the main goroutine serially iterates batch results, writing each entry to the on-disk chunked archives (full / recent / modified) and enqueueing the raw-JSON upload. - The batch’s
resultsslice is released andruntime.GC()runs at the end of every batch so heap usage drops back to baseline before the next batch starts.
Peak batch memory is approximately batchSize × avg(JSON size) ≈ 1000 × 20 KB ≈ 20 MB regardless of how many records exist.
Streaming On-Disk Archives
Each of the three output archives (full, recent, modified) is backed by a db.StreamingArchive that writes tar entries straight through tar.Writer → gzip.Writer → os.File into a single .tar.gz at <tmp>/{name}.tar.gz. Peak memory is O(one entry); peak disk is the compressed archive size (roughly 1/10 of the uncompressed payload for JSON). No intermediate uncompressed chunks accumulate, so even a backfill of every prefix fits comfortably on a Fargate ephemeral volume.
On Close the tar + gzip + file descriptors are flushed and closed. The file is uploaded directly from disk; Remove() deletes each archive right after its uploads finish so disk usage shrinks progressively. There is no defer os.RemoveAll safety net here (unlike cveprefix-json-processor) — cleanup happens only on the success path, which is deliberate: os.Exit does not run defers, and leaving the dir behind lets the next run reuse it (see § Implicit Resume, step 4).
Raw JSON Uploads
Raw JSON uploads overlap with batch processing via a bounded channel (rawUploadQueueSize = 200) feeding a background errgroup of 10 workers. Entries for new/modified records (or all records under --force, or stale-in-S3 records under --resume) are enqueued as each batch is written. After the final batch the channel is closed and the pool drained before the archives are closed.
⚠ Two silent-loss paths, both non-fatal and both leaving the run green.
- The channel send is non-blocking (
select … default:): when all 200 slots are full the job is discarded and onlyuploadDroppedis incremented. That record’s per-record{gcveId}.jsonis never written to S3, and nothing retries it — the manifest is still saved as if it had been.- A failed
PutObjectincrementsuploadErrorsand logs a warning.Both counters surface in the
task.completedpayload asrawUploaded/rawUploadDrops, but neither escalates totask.errored. Likewise a record whosebuildRecordfails is counted inskippedand the run still completes unless every record failed. WatchrawUploadDropsandskippedin the completion stats; a subsequent--resumerun is what actually repairs dropped uploads.
Archive Upload
The .tar.gz files written by StreamingArchive are uploaded from disk via PutObject with the *os.File as the Body. The SDK streams content and sets Content-Length from Stat().Size(), avoiding any bytes.Buffer copy. The full archive file is re-opened to upload once for the dated key and once for the latest key.
Every S3 put (s3PutFile / s3PutJSON / saveManifest) routes through retryPut, which builds a fresh context.WithTimeout rooted at context.Background() on every attempt — the caller-supplied ctx is polled only between attempts, never passed into the SDK. This makes each PutObject immune to poisoning from a cancelled or expired parent ctx. On context.DeadlineExceeded / context.Canceled the call is retried up to s3PutMaxAttempts (3) times with 500 ms × attempt-number backoff; non-ctx errors fall through unchanged. Per-attempt budgets: 10 min for archive files, 60 s for raw JSON blobs, 2 min for the manifest.
Idempotency
Re-running with the same data produces the same archives. The manifest diff ensures no-op exits when nothing has changed. --force bypasses the diff check.
S3-Aware Resume (--resume)
The local filesystem resume cache survives ordinary crashes but not events that wipe /tmp — power outages, fresh containers, or distinct hosts. To recover safely from those cases, --resume lists the existing per-record raw JSON objects in S3 (gcve-archive/cves/GCVE-110/) at startup and builds a gcveId → LastModified map. Per-row classification then becomes:
--force→ always upload.--resumeset → upload iff S3 has no object for the row, or the S3 object’sLastModifiedis before the row’supdatedAt. This is independent of the new/modified delta logic, since after a power outage every row will look “old” relative to the previous manifest yet may still be missing in S3.- Otherwise → upload iff the row is new vs the previous manifest, or
updatedAtfalls in the last hour.
If --resume is set, every row is current in S3, and --force is not set, the run exits before any DB-heavy BuildRecord work or archive churn. When at least one row needs uploading, archives + manifest are still rebuilt as on a normal run (every row’s JSON is needed to produce the full archive, and the resume cache amortises the build cost across attempts).
Recency Window (--max-age-hours)
The EventBridge schedule does not need to consider every record on every fire. With --max-age-hours=72, rows are filtered immediately after load to those with updatedAt within the last N hours. Combined with --resume, the daily/hourly ECS task touches only the small slice of recently-changed records and skips uploads for any that are already current in S3.
Implicit Resume (Record Cache)
The expensive part of each run is export.BuildRecord — it issues multiple DB queries per record. To survive crashes, container kills, and ECS timeouts without throwing that work away, every built record is persisted as a JSON file under a stable resume cache directory:
<tmpRoot>/gcve-json-processor-cache/records/{year}/{gcveId}.json
Flow on every run:
- On startup the cache directory is opened (created if missing). Its entry count is logged for visibility.
- Inside the worker, for each row the cache is consulted by path. A hit is valid only if the cached file’s
mtimein milliseconds is ≥ the row’supdatedAt— any newer upstream change invalidates the cache entry and forces a rebuild. - On a miss the record is built, marshalled, and written atomically (
*.tmp+ rename) to the cache. The worker then proceeds exactly as before (archive writes, raw-JSON upload enqueue, manifest entry). - Archives themselves are not resumable — tar.gz has no safe append point — so they are rebuilt from the resume cache on every run. They live at a stable path
<tmpRoot>/gcve-json-processor-archives/(notMkdirTemp) so a crashed prior run never leaves an orphan dir behind: any stale archive files from a partial run are wiped at startup before the rebuild begins. Cleanup happens on successful completion only —os.Exitpaths leave the dir in place to be overwritten by the next run, sincedeferdoes not fire on exit. Rebuilding the tar from a hot cache is cheap; it’s the record building that was expensive. - After a full successful run both the resume cache and the archive dir are wiped. On crash/kill/timeout the cache persists and the next run implicitly picks up where the previous one left off — cache hits show up in the progress log as
cacheHits/cacheMisses. If a crash lands between archive close and S3 upload, the next run rebuilds archives from the all-hit cache (fast) and re-attempts the uploads. - Defensive check after archive close: an explicit
os.Statconfirms the archive file exists and is non-empty before any S3 upload is attempted. A missing or zero-byte archive is fatal and logged with the offending path, instead of producing a confusing nesteds3PutFilestat error.
--build-clean wipes the resume cache before any work starts, forcing every record to be rebuilt from the DB. Use it when upstream enrichment logic has changed in a way updatedAt cannot detect (e.g. a new field in BuildRecord output for unchanged rows).
3. Architecture Diagram
batch loop + StreamingArchive
s3PutFile / s3PutJSON
loadManifest / saveManifest] RECON[reconstruct.go
buildRecord → export.BuildRecord] end subgraph "internal/export/" BR[record.go
BuildRecord
ExportCache] end subgraph "internal/db/" POOL[pool.go — Pool] GCVE[gcve_export.go
LoadAllGcveIssuances
LoadCVEMetaExport etc.] KEV[kev.go — LoadAllKEV] VC[vulncheck.go — LoadAllVulnCheckKEVCVEs] ALIAS[cvealias.go — LoadAllCVEAliasEdges] CHUNK[streaming_archive.go
NewStreamingArchive
Write / Close / Path / Remove] end MAIN -->|batches of 1000, 20 workers| RECON RECON --> BR BR --> GCVE BR --> KEV BR --> VC BR --> ALIAS MAIN --> POOL MAIN -->|tar entries| CHUNK CHUNK -->|streamed .tar.gz| DISK[(tmp dir
{full,recent,modified}.tar.gz)] MAIN -->|PutObject file body| S3[S3 Bucket] MAIN -->|GetObject| S3
4. Deployment Diagram
go-ecr-deploy.yml] -->|push ARM64 image| ECR[ECR: vdb-manager
tag: go-gcve-json-processor-latest] ECR --> TASKDEF[ECS Task Definition
go-gcve-json-processor] TASKDEF --> EB[EventBridge Schedule
go-gcve-json-processor
cron 0 */6 * * ? *] EB -->|trigger| FARGATE[ECS Fargate Task
vdb-scheduler cluster
ARM64 ap-southeast-2] FARGATE --> CW[CloudWatch Logs
/ecs/vdb-scheduler/go-gcve-json-processor] FARGATE --> READ[RDS Read Replica
hyperdrive-saas-read] FARGATE --> S3[S3 Bucket
gcve-archive/]
5. Processing Flow
records exist?} DELTA -->|no + not force| EXIT0([Exit 0 — no work]) DELTA -->|yes or force| CACHE[Bulk-load KEV / VulnCheck / alias caches] CACHE --> RESUME{--build-clean?} RESUME -->|yes| WIPE[Wipe
Init 3× StreamingArchive + raw-upload pool] TMP --> BATCHLOOP{For each batch of 1000} BATCHLOOP --> BUILD[errgroup — 20 workers
cache.Get mtime≥updatedAt?
hit → unmarshal cached JSON
miss → buildRecord + Marshal + cache.Put] BUILD --> WRITE[Serial write per result:
fullArchive.Write
recent/modified conditionally
enqueue raw-JSON upload
update manifestEntries
nil rec + data] WRITE --> FREE[results = nil
runtime.GC every batch] FREE --> BATCHLOOP BATCHLOOP -->|all batches done| CLOSECH[close upload channel
wait for raw uploads] CLOSECH --> CLOSEAR[Close streaming archives] CLOSEAR --> FULLUP[s3PutFile fullArchive.Path → dated + latest
fullArchive.Remove] FULLUP --> RECENT{recentArchive empty?} RECENT -->|no| RECUP[s3PutFile recentArchive.Path → recent key] RECENT -->|yes| MOD RECUP --> MOD{modifiedArchive empty?} MOD -->|no| MODUP[s3PutFile modifiedArchive.Path → modified key] MOD -->|yes| MANIFEST2 MODUP --> MANIFEST2[recentArchive.Remove
modifiedArchive.Remove
saveManifest to S3] MANIFEST2 --> WIPE2[Wipe resume cache
(success path only — crashed runs leave it for next implicit resume)] WIPE2 --> DONE([Exit 0])
6. S3 Object Layout
gcve-archive/
├── YYYY/MM/DD/HH/
│ └── gcve-full.tar.gz # immutable dated snapshot
├── latest/
│ ├── gcve-full.tar.gz # overwritten each run
│ └── manifest.json # {generatedAt, count, records: {gcveId: {titles,vendors,...}}}
├── recent/
│ └── gcve-new.tar.gz # gcveIds not in previous manifest
├── modified/
│ └── gcve-modified.tar.gz # gcveIds with updatedAt in last hour
└── cves/GCVE-110/{year}/
└── {gcveId}.json # individual cvelistv5 JSON (new/modified runs only)
Each tar.gz contains entries at paths cves/GCVE-110/{year}/{gcveId}.json.
7. Data Mapping
8. Flags
| Flag | Default | Description |
|---|---|---|
--force | false | Reprocess all records even if no new/modified records detected |
--limit | 0 | Cap records processed (0 = all); useful for testing |
--workers | 20 | Parallel record-build goroutines |
--build-clean | false | Wipe both the resume cache (<tmpRoot>/gcve-json-processor-cache/) and the archive dir (<tmpRoot>/gcve-json-processor-archives/) before starting — forces every record to be rebuilt from the DB. Default behaviour is implicit resume: a prior crashed run’s cache is reused. |
--resume | false | At startup, list gcve-archive/cves/GCVE-110/ in S3 and build a gcveId → LastModified map. For each row, skip the per-record raw JSON upload if S3 already has an object whose LastModified is at-or-after the row’s updatedAt. If every row is current and --force is not set, exit early without rebuilding archives or writing the manifest. Survives /tmp loss (e.g. power outage) where the local resume cache is gone. The justfile backfill recipe defaults this to true; the EventBridge ECS schedule passes --resume=true. |
--max-age-hours | 0 | If > 0, filter rows immediately after load to those with updatedAt within the last N hours. The EventBridge daily/hourly ECS schedule passes 72 so the task only touches records changed in the last 3 days; the local backfill leaves it at 0 to process the full table. |
S3 Persistence
- Archive: ⚠ Not yet implemented — requires record reconstruction (DB row → canonical JSON).
- Quarantine: ⚠ Not yet implemented — same reason.
- Likely reasons when implemented:
enrich-error
This is an enrichment processor; it reads from CVEMetadata rather than ingesting raw feeds, so there is no original payload to archive verbatim. See S3 Persistence Contract § Processors whose unit-of-work is not a file.