Skip to content

Validate TTL and invalidation duration arithmetic consistently #33

Description

@lan17

Priority

P2 — Medium — numeric-boundary hardening after the current safety foundations.

Current problem

DialCache accepts any positive safe-integer TTL in seconds. The process-local layer multiplies that value by 1,000 without validating the product, while the Redis layer rejects an unsafe millisecond product. futureBufferMs is validated as a nonnegative safe integer before Redis Lua adds it to Redis wall time, but the supported sum is not explicitly bounded.

Current evidence:

  • TTL resolution:
    const ttlSec = config.ttlSec[options.layer];
    if (ttlSec === undefined) {
    return { status: "disabled", reason: "missing_config" };
    }
    if (!Number.isSafeInteger(ttlSec) || ttlSec <= 0) {
    return { status: "disabled", reason: "invalid_ttl" };
    }
    const configuredRamp = config.ramp[options.layer];
    if (configuredRamp === undefined) {
    return { status: "disabled", reason: "missing_config" };
    }
    if (!Number.isFinite(configuredRamp)) {
    return { status: "disabled", reason: "ramped_down" };
    }
    const ramp = clampPercentage(configuredRamp);
  • Process-local multiplication:
    }
    async put<T>(key: DialCacheKey, value: T, config?: { readonly ttlSec: number }): Promise<void> {
    const ttlSec = config?.ttlSec ?? await this.resolveLocalTtlSec(key);
    if (ttlSec === null) {
    return;
    }
    // lru-cache expires when age > ttl, while DialCache historically expired
    // when its integer-millisecond clock reached the configured boundary.
    const ttlMs = ttlSec * 1000 - 1;
    this.cache?.set(key.urn, { value }, { size: 1, ttl: ttlMs });
  • Redis millisecond validation:
    try {
    const cacheTtlMs = ttlSec * 1000;
    if (!Number.isSafeInteger(cacheTtlMs)) {
    throw new RangeError("Redis cache TTL is too large");
    }
  • Invalidation input validation and Redis-time addition:

    DialCache/src/dialcache.ts

    Lines 689 to 692 in 34a45fa

    function assertValidFutureBufferMs(futureBufferMs: number): void {
    if (!Number.isSafeInteger(futureBufferMs) || futureBufferMs < 0) {
    throw new RangeError("DialCache invalidation futureBufferMs must be a nonnegative safe integer");
    }
    and
    export const INVALIDATE_CACHE_SCRIPT = [
    PARSE_WATERMARK_LUA,
    CEIL_FINITE_NUMBER_LUA,
    String.raw`local future_buffer_ms = ceil_finite_number(ARGV[1])
    local watermark_ttl_floor_ms = ceil_finite_number(ARGV[2])
    if not future_buffer_ms or future_buffer_ms < 0 then
    return redis.error_reply("ERR invalid DialCache future buffer")
    end
    if not watermark_ttl_floor_ms or watermark_ttl_floor_ms <= 0 then
    return redis.error_reply("ERR invalid DialCache watermark TTL")
    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(
    watermark_ttl_floor_ms,
    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",

Current implementation dependency

Open #68 adds registration-time validation for static/default TTLs, but still accepts Number.MAX_SAFE_INTEGER seconds. It improves rejection timing without resolving the cross-layer arithmetic bound.

Decisions required before implementation

  • Choose the maximum supported TTL in seconds so both local and Redis millisecond representations are safe.
  • Choose the maximum supported futureBufferMs relative to Redis timestamp arithmetic.
  • Confirm that invalid static policy is rejected when cached() registers a definition, while malformed dynamic provider values continue to fail open at invocation time.

Acceptance criteria

  • Validate every relevant product and sum before a fallback result reaches storage or invalidation mutates Redis.
  • Make process-local and Redis handling consistent for the same effective policy.
  • Add exact boundary tests around the selected maxima and Number.MAX_SAFE_INTEGER.
  • Preserve fail-open handling for dynamic policy and explicit invalidation failure reporting.
  • Document the supported duration range.

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