koala73/worldmonitor · error · Error

PortWatch cache read returned invalid result

Error message

PortWatch cache read returned invalid result

What it means

This seed script reads a batch of Redis cache entries for PortWatch country data and validates each raw result. Every entry in the pipeline response must be a string (the cached JSON text); if a result object is neither null nor a string, the script throws 'PortWatch cache read returned invalid result', meaning the stored cache value has an unexpected type (e.g. a number, object, or binary value was written where a JSON string was expected).

Solutions

  1. Inspect the offending key's Redis type with TYPE <key> and DELETE/rewrite keys that are not strings
  2. Write cache values strictly with SET/SETNX as JSON.stringify(payload) strings
  3. Ensure the Redis client used for reading does not auto-decode/auto-parse values (disable json/buffer transformations)
  4. Purge the PortWatch cache keys (KEY_PREFIX*) and re-run the seed to rewrite entries as strings
  5. Pin the same Redis client library/options between writer and reader

Example fix

// before
await redis.set(key, payload);
// after
await redis.set(key, JSON.stringify(payload));
Defensive patterns

Strategy: type-guard

Validate before calling

const raw = await redis.get(key);
if (raw !== null && typeof raw !== 'string') throw new Error(`non-string cache value at ${key}`);

Type guard

const isStringResult = (r) => r == null || typeof r.result === 'string';

Try / catch

try {
  const payload = readCacheBatch(keys);
} catch (err) {
  if (err.message.includes('invalid result')) {
    logger.warn({ err }, 'cache type mismatch; purging and reseeding');
    await purgeKeys(keys);
  } else throw err;
}

Prevention

When it happens

Trigger: The Redis pipeline in the cache read returns a result entry that is non-null and not a string: typically a key was written with a non-string type (SET with JSON-serialized object instead of string, HSET leftover, or a client that auto-parsed values), or a serializer/decoder option (e.g. Redis JSON auto-deserialization) was enabled on the read client but not accounted for.

Common situations: A different seed run or worker wrote country cache keys with a different encoding; switching Redis client libraries where one returns buffers/objects; enabling a Redis JSON module or client 'json' mode so results come back as objects; stale keys written by an older script version.

Understand the failure class

Background: Type mismatch errors: IllegalArgumentException, TypeError and type guards across 150 open-source libraries — this error's family across 150 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/90bf6997a4698cad. Report an issue: GitHub.

Appendix: source

Thrown at scripts/seed-portwatch-port-activity.mjs:1042

const CORRUPT_COUNTRY_CACHE = Symbol('corrupt country cache');

// MGET-style batch read via the Upstash REST /pipeline endpoint. Returns an
// array aligned with `keys` where each element is either the parsed JSON
// payload, explicit miss, or confirmed corrupt value. Transport/envelope errors
// remain fatal: only a validated upstream replacement may overwrite corruption.
// Primes the per-country cache lookup in one round-trip instead of 174 GETs.
async function redisMgetJson(keys) {
  if (keys.length === 0) return [];
  const commands = keys.map((k) => ['GET', k]);
  const results = await redisPipeline(commands);
  if (!Array.isArray(results) || results.length !== keys.length) {
    throw new Error('PortWatch cache read returned incomplete results');
  }
  return results.map((r) => {
    if (r?.error || !Object.hasOwn(r ?? {}, 'result')) throw new Error('PortWatch cache read failed');
    if (r.result === null) return null;
    if (typeof r.result !== 'string') throw new Error('PortWatch cache read returned invalid result');
    try {
      const payload = JSON.parse(r.result);
      return payload && typeof payload === 'object' && !Array.isArray(payload)
        ? payload : CORRUPT_COUNTRY_CACHE;
    } catch {
      return CORRUPT_COUNTRY_CACHE;
    }
  });
}

// fetchAll() — pure data collection, no Redis writes.
// Returns { countries: string[], countryData: Map<iso2, payload>, fetchedAt: string }.
//
// `progress` (optional) is mutated in-place so a SIGTERM handler in main()
// can report which batch / country we died on.
//
// Orders cold-fetches by the oldest ATTEMPT, using the last successful cache
// write as the legacy fallback. This is the durable rotation cursor:

View on GitHub (pinned to 7d06c8633d)