Skip to content

Characterize and guard watermark cardinality and Redis Cluster hot slots #42

Description

@lan17

Priority

P2 — Medium — measure and document tracked-cache scale limits before high-cardinality or hot-ID adoption; the former fixed-retention P1 risk was removed.

v0.14.0 grooming verification (2026-08-01)

The scale risk remains on main@34bd022: each tracked identity owns a watermark, all variants share one cluster slot, and tracked node-redis reads remain primary-routed. The v0.14.0 shadow path adds tracked primary reads/fills. Batch invalidation PR #114 improves dispatch but does not remove per-ID hot-slot concentration; proposed stale-on-error #117 would extend value/watermark residency. Keep P2 and gather evidence through #35 before adding controls.

Shipped mitigation in v0.10.0

#88 removed the four-hour watermark floor and its public configuration knobs.

Watermark lifetime is now derived from active state:

  • A tracked write retains the watermark for at least the written value TTL plus one minute.
  • An invalidation retains it for at least the remaining future-buffer window plus one minute.
  • Neither operation shortens a longer or persistent watermark.
  • Reads do not extend watermark lifetime.

This removes the stale capacity model that assumed every new tracked ID remained for at least four hours.

Current evidence:

  • Tracked write TTL derivation:
    export const WRITE_TRACKED_CACHE_SCRIPT = [
    PARSE_WATERMARK_LUA,
    CEIL_FINITE_NUMBER_LUA,
    VALIDATE_WRITE_ARGUMENTS_LUA,
    REDIS_TIME_LUA,
    String.raw`local raw_watermark = redis.call("GET", KEYS[2])
    local watermark = 0
    if raw_watermark then
    watermark = parse_watermark(raw_watermark)
    if not watermark then
    return redis.error_reply("ERR invalid DialCache watermark")
    end
    end
    if watermark >= now_ms then
    return 0
    end`,
    WRITE_FRAME_LUA,
    String.raw`local desired_ttl_ms = cache_ttl_ms + ${WATERMARK_TTL_MARGIN_MS}
    if not raw_watermark then
    redis.call("SET", KEYS[2], "0", "PX", desired_ttl_ms)
    else
    local current_ttl_ms = redis.call("PTTL", KEYS[2])
    if current_ttl_ms == -2 then
    redis.call("SET", KEYS[2], raw_watermark, "PX", desired_ttl_ms)
    elseif current_ttl_ms ~= -1 and current_ttl_ms < desired_ttl_ms then
    redis.call("PEXPIRE", KEYS[2], desired_ttl_ms)
    end
    end`,
    "return 1",
    ].join("\n\n");
  • Invalidation TTL derivation:
    export const INVALIDATE_CACHE_SCRIPT = [
    PARSE_WATERMARK_LUA,
    CEIL_FINITE_NUMBER_LUA,
    String.raw`local future_buffer_ms = ceil_finite_number(ARGV[1])
    if not future_buffer_ms or future_buffer_ms < 0 then
    return redis.error_reply("ERR invalid DialCache future buffer")
    end`,
    REDIS_TIME_LUA,
    String.raw`local proposed_watermark = now_ms + future_buffer_ms
    local raw_watermark = redis.call("GET", KEYS[1])
    local current_watermark = 0
    if raw_watermark then
    local parsed_watermark = parse_watermark(raw_watermark)
    if parsed_watermark then
    current_watermark = parsed_watermark
    end
    end
    local watermark = math.ceil(math.max(current_watermark, proposed_watermark))
    local current_ttl_ms = -2
    if raw_watermark then
    current_ttl_ms = redis.call("PTTL", KEYS[1])
    end
    local desired_ttl_ms = math.max(
    future_buffer_ms + ${WATERMARK_TTL_MARGIN_MS},
    watermark - now_ms + ${WATERMARK_TTL_MARGIN_MS}
    )
    if current_ttl_ms > desired_ttl_ms then
    desired_ttl_ms = current_ttl_ms
    end
    local encoded_watermark = string.format("%.0f", watermark)
    if current_ttl_ms == -1 then
    redis.call("SET", KEYS[1], encoded_watermark)
    else
    redis.call("SET", KEYS[1], encoded_watermark, "PX", desired_ttl_ms)
    end`,
    "return 1",
    ].join("\n\n");
  • Real-Redis lifetime coverage:
    it("does not rewrite sufficient or persistent watermarks on tracked writes", async () => {
    if (client === undefined || admin === undefined) {
    throw new Error("Redis test clients did not start");
    }
    const scriptClient = client.adapter;
    const sufficientValueKey = "write-sufficient:{item:sufficient}:value";
    const sufficientWatermarkKey = "write-sufficient:{item:sufficient}:watermark";
    await admin.set(sufficientWatermarkKey, "1.75", { PX: 120_000 });
    const sufficientTtlBefore = await admin.pTTL(sufficientWatermarkKey);
    expect(
    await scriptClient.write({
    valueKey: sufficientValueKey,
    watermarkKey: sufficientWatermarkKey,
    cacheTtlMs: 2_000,
    value: "cached",
    }),
    ).toBe(true);
    expect(await admin.get(sufficientWatermarkKey)).toBe("1.75");
    expect(await admin.pTTL(sufficientWatermarkKey)).toBeGreaterThan(sufficientTtlBefore - 1_000);
    expect(await admin.pTTL(sufficientWatermarkKey)).toBeLessThanOrEqual(sufficientTtlBefore);
    const persistentValueKey = "write-persistent:{item:persistent}:value";
    const persistentWatermarkKey = "write-persistent:{item:persistent}:watermark";
    await admin.set(persistentWatermarkKey, "2.25");
    expect(
    await scriptClient.write({
    valueKey: persistentValueKey,
    watermarkKey: persistentWatermarkKey,
    cacheTtlMs: 2_000,
    value: "cached",
    }),
    ).toBe(true);
    expect(await admin.get(persistentWatermarkKey)).toBe("2.25");
    expect(await admin.pTTL(persistentWatermarkKey)).toBe(-1);
    });
    it("atomically blocks writes during the buffer and extends watermark TTL", async () => {
    if (client === undefined || admin === undefined) {
    throw new Error("Redis test clients did not start");
    }
    const scriptClient = client.adapter;
    const valueKey = "protocol:{item:ttl}:value";
    const watermarkKey = "protocol:{item:ttl}:watermark";
    const writeRequest = {
    valueKey,
    watermarkKey,
    cacheTtlMs: 2_000,
    value: "cached",
    };
    expect(await scriptClient.write(writeRequest)).toBe(true);
    expect(await admin.get(watermarkKey)).toBe("0");
    const ttlAfterWrite = await admin.pTTL(watermarkKey);
    expect(ttlAfterWrite).toBeGreaterThanOrEqual(61_000);
    await scriptClient.invalidate({ watermarkKey, futureBufferMs: 100 });
    expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull();
    expect(await scriptClient.write({ ...writeRequest, value: "blocked" })).toBe(false);
    expect(await scriptClient.read({ valueKey })).toBe("cached");
    const ttlBeforeRead = await admin.pTTL(watermarkKey);
    await scriptClient.read({ valueKey, watermarkKey });
    expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(ttlBeforeRead);
    await new Promise((resolve) => setTimeout(resolve, 110));
    expect(await scriptClient.write({ ...writeRequest, value: "fresh" })).toBe(true);
    expect(await scriptClient.read({ valueKey, watermarkKey })).toBe("fresh");
    });
    it("documents that losing a watermark removes its publication fence", async () => {
    if (client === undefined || admin === undefined) {
    throw new Error("Redis test clients did not start");
    }
    const scriptClient = client.adapter;
    const valueKey = "watermark-loss:{item:tracked}:value";
    const watermarkKey = "watermark-loss:{item:tracked}:watermark";
    const staleWrite = {
    valueKey,
    watermarkKey,
    cacheTtlMs: 60_000,
    value: "stale",
    };
    await scriptClient.invalidate({ watermarkKey, futureBufferMs: 60_000 });
    expect(await scriptClient.write(staleWrite)).toBe(false);
    await admin.del(watermarkKey);
    expect(await scriptClient.write(staleWrite)).toBe(true);
    expect(await admin.get(watermarkKey)).toBe("0");

Remaining scale risks

The residual risks are workload-dependent but real:

  • Every active tracked keyType / id owns one watermark.
  • All use-case/argument variants for one tracked ID share one Redis Cluster hash slot.
  • Tracked node-redis reads stay on primaries so replica lag cannot hide invalidation.
  • A high-cardinality workload can retain many marker keys for its effective value/future-window lifetime; a celebrity ID can concentrate all variants on one primary.

The relevant capacity model is now approximately:

active distinct tracked-ID rate × effective derived watermark lifetime

plus the workload's ID-skew distribution and tracked-read/write QPS.

Scope

  • Extend Add a repeatable performance and scale benchmark suite #35 with tracked-write and invalidation scenarios.
  • Measure memory/operations for high-cardinality tracked writes across representative value TTLs.
  • Measure primary/cluster throughput for uniform and highly skewed IDs.
  • Document the derived-lifetime capacity model, primary-only read constraint, and per-ID hot-slot limit.
  • Use existing Redis/client telemetry first. Do not add public configuration, a new metric family, or sparse-watermark protocol complexity until measurements prove the need.

Acceptance criteria

  • Reproducible memory and operation results for representative distinct-ID rates and TTLs.
  • Reproducible cluster results for uniform and hot-ID distributions.
  • Documentation gives operators the capacity model and concrete signals to monitor.
  • Any proposed new guardrail is tied to measured failure pressure and preserves invalidation correctness.
  • Logical clock correctness remains owned by Document the synchronized Redis clock assumption for tracked invalidation #32.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions