Skip to content

Commit a9aa2be

Browse files
rustyconoverclaude
andcommitted
feat(cache/exchange): conditional revalidation (304) for streaming + LATERAL
Wire the producer cache's conditional-revalidation flow to the streaming table-in-out (M1) and correlated-LATERAL (M2) exchange operators, both transports. A worker can now advertise the always-revalidate contract (ttl=0 + etag + revalidatable) on exchange output: the entry is stored immediately-stale (memory-only), and a repeat scan confirms freshness with a cheap 0-row not_modified reply instead of recomputing. - Operators: probe LookupForRevalidation FIRST (plain Lookup evicts a stale entry); if the entry is >= vgi_result_cache_revalidate_min_bytes, arm the exchange via SetConditionalRequest. After ReadDataBatch, a 0-row not_modified reply slides the entry's TTL (SlideRevalidatedExchangeEntry) and serves the stored bytes (result_cache.revalidate outcome=not_modified); a fresh reply recaptures. - Connection: FunctionConnection::WriteInputBatch now attaches the vgi.cache.if_none_match/if_modified_since validators to the exchange input batch's metadata (the HTTP path already did via SerializeBatchWithState). - StoreExchangeMemoEntry accepts the always-revalidate (ttl=0 + etag + revalidatable) entry, storing it immediately-stale + memory-only (LookupForRevalidation probes memory; a disk immediately-stale blob is un-loadable). SlideRevalidatedExchangeEntry keeps an always-revalidate entry immediately-stale on slide (doesn't fall to a positive default). NOT wired for buffered (M3): its combine/finalize request model (key known only at Finalize) doesn't fit the per-unit validator flow — documented follow-up. Test: cache/exchange_revalidate.test (both transports, 45 assertions — M1 + M2 304). Full cache suite green (916 assertions); function_registration 132 -> 134. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZMDnzigtwqubFrqbP8JEE
1 parent bca8b43 commit a9aa2be

9 files changed

Lines changed: 337 additions & 30 deletions

CLAUDE.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -674,10 +674,25 @@ by default (needs `vgi_result_cache_dir`), so this only persists when configured
674674
`BuildExchangeCacheKeyStatic` calls `SyncResultCacheSettings` (mirroring the producer's
675675
`ConfigureIfChanged`) so `SET vgi_result_cache_dir/…` reaches the singleton on the exchange
676676
path; `DeserializeCachedRecordBatch` is disk-aware (positioned-reads a streaming entry's
677-
batch from the blob). Transaction scope is refused for exchange entries in v1. Conditional
678-
revalidation (etag/last-modified/304/stale-while-revalidate) is **producer-only** — exchange
679-
entries store the validator fields but a stale entry is a plain miss, not a 304 refresh.
680-
Fixtures (vgi-python `_test_fixtures/table_in_out.py`):
677+
batch from the blob). Transaction scope is refused for exchange entries in v1.
678+
679+
**Conditional revalidation (304).** Wired for the **streaming table-in-out (M1)** and
680+
**correlated LATERAL (M2)** operators (both transports). A worker advertises the
681+
always-revalidate contract (`ttl=0 + etag + revalidatable` → stored but immediately
682+
stale, memory-only). On a repeat, the operator probes `LookupForRevalidation` FIRST
683+
(plain `Lookup` evicts a stale entry), and if the entry is ≥
684+
`vgi_result_cache_revalidate_min_bytes` arms the exchange: the stored validators ride
685+
the `WriteInputBatch` input-batch metadata (`SetConditionalRequest`
686+
`vgi.cache.if_none_match`/`if_modified_since`), the worker's exchange `process()` reads
687+
them off `input.custom_metadata` (surfaced on `ProcessParams`) and answers a 0-row
688+
`CacheControl(not_modified=True)`, and the operator slides the entry's TTL
689+
(`SlideRevalidatedExchangeEntry`) + serves the stored bytes (`result_cache.revalidate
690+
outcome=not_modified`) instead of recomputing. The vgi-python buffered/exchange
691+
`finalize()`/`exchange()` now surface the validators. **NOT wired for buffered (M3)**
692+
its request model (combine/finalize, key known only at Finalize) doesn't fit the
693+
per-unit validator flow; a buffered stale entry is a plain miss (documented follow-up).
694+
Fixtures: `cached_reval_echo` (M1 classic), `cached_reval_double` (M2 blended).
695+
Other fixtures (vgi-python `_test_fixtures/table_in_out.py`):
681696
`cached_echo` (streaming), `cached_double` (LATERAL), `cached_sum_all` (buffered). Tests:
682697
`test/sql/integration/cache/exchange_{streaming,lateral,buffered}.test` (both transports;
683698
hit-skips-worker, LATERAL order-independence + correlated correctness, shared surface + flush).

