STL Preview Cache Pre-Population — Research¶
Date: 2026-02-27
Status: Superseded — the full-preview-per-dimension approach documented here was replaced by the plate-level cache (ADR-061). The legacy cache (16,471 files, ~32 GB) and its scripts (populate-preview-cache.ts, upload-preview-cache.sh) have been removed. This document is retained as historical research.
1. Problem Statement¶
Currently, STL previews are generated on-demand when a customer first views a specific grid dimension in the storefront configurator. This means:
- First-time loads for a given dimension take 12–30+ seconds (depending on grid size).
- Only after the first request is the STL cached to disk for instant subsequent loads.
The goal is to pre-populate the entire cache so that every possible dimension combination is already generated before any customer requests it, eliminating all cold-start latency.
2. Combinatorics¶
Default parameters¶
| Parameter | Value |
|---|---|
| Width range | 10 cm – 100 cm |
| Height range | 10 cm – 100 cm |
| Increment | 0.5 cm (= 5 mm) |
| Distinct values per axis | (100 − 10) / 0.5 + 1 = 181 |
| Width/height interchangeable | Yes (a 30×40 grid ≡ 40×30 grid) |
Combination count formula¶
Since width and height are interchangeable, we need unordered pairs with repetition (a 30×30 grid is valid — same width and height):
n = (maxMm − minMm) / incrementMm + 1
combinations = n × (n + 1) / 2
With the defaults: n = (1000 − 100) / 5 + 1 = 181, combinations = 181 × 182 / 2 = 16,471.
Configurable range and increment¶
The admin should be able to configure three parameters when starting population:
| Parameter | Default | Source of default | Constraints |
|---|---|---|---|
| Min dimension (mm) | 100 | Hardcoded (10 cm minimum practical size) | ≥ 42 (1 grid unit), multiple of incrementMm |
| Max dimension (mm) | DEFAULT_MAX_DIMENSION_MM (1000) |
SystemConfig key gridflock.max_dimension_mm per tenant |
≤ 2000, multiple of incrementMm |
| Increment (mm) | 5 | Hardcoded (0.5 cm = finest storefront step) | ≥ 1, ≤ 100, divides (max − min) evenly |
The max dimension default is already stored in SystemConfig (key gridflock.max_dimension_mm,
fallback DEFAULT_MAX_DIMENSION_MM = 1000 from constants.ts). The populate endpoint
should read this value as the default but allow the admin to override it.
Impact of parameter changes on combination count and storage¶
| Configuration | n | Combinations | Est. storage | Est. time |
|---|---|---|---|---|
| Default: 100–1000 mm, 5 mm step | 181 | 16,471 | ~32 GB | ~69 h |
| Coarse: 100–1000 mm, 10 mm step | 91 | 4,186 | ~8 GB | ~17 h |
| Small range: 100–500 mm, 5 mm step | 81 | 3,321 | ~2.6 GB | ~9 h |
| Quick: 100–1000 mm, 50 mm step | 19 | 190 | ~370 MB | ~47 min |
| Round numbers: 100–1000 mm, 100 mm step | 10 | 55 | ~107 MB | ~14 min |
These estimates use the same 13 KB/cell model but adjusted for the smaller average grid size when the range is restricted.
UI for parameter configuration¶
The populate dialog should expose these fields with sensible defaults:
┌─────────────────────────────────────────────────────────┐
│ Populate Preview Cache │
│ │
│ Dimension range (mm): │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ │
│ │ 100 │ to │ 1000 │ by │ 5 │ mm │
│ └─────────┘ └──────────┘ └────────┘ │
│ │
│ This will generate 16,471 previews (~32 GB). │
│ Estimated time: ~69 hours. │
│ Already cached: 89 (will be skipped). │
│ │
│ ⚠ The server will be under sustained CPU load. │
│ │
│ [Cancel] [Start Population] │
└─────────────────────────────────────────────────────────┘
The combination count, estimated storage, and estimated time should recalculate live as the admin adjusts the parameters (pure client-side math, no API call needed).
Validation rules¶
minMmmust be ≥ 42 (at least 1 Gridfinity grid unit) and <maxMm.maxMmmust be ≤DEFAULT_MAX_DIMENSION_MM(or the tenant's configured max).incrementMmmust divide(maxMm − minMm)evenly.incrementMmmust be ≥ 1 mm (sub-millimeter increments produce near-identical STLs sincefloor(d / 42)groups many mm values to the same grid count anyway).
Cache key format¶
Current format: preview-{widthMm}x{heightMm}.stl
Examples: preview-450x320.stl, preview-1000x500.stl
Important finding: The current cache key is NOT normalized — a request for 450×320 produces
preview-450x320.stl, while 320×450 would producepreview-320x450.stl. These are treated as separate cache entries even though the resulting grids are physically identical (just rotated). ThecomputeSku()function inplate-set-calculator.tsalready normalizes (larger dimension first), butgeneratePreview()does not.Recommendation: Before implementing pre-population, normalize the cache key so the larger dimension always comes first (e.g.,
preview-450x320.stlfor both 450×320 and 320×450). This halves lookup misses and prevents duplicate entries.
3. File Size Estimation¶
Empirical data (from staging, 2 vCPU droplet)¶
| File | Dimensions | Grid cells | File size | KB/cell |
|---|---|---|---|---|
preview-450x320.stl |
450 × 320 mm | 10 × 7 = 70 | 930 KB | 13.3 |
preview-450x600.stl |
450 × 600 mm | 10 × 14 = 140 | 1,839 KB | 13.1 |
preview-450x800.stl |
450 × 800 mm | 10 × 19 = 190 | 2,541 KB | 13.4 |
Grid cells = floor(width / 42) × floor(height / 42) where 42 mm is the Gridfinity grid unit.
Model¶
The data shows a remarkably consistent rate of ~13 KB per grid cell in the binary STL output. Per-plate overhead (base geometry, connectors, borders) is negligible relative to cell geometry.
estimated_file_size ≈ 13 KB × floor(w / 42) × floor(h / 42)
Size distribution across all 16,471 combinations¶
| Category | Dimension range | Grid cells | Est. file size | % of combos |
|---|---|---|---|---|
| Small | both ≤ 300 mm | 4 – 49 | 50 – 640 KB | ~22% |
| Medium | one ≤ 300, other ≤ 600 mm | 4 – 98 | 50 – 1.3 MB | ~30% |
| Large | both ≤ 600 mm | 4 – 196 | 50 – 2.5 MB | ~25% |
| Very large | one or both > 600 mm | up to 529 | up to 6.9 MB | ~23% |
Total estimated storage¶
Using the 13 KB/cell model across all 16,471 unordered pairs:
- Weighted average: ~159 grid cells per combination → ~2.1 MB average file size
- Total estimate: 16,471 × 2.1 MB ≈ 32 GB (range: 25–40 GB)
Size extremes:
- Smallest (100×100): 2×2 = 4 cells → ~52 KB
- Largest (1000×1000): 23×23 = 529 cells → ~6.9 MB
Storage consideration: 32 GB is manageable on a Docker volume on the current staging droplet (which has ~75 GB disk). For production, ensure the volume has at least 50 GB headroom to accommodate the cache plus growth.
4. Generation Time Estimation¶
Empirical observations (2 vCPU staging server, 2 workers)¶
| Dimensions | Plates | Generation time |
|---|---|---|
| 450 × 320 mm | 4 | ~12 seconds |
| 450 × 600 mm | 6–8 | ~20 seconds |
| 450 × 800 mm | 8–12 | ~30 seconds |
Generation time scales roughly linearly with the number of plates (and thus grid cells).
Estimate per combination¶
| Category | Grid cells | Est. time | Avg. |
|---|---|---|---|
| Small (≤49 cells) | 4 – 49 | 2 – 8 s | ~5 s |
| Medium (50–100 cells) | 50 – 100 | 8 – 15 s | ~12 s |
| Large (100–200 cells) | 100 – 200 | 15 – 25 s | ~20 s |
| Very large (200+ cells) | 200 – 529 | 25 – 60 s | ~40 s |
Total generation time (sequential, full population)¶
Weighted average per combination: ~15 seconds
16,471 × 15 s = 247,065 s ≈ 69 hours ≈ 2.9 days
On a beefier machine (e.g., 4–8 vCPUs in production), generation would be proportionally faster due to more parallel workers, but the relationship is not perfectly linear because each combination already saturates available workers for its own plates.
Realistic estimate: 50–100 hours depending on hardware and system load.
5. Architecture Design¶
5.1 Available infrastructure¶
The codebase already has:
| Component | Status | Usage |
|---|---|---|
BullMQ (@nestjs/bullmq) |
In use | Async STL generation for orders |
| Redis | In use | BullMQ backend, Socket.io adapter |
Socket.io (@nestjs/websockets) |
In use | Real-time order/print-job events |
Bull Board (@bull-board/express) |
In use | Queue monitoring UI |
No new dependencies are needed.
5.2 Recommended approach: BullMQ queue + Socket.io progress¶
┌──────────────────┐ POST /populate ┌────────────────────────┐
│ Admin UI │ ──────────────────────> │ PreviewCacheController │
│ (React) │ │ (NestJS) │
│ │ <── Socket.io events ── │ │
│ [Populate] │ progress, done └──────────┬─────────────┘
│ [Pause] │ │
│ [Resume] │ ┌──────────▼─────────────┐
│ [Stop] │ │ BullMQ Queue │
│ │ │ "preview-populate" │
│ Progress: 42% │ │ 16,471 jobs │
│ 1234 / 16471 │ └──────────┬─────────────┘
│ Skipped: 89 │ │
│ ETA: ~38h │ ┌──────────▼─────────────┐
└──────────────────┘ │ PopulateProcessor │
│ (Worker) │
│ │
│ For each job: │
│ 1. Check if cached │
│ 2. Skip if exists │
│ 3. Generate + cache │
│ 4. Emit progress │
└────────────────────────┘
5.3 Queue strategy¶
One job per dimension combination (16,471 jobs):
- BullMQ handles ordering, retries, and concurrency natively.
- Each job payload:
{ widthMm: number, heightMm: number }. - Skip logic: check if
preview-{w}x{h}.stlalready exists → mark complete immediately. - Concurrency: 1 (each combination already uses all CPU cores for plate parallelism).
5.4 Pause / Resume / Stop¶
BullMQ provides built-in support:
| Action | BullMQ API | Effect |
|---|---|---|
| Pause | queue.pause() |
Stops picking up new jobs; current job finishes |
| Resume | queue.resume() |
Resumes processing waiting jobs |
| Stop | queue.drain() |
Removes all waiting jobs (active job finishes) |
No custom state machine needed — these are reliable, battle-tested BullMQ primitives.
5.5 Progress tracking¶
Two complementary channels:
-
Polling endpoint (simple, works without WebSocket):
UsesGET /api/v1/gridflock/preview-cache/populate/status → { state, total, completed, skipped, failed, elapsedMs, estimatedRemainingMs }queue.getJobCounts()to return waiting/active/completed/failed counts. -
Socket.io events (real-time, optional enhancement):
Event: "preview-cache:progress" Payload: { completed, total, skipped, currentDimensions, elapsedMs }
The polling approach is simpler and sufficient for the admin UI (poll every 2–5 seconds). Socket.io can be added later for a smoother UX without additional infrastructure.
5.6 Job ordering strategy¶
Generate smallest combinations first (fewest grid cells → fastest jobs):
- Provides quick early feedback ("it's working").
- Fills the most commonly requested dimensions first (smaller grids are more popular).
- Gives better ETA estimates after the first batch completes.
Sort by floor(w/42) × floor(h/42) ascending before enqueueing.
5.7 Resilience¶
| Scenario | Handling |
|---|---|
| Server restart mid-population | BullMQ jobs persist in Redis. On restart, processing resumes automatically from where it left off. |
| Single job failure (OOM, timeout) | BullMQ retries with backoff (configure 2–3 attempts). Failed jobs are tracked separately. |
| Disk full | Job fails, logged. Admin can see failures in the UI and in Bull Board. |
| Concurrent population requests | Reject if a population is already active (check queue state). |
6. API Design¶
Endpoints¶
POST /api/v1/gridflock/preview-cache/populate Start population
POST /api/v1/gridflock/preview-cache/populate/pause Pause
POST /api/v1/gridflock/preview-cache/populate/resume Resume
DELETE /api/v1/gridflock/preview-cache/populate Stop (drain queue)
GET /api/v1/gridflock/preview-cache/populate/status Get progress
All endpoints require PERMISSIONS.ADMIN.
Start request body¶
{
"minMm": 100,
"maxMm": 1000,
"incrementMm": 5
}
All fields are optional; the server uses defaults when omitted
(minMm: 100, maxMm: SystemConfig value or 1000, incrementMm: 5).
Start response¶
{
"total": 16471,
"skippedExisting": 89,
"queued": 16382,
"estimatedTimeSeconds": 245730
}
Status response¶
{
"state": "running",
"total": 16471,
"completed": 1234,
"skipped": 89,
"failed": 2,
"active": 1,
"waiting": 15146,
"elapsedSeconds": 18540,
"estimatedRemainingSeconds": 227190,
"currentDimensions": "550 x 420 mm"
}
7. Frontend Design¶
UI on the existing Preview Cache page¶
Add a "Populate Cache" section above the file list:
┌─────────────────────────────────────────────────────┐
│ Cache Pre-Population │
│ │
│ Fill the cache with all 16,471 possible dimension │
│ combinations. Already-cached dimensions are │
│ skipped automatically. │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ████████████░░░░░░░░░░░░░░░░░░░ 42% │ │
│ └──────────────────────────────────────────────┘ │
│ 6,912 / 16,471 completed · 89 skipped · 2 err │
│ Elapsed: 5h 09m · ETA: ~7h 12m │
│ Currently generating: 550 × 420 mm │
│ │
│ [⏸ Pause] [⏹ Stop] │
│ │
│ (when idle) │
│ [▶ Populate Cache] │
└─────────────────────────────────────────────────────┘
States¶
| State | Buttons shown |
|---|---|
| idle (no population running) | [Populate Cache] |
| running | [Pause] [Stop] + progress bar |
| paused | [Resume] [Stop] + progress bar (static) |
| completed | Summary message + [Populate Cache] (for re-run) |
Confirmation dialogs¶
- Populate: Shows the parameter configuration form (see Section 2) with live-updating counts. After clicking "Start Population", a confirmation: "This will generate ~16,382 previews (~32 GB). Estimated time: ~69 hours. The server will be under sustained CPU load during this time. Continue?"
- Stop: "This will cancel all remaining jobs. Already-generated previews are kept. Continue?"
8. Key Implementation Considerations¶
8.1 Cache key normalization (prerequisite)¶
Before implementing pre-population, the preview cache key must be normalized so the larger dimension always comes first:
// Before (current — NOT normalized):
const filename = `preview-${dto.widthMm}x${dto.heightMm}.stl`;
// After (normalized):
const w = Math.max(dto.widthMm, dto.heightMm);
const h = Math.min(dto.widthMm, dto.heightMm);
const filename = `preview-${w}x${h}.stl`;
This must be applied to both generatePreview() (cache read/write) and the population
job producer. Without normalization, a customer requesting 320×450 would miss the cache
entry stored as preview-450x320.stl.
8.2 Separate queue from order processing¶
The population queue (preview-populate) must be separate from the existing
GRIDFLOCK_GENERATION_QUEUE used for order processing. Order generation must never be
blocked or delayed by cache population.
8.3 Throttling and CPU impact¶
Each preview generation saturates all available CPU cores (2 workers on 2 vCPUs). During pre-population:
- Order processing (which also uses CPU for STL generation) would compete for resources.
- Consider adding a configurable delay between jobs (e.g., 1–2 seconds) to leave breathing room.
- Alternatively, run population during off-peak hours only (night/weekend).
- On the current 2-vCPU staging server, population will make the server noticeably slower for other operations.
8.4 Memory pressure¶
Each STL generation loads JSCAD geometry into memory. For large grids (500+ cells), this
can consume significant RAM. With concurrency: 1 on the population queue, only one
combination is generated at a time, keeping memory usage bounded.
8.5 Dimension values in millimeters¶
The dimension range is 10 cm to 100 cm in 0.5 cm increments. In millimeters: 100, 105, 110, 115, ..., 995, 1000 (181 values, step = 5 mm).
The population job producer must enumerate all unordered pairs:
for (let w = 100; w <= 1000; w += 5) {
for (let h = w; h <= 1000; h += 5) {
// (w, h) is a unique normalized pair
queue.add('populate', { widthMm: w, heightMm: h });
}
}
9. Offline Generation on a Powerful Machine¶
9.1 Concept¶
Instead of generating STLs on the staging/production server (which has limited CPU and serves live traffic), generate the entire cache offline on a powerful multi-core machine, then upload the resulting files to the server.
┌───────────────────────────┐ scp / rsync ┌─────────────────────┐
│ Powerful local machine │ ───────────────────────────> │ Staging server │
│ (16+ cores) │ │ (2 vCPU) │
│ │ preview-100x100.stl │ │
│ $ npx ts-node │ preview-100x105.stl │ /data/gridflock/ │
│ populate-cache.ts │ preview-100x110.stl │ preview-cache/ │
│ --min 100 │ ... │ │
│ --max 1000 │ preview-1000x1000.stl │ │
│ --increment 5 │ (16,471 files, ~32 GB) │ │
│ --out ./cache-output │ │ │
└───────────────────────────┘ └─────────────────────┘
9.2 What the gridflock-core library needs to run standalone¶
The @forma3d/gridflock-core library is a pure computation library with no server
dependencies. It requires only:
| Dependency | Version | Purpose |
|---|---|---|
| Node.js | ≥ 18 | Runtime + worker_threads |
@jscad/modeling |
^2.12.7 | CSG geometry operations |
@jscad/stl-serializer |
^2.1.22 | Binary STL serialization |
It does not depend on NestJS, Prisma, Redis, BullMQ, or any server infrastructure. The key functions needed are all exported from the library's public API:
calculatePlateSet()— compute plate layout for a dimension pairgeneratePlatesParallel()— generate all plates using worker threadscombineStlBuffers()— merge plates into a single STLPRINTER_PROFILES['bambu-a1']— printer profile constantsDEFAULT_MAGNET_PARAMS,DEFAULT_EDGE_PUZZLE_PARAMS— generation defaults
9.3 Standalone CLI script design¶
A standalone script can reuse the exact same generation logic as
GridflockPreviewService.generatePreview():
// scripts/populate-cache.ts
import {
calculatePlateSet, generatePlatesParallel, combineStlBuffers,
PRINTER_PROFILES, DEFAULT_MAGNET_PARAMS, DEFAULT_EDGE_PUZZLE_PARAMS,
GRID_UNIT_MM, type StlPlacement, type PlateGenerationOptions,
} from '@forma3d/gridflock-core';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PLATE_GAP_MM = 10;
const OPTIONS: PlateGenerationOptions = {
connectorType: 'intersection-puzzle',
magnets: { ...DEFAULT_MAGNET_PARAMS, enabled: false },
edgePuzzle: DEFAULT_EDGE_PUZZLE_PARAMS,
};
async function generateOne(widthMm: number, heightMm: number, outDir: string) {
// Normalize: larger dimension first
const w = Math.max(widthMm, heightMm);
const h = Math.min(widthMm, heightMm);
const filename = `preview-${w}x${h}.stl`;
const outPath = join(outDir, filename);
if (existsSync(outPath)) return 'skipped';
const plateSet = calculatePlateSet({
targetWidthMm: w,
targetDepthMm: h,
printerProfile: PRINTER_PROFILES['bambu-a1'],
...OPTIONS,
});
const results = await generatePlatesParallel(plateSet.plates, OPTIONS);
// Same offset logic as GridflockPreviewService
const placements: StlPlacement[] = plateSet.plates.map((plate, i) => ({
stlBuffer: results[i].stlBuffer,
offsetX: /* computed offsets */,
offsetY: /* computed offsets */,
}));
const combined = combineStlBuffers(placements);
writeFileSync(outPath, combined);
return 'generated';
}
// Main loop: enumerate all combinations
async function main() {
const { min, max, increment, outDir } = parseArgs();
mkdirSync(outDir, { recursive: true });
const pairs = [];
for (let w = min; w <= max; w += increment) {
for (let h = w; h <= max; h += increment) {
pairs.push([w, h]);
}
}
// Sort by grid cell count (smallest first)
pairs.sort((a, b) =>
Math.floor(a[0]/42) * Math.floor(a[1]/42) -
Math.floor(b[0]/42) * Math.floor(b[1]/42)
);
let completed = 0, skipped = 0;
for (const [w, h] of pairs) {
const result = await generateOne(w, h, outDir);
if (result === 'skipped') skipped++;
completed++;
// Progress output every 100 combinations
if (completed % 100 === 0) {
console.log(`${completed}/${pairs.length} (${skipped} skipped)`);
}
}
}
This script would live in the monorepo and import from @forma3d/gridflock-core directly
(using the workspace's TypeScript build). It can be run with npx ts-node or compiled
and run with node.
9.4 Parallelism on multi-core machines¶
The current parallel-generator.ts uses MAX_WORKERS = Math.max(2, cpus().length).
Each combination distributes its plates across all available workers. However, the
parallelism per combination is limited by the number of plates:
| Dimension range | Plates | Workers used (16-core machine) | Idle cores |
|---|---|---|---|
| 100–252 mm (both) | 1 | 1 (direct, no workers) | 15 |
| 253–504 mm (both) | 2–4 | 2–4 | 12–14 |
| 505–756 mm (both) | 6–9 | 6–9 | 7–10 |
| 757–1000 mm (both) | 8–16 | 8–16 | 0–8 |
Key insight: For small combinations (which are the majority), most cores sit idle. To fully utilize a powerful machine, the CLI script should run multiple combinations concurrently with a pool-based approach:
Strategy A: Sequential combinations, all cores per combination (current behavior)
→ Simple but wastes cores on small combinations
Strategy B: Concurrent combinations with adaptive worker count
→ Run N combinations in parallel, each gets cpus()/N workers
→ Better utilization but requires modifying MAX_WORKERS behavior
Strategy C: Fixed concurrency pool (recommended)
→ Run 4 combinations concurrently on a 16-core machine
→ Each combination gets ~4 workers (cpus()/4)
→ Simple to implement: use a promise pool (e.g., p-limit)
Recommendation: Strategy C is the best balance of simplicity and performance.
The CLI script would use a concurrency pool (e.g., p-limit or a simple semaphore):
import pLimit from 'p-limit';
const CONCURRENCY = Math.max(1, Math.floor(cpus().length / 4));
const limit = pLimit(CONCURRENCY);
// Override MAX_WORKERS for this session
process.env['POPULATE_WORKERS_PER_JOB'] = String(
Math.floor(cpus().length / CONCURRENCY)
);
await Promise.all(
pairs.map(([w, h]) => limit(() => generateOne(w, h, outDir)))
);
This requires a small change to parallel-generator.ts to respect an environment
variable for worker count (or pass it as a parameter to generatePlatesParallel).
9.5 Performance estimates by machine¶
| Machine | Cores | Strategy | Concurrent jobs | Est. total time |
|---|---|---|---|---|
| Staging droplet (current) | 2 vCPU | Sequential | 1 | ~69 hours |
| Developer laptop (M-series Mac) | 8–10 cores | Pool of 2 | 2 | ~20–25 hours |
| Developer workstation (high-end) | 16 cores | Pool of 4 | 4 | ~10–14 hours |
| Cloud instance (c6i.4xlarge) | 16 vCPU | Pool of 4 | 4 | ~12–16 hours |
| Cloud instance (c6i.8xlarge) | 32 vCPU | Pool of 8 | 8 | ~7–10 hours |
| Cloud instance (c7g.16xlarge) | 64 vCPU | Pool of 16 | 16 | ~4–6 hours |
Cloud cost (AWS on-demand, us-east-1): - c6i.4xlarge: \(0.68/hr × 14h = **~\)10** - c6i.8xlarge: \(1.36/hr × 10h = **~\)14** - c7g.16xlarge: \(2.18/hr × 5h = **~\)11**
The cloud option is remarkably cheap and would complete in a fraction of the time. A spot instance would reduce cost by ~60–70%.
9.6 Transfer to staging/production¶
After generation, the cache files need to be uploaded to the server's Docker volume.
Option A: tar + scp (simple, one-shot)
# On the generation machine
tar czf preview-cache.tar.gz -C ./cache-output .
# Upload to server (~32 GB compressed to ~20 GB with gzip)
scp -i ~/.ssh/key preview-cache.tar.gz root@server:/tmp/
# On the server
docker exec -u 0 forma3d-gridflock-service \
tar xzf /tmp/preview-cache.tar.gz -C /data/gridflock/preview-cache/
rm /tmp/preview-cache.tar.gz
Estimated transfer time at 100 Mbps: ~20 GB / 12.5 MB/s ≈ ~27 minutes.
Option B: rsync (incremental, resumable)
# Sync only new/changed files (resumable if interrupted)
rsync -avz --progress \
./cache-output/ \
root@server:/data/gridflock/preview-cache/
rsync is preferred for incremental updates (e.g., after adding new dimension ranges) and can resume interrupted transfers.
Option C: Direct Docker volume mount via SSH
# Stream tar directly into the container (no temp file on server)
tar czf - -C ./cache-output . | \
ssh root@server "docker exec -i -u 0 forma3d-gridflock-service \
tar xzf - -C /data/gridflock/preview-cache/"
File ownership: The cache directory is owned by nestjs:nodejs (UID 1001:1001).
After uploading via root, fix permissions:
docker exec -u 0 forma3d-gridflock-service \
chown -R nestjs:nodejs /data/gridflock/preview-cache/
9.7 Verifying cache integrity after upload¶
After uploading, the admin can use the existing Preview Cache screen to verify:
- File count matches expected combinations.
- Total size is within the expected range.
- Spot-check a few dimensions in the storefront configurator (should load instantly).
A verification endpoint could also be added:
GET /api/v1/gridflock/preview-cache/populate/coverage
?minMm=100&maxMm=1000&incrementMm=5
→ { total: 16471, cached: 16471, missing: 0, coverage: "100%" }
9.8 Pros and cons of offline vs. server-side generation¶
| Aspect | Server-side (BullMQ) | Offline (CLI + upload) |
|---|---|---|
| Server impact | Sustained CPU load for days | Zero impact |
| Speed | ~69 hours (2 vCPU) | ~5–14 hours (16–64 cores) |
| Cost | Included in server hosting | ~$10–15 for a cloud instance |
| Automation | Fully automated (start from UI) | Manual: run script, upload, fix permissions |
| Resumability | BullMQ persists state in Redis | Script skips existing files, inherently resumable |
| Incremental updates | Re-run skips existing | rsync only uploads new files |
| Pause/stop | Native BullMQ support | Ctrl+C, resume later (skips existing) |
| Progress visibility | Admin UI with progress bar | Terminal output |
| Dependencies | All in place | Node.js + monorepo on generation machine |
| Disk space on server | Fills gradually (manageable) | Full 32 GB arrives at once (need space) |
9.9 Recommendation¶
Both approaches should be implemented — they serve different use cases:
- Server-side BullMQ population for:
- Small/incremental runs (e.g., after changing max dimension setting)
- "Quick populate" with coarse increment (100 mm step = 55 combinations, ~14 minutes)
-
Topping off the cache after individual deletions
-
Offline CLI script for:
- Initial full population of 16,471 combinations
- Re-generating the full cache after STL geometry changes (e.g., connector redesign)
- Environments where the server cannot afford sustained CPU load
10. Other Alternatives Considered¶
10.1 Pre-compute during CI/CD¶
Generate the full cache as a build step and bake it into the Docker image or push to an object store.
- Pro: Fully deterministic, no runtime cost.
- Con: 32 GB Docker image is impractical; would need object store.
- Con: Build times would increase by ~70 hours (unacceptable).
- Verdict: Not viable with current generation speed.
10.2 Lazy population with priority queue¶
Instead of generating all 16,471 up front, pre-populate only the most common dimensions (e.g., multiples of 5 cm → ~361 combinations) and let the rest fill on-demand.
- Pro: Covers the common cases with ~2% of the work.
- Con: Long tail of uncommon dimensions still has cold starts.
- Verdict: Good pragmatic middle ground. Could be offered as a "quick populate" option.
11. Summary¶
| Metric | Value |
|---|---|
| Total combinations (default params) | 16,471 |
| Configurable parameters | min (100mm), max (1000mm), increment (5mm) |
| Estimated total storage | ~32 GB (range: 25–40 GB) |
| Estimated generation time (2 vCPU) | ~69 hours |
| Estimated generation time (16 cores, offline) | ~10–14 hours |
| Cloud compute cost (offline, 16 vCPU) | ~$10–15 |
| Smallest file | ~52 KB (100 × 100 mm) |
| Largest file | ~6.9 MB (1000 × 1000 mm) |
| Average file size | ~2.1 MB |
| New dependencies needed | None (BullMQ + Socket.io already available) |
| Prerequisites | Cache key normalization |
Recommended implementation order¶
- Normalize cache keys (larger dimension first) — small change, big prerequisite.
- Add configurable parameters to populate API (minMm, maxMm, incrementMm with defaults).
- Add
preview-populateBullMQ queue with processor. - Add API endpoints (start, pause, resume, stop, status, coverage).
- Add frontend UI (populate button, parameter fields, progress bar, controls).
- Create offline CLI script (
scripts/populate-cache.ts) with concurrent pool. - Optional: Add Socket.io real-time progress events.
- Optional: Add coverage verification endpoint.