Result Caching
Submitting the same request twice (same body, same solver logic version) returns the cached result from the first call instead of re-solving. Applies to /optimize/sync, /optimize/async, /optimize/reoptimize, /optimize/evaluate, and /optimize/replan/async. Cached results expire after RESULT_CACHE_TTL_SECONDS (default one hour). If the cache backend is unreachable, your request just solves normally — caching never turns into an error.
Why this exists
Dispatchers re-submit the same plan after a UI refresh or a double-click. Clients retry after a network timeout without knowing the first attempt already succeeded. Two callers submit an identical scenario within seconds of each other. Each of these, without caching, re-runs the full solver — seconds to tens of seconds of CPU time — to produce an answer that was already computed. The result cache recognizes an identical request and returns the previously-computed answer instead of solving again.
What counts as "identical"
Every cacheable request is reduced to a single fingerprint, a SHA-256 hash of the request body serialized in a canonical form (sorted keys, no incidental whitespace) combined with the running application's version. Two requests produce the same fingerprint only if their JSON bodies are equivalent (field order and formatting don't matter) and they were served by the same application version.
That version component matters: it is set in CI to the deployed build's commit SHA, so every new deploy invalidates every prior cache entry implicitly. There's no explicit cache-flush step, and therefore no way to accidentally forget one — old entries just become unreachable and expire on their own. This guarantees a code change that alters what the "correct" answer is for a given input can never serve a stale, pre-fix result.
| Endpoint | What's fingerprinted |
|---|---|
POST /optimize/sync | The OptimizationRequest, after recurring-job injection (see below). |
POST /optimize/async | Same as /sync — after recurring-job injection. |
POST /optimize/reoptimize | The OptimizationRequest as received (this route doesn't perform recurring-job injection). |
POST /optimize/evaluate | The full EvaluateRequest, including both base and solution_routes. A cache hit requires the exact seeded/locked routes to match too, not just the base request — a narrower hit surface than the other endpoints, by design. |
POST /optimize/replan/async | The post-transformation request actually launched and solved, not the raw ReplanRequest you send. |
POST /optimize/replan (the synchronous form) is not covered by this cache in the current iteration — only its async counterpart, /optimize/replan/async, participates. POST /optimize/validate and POST /optimize/suggest-insertion are not cacheable endpoints at all; they are not part of this feature's fingerprinted-endpoint set.
Recurring jobs and fingerprinting
If the recurring-jobs feature is enabled and your request opts in, tenant-specific recurring-pattern jobs may be silently merged into the request before it's solved. Two textually identical raw requests submitted at different times can therefore expand into different actual solver input, depending on which recurring patterns are active for your tenant at request time.
The fingerprint is computed after that injection, on the request the solver actually receives — not on the raw body you sent. This is correct by construction: the cache key reflects what was really solved, so an identical expanded request is guaranteed to be an identical problem, and a request that differs only in what got injected is guaranteed to produce a different key.
Time-to-live
Cached results expire after RESULT_CACHE_TTL_SECONDS (default 3600, one hour). This is a middle ground: long enough to catch a dispatcher re-submitting a slightly later variant of the same scenario, short enough that stale real-world conditions — traffic, vehicle availability, a job that was just marked complete — don't linger for a full planning day.
Fail-open behavior
The result cache is backed by Redis, and every Redis operation it performs fails open: if Redis is unreachable, misconfigured, or returns something unexpected, the affected call is logged and treated as a cache miss (or a no-op on writes) — never as an error surfaced to you. A broken cache degrades to "every request solves normally," not to an outage. This includes the case of a corrupted or non-JSON value ending up under a cache key: it's treated as a miss, not an exception.
You can disable the cache entirely, instantly, with RESULT_CACHE_ENABLED=false, with no other behavior change.
How this works for async requests
/optimize/async and /optimize/replan/async handle every incoming request as one of three cases:
- Cache hit. A finished result already exists for this fingerprint. A new job is created already in its terminal
completedstate, with no solver subprocess ever launched. The very firstGET /jobs/{job_id}call returnscompletedwith a result — the response shape is identical to a genuinely-completed job, with no client-visible special case. - In-flight hit. An identical request is currently being solved under a different
job_id. You get back the samejob_idas the original request — no new job is created and no second solve is launched. Both callers poll the same job and observe the same eventual result. If that solve later fails, it is retried once in place (capped at a single retry) specifically because a duplicate caller is still waiting on the outcome; if the retry also fails, the job is left terminallyfailedand any later duplicate starts a fresh attempt of its own. - New request. Neither a cache hit nor an in-flight hit — the solve is launched normally, and the in-flight marker is written before the launch so that a concurrent duplicate arriving during the launch window can still attach to it.
In short: a duplicate in-flight async request attaches to the already-running job instead of starting a new solve. You never pay for the same solve twice.
The fingerprint lookup for in-flight dedup is shared across replicas (it reads from Redis), but the job_id it returns is then looked up in an in-memory store that is not shared across replicas. With FieldGenius API running as a single process, this is a distinction without a difference. Once multiple replicas are in play, a duplicate request landing on a different replica than the one running the original job won't find that job and will fall through to launching a redundant solve. This is safe — both callers still get correct results — but wasteful, and is a known, currently-accepted gap ahead of a multi-replica rollout.
Configuration
| Variable | Default | Description |
|---|---|---|
RESULT_CACHE_ENABLED | true | Master switch. Disable instantly, without a redeploy, if the cache is ever suspected of misbehaving. |
REDIS_URL | none (required) | Redis connection string, e.g. redis://localhost:6379/0. Required whenever RESULT_CACHE_ENABLED is true — there is deliberately no default that guesses a local address, so a missing configuration fails loudly the first time a Redis call is attempted, rather than silently pointing somewhere wrong. |
RESULT_CACHE_TTL_SECONDS | 3600 | How long a cached result stays valid before a repeat request re-solves. |
curl example
# First call: full solve time
time curl -s -X POST https://api.fieldgenius.be/api/v1/optimize/sync \
-H "Content-Type: application/json" \
-H "X-API-Key: your-adapter-key" \
-H "X-Client-Key: your-client-key" \
-d @request.json -o /dev/null
# real 0m30.3s
# Second call, identical body: served from cache, near-instant
time curl -s -X POST https://api.fieldgenius.be/api/v1/optimize/sync \
-H "Content-Type: application/json" \
-H "X-API-Key: your-adapter-key" \
-H "X-Client-Key: your-client-key" \
-d @request.json -o /dev/null
# real 0m0.3s
Python example (httpx)
import time
import httpx
BASE = "https://api.fieldgenius.be/api/v1"
HEADERS = {
"X-API-Key": "your-adapter-key",
"X-Client-Key": "your-client-key",
}
with httpx.Client(timeout=60, headers=HEADERS) as client:
t0 = time.monotonic()
first = client.post(f"{BASE}/optimize/sync", json=request_payload)
first.raise_for_status()
print("first call:", time.monotonic() - t0, "seconds")
t0 = time.monotonic()
second = client.post(f"{BASE}/optimize/sync", json=request_payload)
second.raise_for_status()
print("second call (cached):", time.monotonic() - t0, "seconds")
- Live Solve Tracking — watch an async solve's progress while it runs, also backed by the same Redis infrastructure.
- Webhooks & Async — the submit/poll/retrieve cycle that result caching sits underneath.
- POST /optimize/async reference