src/include/vgi_exchange_cache_key.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ struct ExchangeStoreResult {
104104
int64_t bytes = 0;
105105
};
106106

107+
//! On a 304 not_modified reply, re-insert the stored entry with a slid TTL (and any
108+
//! refreshed validators from `cc`) so future lookups hit fresh without re-fetching.
109+
//! Mirrors the producer's MaybeSlideRevalidatedEntry. Best-effort.
110+
void SlideRevalidatedExchangeEntry(const VgiResultCacheEntry &entry, const VgiCacheControl &cc,
111+
int64_t default_ttl_seconds, bool allow_disk);
112+
107113
//! Build a cache entry from one input unit's output batches and Insert it. Freshness
108114
//! from `cc`: a positive ttl (from cc.ttl_seconds or default_ttl_seconds) is required;
109115
//! no_store and transaction-scoped results are refused. `allow_disk` opts the entry

src/include/vgi_table_in_out_impl.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ struct VgiTableInOutGlobalState : public GlobalTableFunctionState {
218218
VgiResultCacheKey cache_key; // static portion; input_hash set per exchange
219219
std::string cache_catalog_name;
220220
int64_t cache_default_ttl_seconds = 0;
221+
// Min stored-payload size before a stale revalidatable entry is conditionally
222+
// revalidated (below it, refetch instead of a conditional request).
223+
int64_t cache_revalidate_min_bytes = 262144;
221224
};
222225

223226
// ============================================================================

src/vgi_exchange_cache_key.cpp

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,38 @@ std::shared_ptr<arrow::RecordBatch> DeserializeCachedRecordBatch(const VgiResult
313313
return next_result.ValueUnsafe().batch;
314314
}
315315

