Skip to content

Commit 2a3a363

Browse files
perf(apps_script): batch CacheService getAll + edge-DNS hot-path wins
1 parent f28b6b1 commit 2a3a363

2 files changed

Lines changed: 632 additions & 37 deletions

File tree

assets/apps_script/CodeFull.gs

Lines changed: 156 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ const SKIP_HEADERS = {
4949
// re-firing them.
5050
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
5151

52+
// Compiled once to avoid re-parsing per request in the relay hot path.
53+
const URL_RE = /^https?:\/\//i;
54+
5255
// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style
5356
// placeholder page — no proxy-shaped JSON, nothing distinctive enough
5457
// for a probe to fingerprint as a tunnel endpoint.
@@ -72,11 +75,15 @@ function _decoyOrError(jsonBody) {
7275
// does its own DoH lookup on a miss from inside Google's network.
7376
// Cache hits never reach the tunnel-node.
7477
//
75-
// Safety property: any failure (parse error, DoH unreachable,
76-
// CacheService error, refused qtype) returns null from _edgeDnsTry,
77-
// and the op falls through to the existing tunnel-node forward path.
78-
// Set false to disable and forward all DNS through the tunnel as
79-
// before.
78+
// Safety property: parse errors, refused qtypes, and "every DoH resolver
79+
// failed" return null from _edgeDnsResolve and the op falls through to
80+
// the existing tunnel-node forward path. CacheService failures (transient
81+
// quota, getAll exceptions, oversize keys) are softer: the per-batch
82+
// cache lookup is skipped and no put happens, but DoH still runs from
83+
// inside Google's network. The per-op outcome degrades to "uncached
84+
// forward via DoH" rather than "forwarded all the way to the tunnel-node".
85+
// Set ENABLE_EDGE_DNS_CACHE=false to disable the whole feature and route
86+
// all DNS through the tunnel as before.
8087
const ENABLE_EDGE_DNS_CACHE = true;
8188

8289
// DoH endpoints tried in order on cache miss. All speak RFC 8484
@@ -96,8 +103,9 @@ const EDGE_DNS_MAX_TTL_S = 21600; // 6h CacheService ceiling
96103
const EDGE_DNS_NEG_TTL_S = 45;
97104
const EDGE_DNS_CACHE_PREFIX = "edns:";
98105
// CacheService rejects keys longer than 250 chars. Names approaching the
99-
// 253-char DNS limit + prefix + qtype digits can exceed that, so we bail
100-
// before issuing the get/put. The op falls through to the tunnel-node.
106+
// 253-char DNS limit + prefix + qtype digits can exceed that, so keys
107+
// over this length get switched to a SHA-256-hashed form (see
108+
// _edgeDnsPrepare) rather than skipping the cache entirely.
101109
const EDGE_DNS_MAX_KEY_LEN = 240;
102110

103111
// qtypes we refuse to cache and pass through to the tunnel-node:
@@ -183,6 +191,15 @@ function _doTunnel(req) {
183191
// Batch tunnel: forward all ops in one request to /tunnel/batch.
184192
// When ENABLE_EDGE_DNS_CACHE is true, udp_open/port=53 ops are served
185193
// locally where possible and only the remainder is forwarded.
194+
//
195+
// Edge-DNS resolution runs in two passes so the CacheService backend
196+
// is hit exactly once for the whole batch:
197+
// pass 1: parse each candidate's question and collect cache keys
198+
// one cache.getAll(keys) call serves every hit
199+
// pass 2: resolve each candidate (cache hit → synth; miss → DoH; null
200+
// → tunnel-node forward)
201+
// On a 5-DNS-query batch, this collapses 5 serial cache.get round trips
202+
// into one cache.getAll round trip.
186203
function _doTunnelBatch(req) {
187204
var ops = (req && req.ops) || [];
188205

@@ -195,19 +212,59 @@ function _doTunnelBatch(req) {
195212
var forwardOps = [];
196213
var forwardIdx = [];
197214

215+
// Pass 1: route non-DNS ops to forward, parse DNS candidates.
216+
var candidates = []; // [{ i, prep }, ...]
198217
for (var i = 0; i < ops.length; i++) {
199218
var op = ops[i];
200219
if (op && op.op === "udp_open" && op.port === 53 && op.d) {
201-
var synth = _edgeDnsTry(op);
202-
if (synth) {
203-
results[i] = synth;
220+
var prep = _edgeDnsPrepare(op);
221+
if (prep) {
222+
candidates.push({ i: i, prep: prep });
204223
continue;
205224
}
206225
}
207226
forwardOps.push(op);
208227
forwardIdx.push(i);
209228
}
210229

230+
// One batched cache lookup for every DNS candidate. CacheService.getAll
231+
// returns a {key: value} map populated only for hits; missing keys are
232+
// simply absent. Any failure (transient quota, backend hiccup) returns
233+
// an empty map so each candidate falls through to its own DoH attempt
234+
// with no cached put either — the safe degradation path.
235+
var cacheMap = {};
236+
var cache = null;
237+
if (candidates.length > 0) {
238+
try {
239+
cache = CacheService.getScriptCache();
240+
var keys = new Array(candidates.length);
241+
for (var c = 0; c < candidates.length; c++) {
242+
keys[c] = candidates[c].prep.key;
243+
}
244+
cacheMap = cache.getAll(keys) || {};
245+
} catch (_) {
246+
cacheMap = {};
247+
cache = null;
248+
}
249+
}
250+
251+
// Pass 2: resolve each candidate. cacheMap doubles as the in-batch dedup
252+
// table — a successful DoH writes its encoded reply back into cacheMap
253+
// so a later candidate with the same qname/qtype hits without re-DoH.
254+
// On null (cache miss + DoH all failed), append to the forward path so
255+
// the tunnel-node still gets a chance.
256+
for (var c = 0; c < candidates.length; c++) {
257+
var cand = candidates[c];
258+
var synth = _edgeDnsResolve(
259+
cand.prep, cacheMap[cand.prep.key] || null, cache, cacheMap);
260+
if (synth) {
261+
results[cand.i] = synth;
262+
} else {
263+
forwardOps.push(ops[cand.i]);
264+
forwardIdx.push(cand.i);
265+
}
266+
}
267+
211268
// All ops served locally — no tunnel-node round-trip.
212269
if (forwardOps.length === 0) {
213270
return _json({ r: results });
@@ -279,7 +336,7 @@ function _spliceTunnelResults(forwardIdx, forwardedResults, allResults) {
279336
// ========================== HTTP relay mode ==========================
280337

281338
function _doSingle(req) {
282-
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
339+
if (!req.u || typeof req.u !== "string" || !URL_RE.test(req.u)) {
283340
return _json({ e: "bad url" });
284341
}
285342
var opts = _buildOpts(req);
@@ -302,7 +359,7 @@ function _doBatch(items) {
302359
errorMap[i] = "bad item";
303360
continue;
304361
}
305-
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
362+
if (!item.u || typeof item.u !== "string" || !URL_RE.test(item.u)) {
306363
errorMap[i] = "bad url";
307364
continue;
308365
}
@@ -403,12 +460,20 @@ function _buildOpts(req) {
403460
return opts;
404461
}
405462

463+
// Lazy module-level cache of the runtime feature check; reset between GAS
464+
// executions but reused across all responses inside a single execution
465+
// (batches of 50+ make this matter).
466+
var _hasGetAllHeaders = null;
467+
406468
function _respHeaders(resp) {
407-
try {
408-
if (typeof resp.getAllHeaders === "function") {
469+
if (_hasGetAllHeaders === null) {
470+
_hasGetAllHeaders = (typeof resp.getAllHeaders === "function");
471+
}
472+
if (_hasGetAllHeaders) {
473+
try {
409474
return resp.getAllHeaders();
410-
}
411-
} catch (err) {}
475+
} catch (err) {}
476+
}
412477
return resp.getHeaders();
413478
}
414479

@@ -433,31 +498,54 @@ function _json(obj) {
433498

434499
// ========================== Edge DNS helpers ==========================
435500

436-
// Tries to serve a single udp_open DNS op from CacheService or DoH.
437-
// Returns a synthesized batch-result {sid, pkts, eof} on success, or null
438-
// on any failure / unsupported case so the caller can forward to the
439-
// tunnel-node. Null is the safe default — every error path returns null.
440-
function _edgeDnsTry(op) {
501+
// Phase-1 helper: parses a udp_open op into the data needed for both the
502+
// batched cache lookup and the eventual resolve. Returns {bytes, q, key}
503+
// on success, or null for unparseable/refused ops so the caller can route
504+
// them to the tunnel-node forward path.
505+
//
506+
// Long qnames that would exceed CacheService's 250-char key limit fall back
507+
// to a SHA-256-hashed key under a separate `edns:h:` namespace. The
508+
// 256-bit digest makes accidental collisions astronomically unlikely, and
509+
// the distinct namespace prevents short-name keys from colliding with
510+
// hashed long-name keys.
511+
function _edgeDnsPrepare(op) {
441512
try {
442513
var bytes = Utilities.base64Decode(op.d);
443514
if (!bytes || bytes.length < 12) return null;
444-
445515
var q = _dnsParseQuestion(bytes);
446516
if (!q) return null;
447517
if (EDGE_DNS_REFUSE_QTYPES[q.qtype]) return null;
448-
449518
var key = EDGE_DNS_CACHE_PREFIX + q.qtype + ":" + q.qname;
450-
if (key.length > EDGE_DNS_MAX_KEY_LEN) return null;
451-
var cache = CacheService.getScriptCache();
519+
if (key.length > EDGE_DNS_MAX_KEY_LEN) {
520+
key = EDGE_DNS_CACHE_PREFIX + "h:" + q.qtype + ":" + _sha256Hex(q.qname);
521+
}
522+
return { bytes: bytes, q: q, key: key };
523+
} catch (_) {
524+
return null;
525+
}
526+
}
452527

453-
var stored = null;
454-
try { stored = cache.get(key); } catch (_) {}
455-
if (stored) {
528+
// Phase-2 helper: given a prepared op and an optional pre-fetched cache
529+
// value, returns a synthesized batch-result {sid, pkts, eof} on success,
530+
// or null on any failure so the caller can forward to the tunnel-node.
531+
//
532+
// `cache` is the CacheService handle reused across the batch (or null
533+
// if CacheService is unavailable, in which case DoH still runs
534+
// but no put).
535+
// `localMap` is an optional in-batch lookup table (typically the same
536+
// object returned by cache.getAll). When DoH succeeds, the
537+
// encoded reply is written back to localMap[prep.key] so that
538+
// a later candidate in the same batch with the same qname/qtype
539+
// hits without a second DoH round-trip.
540+
function _edgeDnsResolve(prep, cachedReplyB64, cache, localMap) {
541+
try {
542+
if (cachedReplyB64) {
456543
try {
457-
var hit = Utilities.base64Decode(stored);
544+
var hit = Utilities.base64Decode(cachedReplyB64);
458545
if (hit && hit.length >= 12) {
459-
// Rewrite txid to match this query (RFC 1035 §4.1.1).
460-
var rewritten = _dnsRewriteTxid(hit, q.txid);
546+
// Rewrite txid to match this query (RFC 1035 §4.1.1). Returns a
547+
// copy so the cached bytes themselves are never mutated.
548+
var rewritten = _dnsRewriteTxid(hit, prep.q.txid);
461549
return {
462550
sid: "edns-cache",
463551
pkts: [Utilities.base64Encode(rewritten)],
@@ -468,7 +556,7 @@ function _edgeDnsTry(op) {
468556
}
469557

470558
for (var i = 0; i < EDGE_DNS_RESOLVERS.length; i++) {
471-
var reply = _edgeDnsDoh(EDGE_DNS_RESOLVERS[i], bytes);
559+
var reply = _edgeDnsDoh(EDGE_DNS_RESOLVERS[i], prep.bytes);
472560
if (!reply) continue;
473561

474562
var rcode = reply[3] & 0x0F;
@@ -482,15 +570,24 @@ function _edgeDnsTry(op) {
482570
if (ttl > EDGE_DNS_MAX_TTL_S) ttl = EDGE_DNS_MAX_TTL_S;
483571
}
484572

485-
try {
486-
cache.put(key, Utilities.base64Encode(reply), ttl);
487-
} catch (_) {
488-
// >100KB value or transient quota — still return the live answer.
573+
// Encode once and reuse for both the persistent cache and the
574+
// in-batch dedup map. The reply bytes carry the resolver-echoed
575+
// txid; any future hit rewrites it to that request's txid.
576+
var encoded = (cache || localMap) ? Utilities.base64Encode(reply) : null;
577+
if (cache) {
578+
try {
579+
cache.put(prep.key, encoded, ttl);
580+
} catch (_) {
581+
// >100KB value or transient quota — still return the live answer.
582+
}
583+
}
584+
if (localMap) {
585+
localMap[prep.key] = encoded;
489586
}
490587

491588
// The DoH reply already echoes our query's txid; rewrite defensively
492589
// in case a resolver mangles it.
493-
var fixed = _dnsRewriteTxid(reply, q.txid);
590+
var fixed = _dnsRewriteTxid(reply, prep.q.txid);
494591
return {
495592
sid: "edns-doh",
496593
pkts: [Utilities.base64Encode(fixed)],
@@ -503,6 +600,22 @@ function _edgeDnsTry(op) {
503600
}
504601
}
505602

603+
// Hex-encodes the SHA-256 of a UTF-8 string. Used to keep long-qname cache
604+
// keys under CacheService's 250-char limit. 64 hex chars is well below the
605+
// cap and survives any future bumps to EDGE_DNS_MAX_KEY_LEN. SHA-256 over
606+
// MD5 here is just future-proofing — the hash isn't security-sensitive
607+
// (cache namespace only), but SHA-256 avoids any "why MD5?" discussion.
608+
function _sha256Hex(s) {
609+
var d = Utilities.computeDigest(
610+
Utilities.DigestAlgorithm.SHA_256, s, Utilities.Charset.UTF_8);
611+
var hex = "";
612+
for (var i = 0; i < d.length; i++) {
613+
var b = d[i] & 0xFF;
614+
hex += (b < 16 ? "0" : "") + b.toString(16);
615+
}
616+
return hex;
617+
}
618+
506619
// Single DoH GET against `url`. Returns the reply as a byte array, or null
507620
// on any failure (HTTP non-200, network error, malformed body).
508621
function _edgeDnsDoh(url, queryBytes) {
@@ -623,6 +736,12 @@ function _dnsSkipName(bytes, off) {
623736
// big-endian 16-bit transaction id. Coerces to signed-byte range so the
624737
// result round-trips through Utilities.base64Encode regardless of whether
625738
// the runtime exposes bytes as signed Java int8 or unsigned JS numbers.
739+
//
740+
// Always copies — the cache-safety invariant (callers can hand in a buffer
741+
// they may reuse, e.g. a CacheService string round-tripped through decode)
742+
// is enforced here rather than via per-call-site reasoning. The copy is
743+
// cheap (~100 bytes for a typical DNS reply) compared to the surrounding
744+
// base64 encode/decode work.
626745
function _dnsRewriteTxid(bytes, txid) {
627746
var out = [];
628747
for (var i = 0; i < bytes.length; i++) out.push(bytes[i]);

0 commit comments

Comments
 (0)