A single-process sliding window dies with the process and cannot be shared across a worker fleet. This guide implements a distributed sliding window on Redis sorted sets — the standard pattern for correlation state that survives restarts and scales horizontally — as one precise technique within temporal correlation windows and the broader alert correlation rule engines pipeline.

Root-Cause Context

Correlation windows held in Python dictionaries have two hard limits: they are bound to one process, and they vanish on restart. A SOC running a consumer group of eight parser workers cannot hold a shared “failed logons per account in the last 60 seconds” count in any one worker’s memory, because each worker sees only its slice of the partitioned stream. Moving window state into Redis solves both problems — but only if the operations are atomic, because a read-modify-write across the network is a race that under-counts a burst exactly when it matters.

A Redis sorted set (ZSET) is the right structure: members are event ids, scores are event timestamps. Sliding the window is then two operations — evict everything with a score below now − window, and count what remains — and both are server-side. The remaining risk is doing eviction, insertion, and counting as separate round-trips; under concurrency that interleaves and mis-counts. Pipelining them into one atomic unit is what makes the distributed window correct.

Redis sorted-set sliding window operations Per correlation key, a Redis sorted set stores event ids scored by timestamp. On each event an atomic pipeline adds the new member, removes members older than now minus the window, counts the remaining members, and sets an expiry. Events older than the window fall outside the shaded window band and are evicted; the surviving count is compared against the rule threshold. ZSET per key · members = event ids · scores = timestamps time → active window [now − W, now] counted evicted < now − W new event ZREMRANGEBYSCORE ZCARD ≥ threshold?

Prerequisites

The implementation targets Python 3.11+ with the async Redis client. It assumes a reachable Redis 7 instance and events normalized upstream by the parent temporal correlation windows guide.

pip install "redis>=5.0,<6.0" "pydantic>=2.6,<3.0"

Production-Ready Implementation

Each event runs one atomic pipeline: add the member, evict the expired range, count survivors, and set a TTL so idle keys self-clean. The count is compared to the rule threshold.

from __future__ import annotations

import logging

import redis.asyncio as redis
from pydantic import BaseModel, Field

logger = logging.getLogger("soc.correlation.rediswin")


class WindowEvent(BaseModel):
    event_id: str = Field(min_length=1)
    key: str = Field(min_length=1)         # correlation key
    event_time: float = Field(gt=0)        # epoch seconds


class RedisSlidingWindow:
    def __init__(self, client: redis.Redis, window_seconds: float = 60.0,
                 max_members: int = 10_000) -> None:
        self._r = client
        self._w = window_seconds
        self._max = max_members

    def _zkey(self, key: str) -> str:
        return f"win:{key}"

    async def add_and_count(self, ev: WindowEvent) -> int:
        """Atomically insert, evict expired, and return the in-window count."""
        zkey = self._zkey(ev.key)
        cutoff = ev.event_time - self._w
        async with self._r.pipeline(transaction=True) as pipe:
            pipe.zadd(zkey, {ev.event_id: ev.event_time})
            pipe.zremrangebyscore(zkey, "-inf", f"({cutoff}")   # exclusive lower
            pipe.zcard(zkey)
            pipe.expire(zkey, int(self._w) + 5)                 # idle self-clean
            results = await pipe.execute()
        count = int(results[2])
        if count > self._max:                                   # ERR_WINDOW_031
            # Trim to the newest max_members to bound memory.
            await self._r.zremrangebyrank(zkey, 0, count - self._max - 1)
            logger.warning("ERR_WINDOW_031 key=%s trimmed to %d", ev.key, self._max)
            return self._max
        return count

    async def fires(self, ev: WindowEvent, threshold: int) -> bool:
        count = await self.add_and_count(ev)
        if count >= threshold:
            logger.info('{"msg":"window_fire","key":"%s","count":%d}', ev.key, count)
            return True
        return False


async def _demo() -> None:
    client = redis.Redis(host="localhost", port=6379, decode_responses=True)
    win = RedisSlidingWindow(client, window_seconds=60.0)
    base = 1_000_000.0
    fired = False
    for i in range(12):
        fired = await win.fires(
            WindowEvent(event_id=f"e{i}", key="user:jdoe", event_time=base + i),
            threshold=10,
        )
    assert fired is True         # 10th+ event within 60s trips the threshold
    await client.aclose()


if __name__ == "__main__":
    import asyncio
    asyncio.run(_demo())

Wrapping the four commands in a transaction=True pipeline (a Redis MULTI/EXEC) makes insert-evict-count atomic, so two workers processing the same key concurrently cannot interleave into an under-count. The exclusive lower bound ((cutoff) ensures an event exactly at the window edge is treated consistently, and the EXPIRE guarantees idle keys evaporate without a sweeper job.

Error-Code Reference

Code Meaning Action
ERR_WINDOW_031 Per-key member count exceeded the cap Trim to newest N by rank; investigate a flood inflating one key
ERR_WINDOW_041 Redis unreachable during the pipeline Trip circuit breaker; buffer locally with bounded backpressure
ERR_WINDOW_042 Pipeline EXEC returned a WATCH/conflict error Retry the pipeline; the operation is idempotent by event id
ERR_WINDOW_043 Clock skew placed an event far in the future Clamp score to now; alert on the source’s NTP

Operational Notes

  • Atomicity is the whole point. Never split add, evict, and count into separate awaited calls; a MULTI/EXEC pipeline (or a Lua script) keeps them race-free under a consumer group.
  • Let keys expire themselves. Setting a TTL just past the window means idle correlation keys self-clean, eliminating a separate reaper and bounding total key count to active entities.
  • Bound per-key cardinality. A scanning host can push thousands of members into one ZSET; the rank-trim keeps memory flat, mirroring the cap discipline in cross-source event linking.
  • Shard by key hash across Redis nodes beyond a single instance’s throughput; the correlation key is the natural shard key.

Verification Checklist

FAQ

Why a sorted set instead of a Redis list or counter?

A plain counter cannot evict old events, so it counts all-time hits rather than hits within a window; a list has no efficient range-by-time eviction. A sorted set scored by timestamp makes both window operations — evict everything older than now − window and count the remainder — single server-side commands, which is exactly the sliding-window semantics a correlation rule needs.

Do I need a Lua script or is a pipeline enough?

A MULTI/EXEC pipeline is sufficient for the add-evict-count sequence because those commands do not need to read a value mid-transaction to decide the next step. Reach for a Lua script only when the logic must branch on an intermediate result within the same atomic unit; for this pattern the transactional pipeline is simpler and equally race-free.

How does this stay correct with out-of-order events?

Because eviction is by score (event time), not arrival order, an out-of-order event still lands in the correct position in the sorted set and is counted if its timestamp falls within the window. For strict event-time completeness under large disorder, combine this with the watermark discipline from the parent temporal correlation windows guide.