316+
void SlideRevalidatedExchangeEntry(const VgiResultCacheEntry &entry, const VgiCacheControl &cc,
317+
int64_t /*default_ttl_seconds*/, bool allow_disk) {
318+
auto fresh = std::make_shared<VgiResultCacheEntry>(entry); // shallow copy (shared Buffers)
319+
fresh->stored_at = std::chrono::steady_clock::now();
320+
// A fresh ttl on the not_modified batch wins; else reuse the PRIOR lifetime so a
321+
// validator-only 304 slides forward — and, crucially, an always-revalidate entry
322+
// (prior lifetime 0 / immediately stale) stays immediately stale so it keeps
323+
// revalidating (do NOT fall to a positive default). Mirrors the producer.
324+
int64_t ttl = cc.ttl_seconds.value_or(0);
325+
if (ttl <= 0 && !entry.never_expires) {
326+
auto prior =
327+
std::chrono::duration_cast<std::chrono::seconds>(entry.expires_at - entry.stored_at).count();
328+
ttl = prior > 0 ? prior : 0;
329+
}
330+
if (ttl > VGI_CACHE_MAX_TTL_SECONDS) {
331+
ttl = VGI_CACHE_MAX_TTL_SECONDS;
332+
}
333+
if (!entry.never_expires) {
334+
fresh->expires_at = fresh->stored_at + std::chrono::seconds(ttl > 0 ? ttl : 0);
335+
}
336+
if (!cc.etag.empty()) {
337+
fresh->etag = cc.etag;
338+
}
339+
if (!cc.last_modified.empty()) {
340+
fresh->last_modified = cc.last_modified;
341+
}
342+
// An immediately-stale (always-revalidate) entry is memory-only: LookupForRevalidation
343+
// probes the in-memory index, and a disk immediately-stale blob is un-loadable.
344+
bool eff_allow_disk = allow_disk && (entry.never_expires || fresh->expires_at > fresh->stored_at);
345+
VgiResultCache::Instance().Insert(fresh, eff_allow_disk);
346+
}
347+
316348
ExchangeStoreResult StoreExchangeMemoEntry(const VgiResultCacheKey &key, const VgiCacheControl &cc,
317349
const std::string &catalog_name, int64_t default_ttl_seconds,
318350
const std::vector<std::shared_ptr<arrow::RecordBatch>> &out_batches,
@@ -335,11 +367,17 @@ ExchangeStoreResult StoreExchangeMemoEntry(const VgiResultCacheKey &key, const V
335367
if (ttl > VGI_CACHE_MAX_TTL_SECONDS) {
336368
ttl = VGI_CACHE_MAX_TTL_SECONDS;
337369
}
338-
// v1: a positive ttl is required (expires-only advertisements fall back to the
339-
// default; if that is also 0 there is no freshness basis → refuse).
340-
if (ttl <= 0) {
370+
// A positive ttl is required UNLESS this is the "always-revalidate" (HTTP no-cache)
371+
// contract: ttl=0 + a validator (etag) + revalidatable — stored but immediately
372+
// stale, so every read revalidates via a conditional request and a 304 reuses the
373+
// stored bytes. Such an entry is memory-only (LookupForRevalidation probes memory;
374+
// a disk immediately-stale blob is un-loadable).
375+
const bool immediately_stale = ttl <= 0;
376+
const bool revalidatable_no_cache = immediately_stale && cc.revalidatable && !cc.etag.empty();
377+
if (immediately_stale && !revalidatable_no_cache) {
341378
return skip("no_freshness");
342379
}
380+
const bool eff_allow_disk = allow_disk && !immediately_stale;
343381

344382
auto entry = std::make_shared<VgiResultCacheEntry>();
345383
entry->key = key;
@@ -349,7 +387,7 @@ ExchangeStoreResult StoreExchangeMemoEntry(const VgiResultCacheKey &key, const V
349387
entry->last_modified = cc.last_modified;
350388
entry->revalidatable = cc.revalidatable;
351389
entry->stored_at = std::chrono::steady_clock::now();
352-
entry->expires_at = entry->stored_at + std::chrono::seconds(ttl);
390+
entry->expires_at = entry->stored_at + std::chrono::seconds(immediately_stale ? 0 : ttl);
353391

354392
CachedStream stream;
355393
int64_t rows = 0;
@@ -372,7 +410,7 @@ ExchangeStoreResult StoreExchangeMemoEntry(const VgiResultCacheKey &key, const V
372410
entry->rows = rows;
373411
entry->total_bytes = bytes;
374412

375-
if (!VgiResultCache::Instance().Insert(std::move(entry), allow_disk)) {
413+
if (!VgiResultCache::Instance().Insert(std::move(entry), eff_allow_disk)) {
376414
return skip("too_large_for_memory");
377415
}
378416
res.stored = true;

src/vgi_function_connection.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,31 @@ void FunctionConnection::WriteInputBatch(const std::shared_ptr<arrow::RecordBatc
13421342
}
13431343
#endif
13441344

1345+
// Conditional-revalidation validators (exchange-mode result cache): ride the
1346+
// exchange input batch's custom_metadata (the worker's exchange process() reads
1347+
// them, mirroring the producer's first tick) so the worker can answer 304-style
1348+
// with a 0-row vgi.cache.not_modified batch. The caller (an exchange operator)
1349+
// sets them per input unit via SetConditionalRequest and clears them after, so
1350+
// they never leak to the next unit. Merged with any shm pointer metadata.
1351+
if (!cond_if_none_match_.empty() || !cond_if_modified_since_.empty()) {
1352+
std::vector<std::string> keys, vals;
1353+
if (write_meta) {
1354+
for (int64_t i = 0; i < write_meta->size(); i++) {
1355+
keys.push_back(write_meta->key(i));
1356+
vals.push_back(write_meta->value(i));
1357+
}
1358+
}
1359+
if (!cond_if_none_match_.empty()) {
1360+
keys.emplace_back(VGI_CACHE_IF_NONE_MATCH_KEY);
1361+
vals.push_back(cond_if_none_match_);
1362+
}
1363+
if (!cond_if_modified_since_.empty()) {
1364+
keys.emplace_back(VGI_CACHE_IF_MODIFIED_SINCE_KEY);
1365+
vals.push_back(cond_if_modified_since_);
1366+
}
1367+
write_meta = arrow::KeyValueMetadata::Make(std::move(keys), std::move(vals));
1368+
}
1369+
13451370
auto write_status = write_meta ? input_writer_->WriteRecordBatch(*to_write, write_meta)
13461371
: input_writer_->WriteRecordBatch(*to_write);
13471372
if (!write_status.ok()) {

src/vgi_lateral_batch_operator.cpp

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ struct VgiLateralBatchOperatorState : public OperatorState {
127127
VgiResultCacheKey cache_static_key;
128128
std::string cache_catalog_name;
129129
int64_t cache_default_ttl_seconds = 0;
130+
int64_t cache_revalidate_min_bytes = 262144;
130131
VgiCacheControl cache_cc; // latched from the first exchange output
131132
bool cache_cc_latched = false;
132133
// MISS capture: the current input chunk's full key + its accumulated POST-STAMP
@@ -328,6 +329,10 @@ OperatorResultType PhysicalVgiLateralBatch::Execute(ExecutionContext &context, D
328329
if (client_context.TryGetCurrentSetting("vgi_result_cache_default_ttl_seconds", ttl_v)) {
329330
state.cache_default_ttl_seconds = static_cast<int64_t>(ttl_v.GetValue<uint64_t>());
330331
}
332+
Value rmv;
333+
if (client_context.TryGetCurrentSetting("vgi_result_cache_revalidate_min_bytes", rmv) && !rmv.IsNull()) {
334+
state.cache_revalidate_min_bytes = static_cast<int64_t>(rmv.GetValue<uint64_t>());
335+
}
331336
} else if (reason) {
332337
VGI_LOG(client_context, "result_cache.ineligible",
333338
{{"function", bd.function_name}, {"reason", reason}});
@@ -377,20 +382,31 @@ OperatorResultType PhysicalVgiLateralBatch::Execute(ExecutionContext &context, D
377382
// (B) Fresh input chunk. Cache lookup on the static key + order-independent
378383
// FULL-chunk input hash. A HIT replays the cached POST-STAMP output (correlated
379384
// columns baked in — no re-stamp; sound because the operator is NO_ORDER).
385+
std::shared_ptr<const VgiResultCacheEntry> reval_entry; // armed conditional revalidation
380386
if (state.cache_eligible) {
381387
state.capture_key = state.cache_static_key;
382388
state.capture_key.input_hash = HashInputChunkUnordered(client_context, input);
383-
auto entry = VgiResultCache::Instance().Lookup(state.capture_key, std::chrono::steady_clock::now());
384-
if (entry) {
385-
VGI_LOG(client_context, "result_cache.hit",
386-
{{"function", bd.function_name}, {"key_hash", state.capture_key.HexDigest()}, {"tier", "memory"}});
387-
if (entry->streams.empty() || entry->streams[0].batches.empty()) {
388-
chunk.SetCardinality(0); // cached empty (all-filtered) result — nothing to emit
389-
return OperatorResultType::NEED_MORE_INPUT;
389+
auto now = std::chrono::steady_clock::now();
390+
// Probe conditional revalidation FIRST (Lookup evicts a stale entry). If a
391+
// large-enough stale revalidatable entry exists, arm the exchange with its
392+
// validators; the worker confirms (304 → reuse stored) or returns fresh data.
393+
auto reval = VgiResultCache::Instance().LookupForRevalidation(state.capture_key, now);
394+
if (reval && reval->revalidatable && !reval->streams.empty() &&
395+
reval->total_bytes >= state.cache_revalidate_min_bytes) {
396+
reval_entry = reval;
397+
} else {
398+
auto entry = VgiResultCache::Instance().Lookup(state.capture_key, now);
399+
if (entry) {
400+
VGI_LOG(client_context, "result_cache.hit",
401+
{{"function", bd.function_name}, {"key_hash", state.capture_key.HexDigest()}, {"tier", "memory"}});
402+
if (entry->streams.empty() || entry->streams[0].batches.empty()) {
403+
chunk.SetCardinality(0); // cached empty (all-filtered) result — nothing to emit
404+
return OperatorResultType::NEED_MORE_INPUT;
405+
}
406+
state.serving = entry;
407+
state.serve_cursor = 0;
408+
return EmitServedSlice(state, state.serve_arrow_table, chunk);
390409
}
391-
state.serving = entry;
392-
state.serve_cursor = 0;
393-
return EmitServedSlice(state, state.serve_arrow_table, chunk);
394410
}
395411
}
396412

@@ -408,6 +424,12 @@ OperatorResultType PhysicalVgiLateralBatch::Execute(ExecutionContext &context, D
408424
state.connection = AcquireBlendedInputConnection(client_context, bd, state.substream_id, projection_ids);
409425
}
410426

427+
// Arm conditional-revalidation validators for this chunk's exchange (they ride the
428+
// input batch's metadata). Cleared right after the exchange so they don't leak.
429+
if (reval_entry) {
430+
state.connection->SetConditionalRequest(reval_entry->etag, reval_entry->last_modified);
431+
}
432+
411433
// Split off the worker-input columns [0, input_length) as a zero-copy view.
412434
DataChunk worker_input;
413435
vector<LogicalType> in_types;
@@ -442,6 +464,28 @@ OperatorResultType PhysicalVgiLateralBatch::Execute(ExecutionContext &context, D
442464
bd.worker_path(), bd.function_name);
443465
}
444466

467+
// Conditional revalidation: clear the armed validators (so the next chunk's exchange
468+
// isn't a stray conditional request) and, on a 304 (0-row not_modified reply), slide
469+
// the stored entry's TTL and serve its cached POST-STAMP output instead of the worker
470+
// response. Otherwise the fresh response falls through to the normal capture path.
471+
if (reval_entry) {
472+
state.connection->SetConditionalRequest("", "");
473+
if (output_batch->num_rows() == 0) {
474+
auto cc = state.connection->GetLastCacheControl();
475+
if (cc.not_modified) {
476+
SlideRevalidatedExchangeEntry(*reval_entry, cc, state.cache_default_ttl_seconds,
477+
/*allow_disk=*/true);
478+
VGI_LOG(client_context, "result_cache.revalidate",
479+
{{"function", bd.function_name},
480+
{"key_hash", state.capture_key.HexDigest()},
481+
{"outcome", "not_modified"}});
482+
state.serving = reval_entry;
483+
state.serve_cursor = 0;
484+
return EmitServedSlice(state, state.serve_arrow_table, chunk);
485+
}
486+
}
487+
}
488+
445489
// Latch the worker's cache-control advertisement off the first exchange output.
446490
if (state.cache_eligible && !state.cache_cc_latched) {
447491
state.cache_cc = state.connection->GetLastCacheControl();

src/vgi_table_in_out_impl.cpp

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,10 @@ unique_ptr<GlobalTableFunctionState> VgiTableInOutInitGlobal(ClientContext &cont
484484
if (context.TryGetCurrentSetting("vgi_result_cache_default_ttl_seconds", ttl_v)) {
485485
global_state->cache_default_ttl_seconds = static_cast<int64_t>(ttl_v.GetValue<uint64_t>());
486486
}
487+
Value rmv;
488+
if (context.TryGetCurrentSetting("vgi_result_cache_revalidate_min_bytes", rmv) && !rmv.IsNull()) {
489+
global_state->cache_revalidate_min_bytes = static_cast<int64_t>(rmv.GetValue<uint64_t>());
490+
}
487491
} else if (reason) {
488492
VGI_LOG(context, "result_cache.ineligible",
489493
{{"function", bind_data.function_name}, {"reason", reason}});
@@ -885,17 +889,32 @@ OperatorResultType VgiTableInOutFunction(ExecutionContext &context, TableFunctio
885889
std::shared_ptr<arrow::RecordBatch> output_batch;
886890
bool cache_hit = false;
887891
VgiResultCacheKey batch_key;
892+
// Conditional revalidation: a stale-but-revalidatable entry the worker can confirm
893+
// via a cheap 0-row not_modified reply (validators ride the exchange input batch).
894+
std::shared_ptr<const VgiResultCacheEntry> reval_entry;
888895
if (global_state.cache_eligible) {
889896
batch_key = global_state.cache_key;
890897
batch_key.input_hash = HashInputBatchOrdered(input_batch);
891-
auto entry = VgiResultCache::Instance().Lookup(batch_key, std::chrono::steady_clock::now());
892-
if (entry && !entry->streams.empty() && !entry->streams[0].batches.empty()) {
893-
output_batch = DeserializeCachedRecordBatch(*entry, entry->streams[0].batches[0]);
894-
cache_hit = true;
895-
VGI_LOG(client_context, "result_cache.hit",
896-
{{"function", bind_data.function_name},
897-
{"key_hash", batch_key.HexDigest()},
898-
{"tier", "memory"}});
898+
auto now = std::chrono::steady_clock::now();
899+
// Probe conditional revalidation FIRST — Lookup() drops (evicts) a stale entry,
900+
// so calling it first would destroy the very entry we want to revalidate.
901+
auto reval = VgiResultCache::Instance().LookupForRevalidation(batch_key, now);
902+
if (reval && reval->revalidatable && !reval->streams.empty() &&
903+
reval->total_bytes >= global_state.cache_revalidate_min_bytes) {
904+
// Arm the exchange with its validators; do NOT serve yet — the worker
905+
// confirms (304 → reuse stored) or returns fresh data (recapture).
906+
reval_entry = reval;
907+
conn.SetConditionalRequest(reval->etag, reval->last_modified);
908+
} else {
909+
auto entry = VgiResultCache::Instance().Lookup(batch_key, now);
910+
if (entry && !entry->streams.empty() && !entry->streams[0].batches.empty()) {
911+
output_batch = DeserializeCachedRecordBatch(*entry, entry->streams[0].batches[0]);
912+
cache_hit = true;
913+
VGI_LOG(client_context, "result_cache.hit",
914+
{{"function", bind_data.function_name},
915+
{"key_hash", batch_key.HexDigest()},
916+
{"tier", "memory"}});
917+
}
899918
}
900919
}
901920

@@ -933,10 +952,33 @@ OperatorResultType VgiTableInOutFunction(ExecutionContext &context, TableFunctio
933952
output_batch = conn.ReadDataBatch();
934953
}
935954

955+
// Conditional revalidation: if we armed validators for this unit, clear them so
956+
// the next input batch's exchange isn't a stray conditional request. If the
957+
// worker answered 304 (a 0-row vgi.cache.not_modified batch), slide the stored
958+
// entry's TTL and serve its bytes instead of re-storing — the payload is
959+
// unchanged. Otherwise the fresh response falls through to normal capture below
960+
// (replacing the stale entry).
961+
if (reval_entry) {
962+
conn.SetConditionalRequest("", "");
963+
if (output_batch && output_batch->num_rows() == 0) {
964+
auto cc = conn.GetLastCacheControl();
965+
if (cc.not_modified) {
966+
SlideRevalidatedExchangeEntry(*reval_entry, cc, global_state.cache_default_ttl_seconds,
967+
/*allow_disk=*/true);
968+
output_batch = DeserializeCachedRecordBatch(*reval_entry, reval_entry->streams[0].batches[0]);
969+
cache_hit = true; // serve the stored bytes; skip the store below
970+
VGI_LOG(client_context, "result_cache.revalidate",
971+
{{"function", bind_data.function_name},
972+
{"key_hash", batch_key.HexDigest()},
973+
{"outcome", "not_modified"}});
974+
}
975+
}
976+
}
977+
936978
// Latch the worker's cache-control advertisement off the first exchange
937979
// output, then (if cacheable) memoize this input batch's output. Skip on the
938980
// terminal EOS batch (nullptr) — there is nothing to cache and cc rides data.
939-
if (global_state.cache_eligible && output_batch) {
981+
if (!cache_hit && global_state.cache_eligible && output_batch) {
940982
if (!local_state.cache_cc_latched) {
941983
local_state.cache_cc = conn.GetLastCacheControl();
942984
local_state.cache_cc_latched = true;

0 commit comments

Comments
 (0)