NAME
LinkedList::Single::Example::SlidingWindow::POD - A sliding window of events, held in a linked list
SYNOPSIS
perl app/30-SlidingWindow/sliding-window # a login throttle
prove -r t/30-SlidingWindow # the tests
perl benchmarks/30-SlidingWindow/compare # the measurements below
THE PROBLEM
A login form that lets anyone guess passwords as fast as they can type will, sooner or later, be guessed. Standards for authentication require the server to limit failed attempts [1], and a common way to do it is to count the attempts in the last few seconds or minutes: fail2ban, for example, bans a host that has failed maxretry times during the last findtime seconds [2]. Rate limiters ("no more than 100 requests a minute") and counters ("requests in the last minute") work the same way.
All of them keep a sliding window: the events of the last span seconds. New events arrive at the end. As time passes, the oldest events fall out of the window at the front. Because events arrive in time order, the expired ones are always a prefix, a run at the front of the window. When traffic comes in a burst the window grows, and when the burst is over it shrinks again.
The window here has two operations:
record( $time, @payload )-
Adds an event at the end.
expire( $now, $handler )-
Drops every event older than
$now - span, hands each one to the handler (a logger, a counter, something that closes a handle), oldest first, and returns how many were dropped.
In outline, whatever the data structure:
record( time, payload ):
add [ time, payload ] at the end
expire( now, handler ):
cutoff = now - span
find the first event whose time is not before cutoff
remove everything in front of it
give each removed event to handler, oldest first
give the memory back
app/30-SlidingWindow/sliding-window is the login throttle: it refuses a user with three attempts in the last ten seconds, and says so as old attempts are forgotten.
THE ARRAY VERSION
Perl's arrays are good at exactly this. push adds at the end; splice takes a run off the front (LinkedList::Single::Example::SlidingWindow::Array):
sub expire( $self, $now, $handler = sub {} )
{
my $window = $self->{ window };
my $cutoff = $now - $self->{ span };
my $count = 0;
++$count
while $count < $window->@*
&& $window->[ $count ][ 0 ] < $cutoff;
$count
or return 0;
my @dead = splice $window->@*, 0, $count;
$handler->( $_->@* ) for @dead;
$count
}
This is short, correct and fast: taking items off the front of a perl array moves a pointer inside the array rather than copying what is left [4]. It has two weaknesses. $count is a position that the loop and the splice must agree on. And the array keeps the room it grew to: after a burst of 200,000 events has expired, the array still holds space for 200,000 (the numbers are below). The program gets that memory back only if it remembers to copy the survivors into a new array.
A hash is not a natural alternative here. The events come in order and leave from the front, which is what an array already does; a hash keyed by a sequence number would add a key to every event and still have to count from the oldest. No hash version was built.
KEEPING THE WINDOW IN A LINKED LIST
A linked list is a chain of nodes in which each node knows the next one. LinkedList::Single (version 2) ends every chain with an empty node, the sentinel, which marks the end. The window uses three of its pieces:
- a cursor
-
marks a place in the list.
$cursor->push( @data )on the sentinel fills it and hangs a new sentinel after it;$cursor->advancemoves the cursor to the next node. - a Fragment
-
is a piece of chain that belongs to no list, so nothing else can reach its nodes.
$list->fragmentmoves every node of a list into a new Fragment and leaves the list empty;$cursor->fragmentdoes the same from the cursor to the end. $fragment->first( $test ),push_onto,consume-
firstreturns a cursor on the first node whose data pass the test.$fragment->push_onto( $list )puts a Fragment's nodes on the end of a list.$fragment->consume( $handler )gives each node's data to the handler and moves past it; as it moves, the node, which nothing else refers to, is freed.
The window is a list and one cursor, tail, parked on the sentinel, much as a textbook linked-list queue keeps a link to its last node [5]:
first event -> ... -> newest event -> sentinel
^ tail
Recording
$self->{ tail }->push( $time, @payload )->advance;
push at the sentinel turns it into an event node with a new sentinel after it, and advance steps the tail onto the new sentinel. Nothing is searched. The obvious $list->push is not used: it finds the end by walking from the front every time, which makes building a window of n events take time in proportion to n squared; 32,000 pushes took 30 seconds. Neither is $tail->append, which at a sentinel stores a spurious empty array in front of the data (see "A NOTE FOR LinkedList::Single").
Expiring
The list version has no positions to count. It cuts the window apart where the first live event begins, keeps the live half and consumes the rest (from the module; the handler wrapper that counts the dropped events is shortened to $counting_handler):
$list
and ( $list->data )[ 0 ] < $cutoff
or return 0; # usually nothing has expired
my $dead = $list->fragment; # every node; $list is empty
my $live = $dead->first( sub( $time, @ ) { $time >= $cutoff } );
if( $live )
{
$live->fragment->push_onto( $list ); # the live nodes go back
}
else
{
$self->{ tail } = $list->cursor; # everything expired
}
$dead->consume( $counting_handler );
This reads close to the outline: find the first event in the window, take off everything in front of it, give each removed event to the handler. $live->fragment cuts at the cursor, so $dead ends where the live events began and the live nodes come back as their own Fragment.
The cost is in the number of objects: two Fragments and a cursor per sweep, and first and consume each make a method call and run a try block (perl's catching of errors) for every node they visit. That is the reason for the times below.
Why the tail cursor stays valid
fragment, first and push_onto relink or move the nodes in front of the sentinel, but never the sentinel at the end of the chain. So when some events survive, the tail is still on the list's sentinel and needs no repair. When every event expires, the old sentinel leaves with $dead, and expire parks a new cursor on the empty list. That is the one piece of bookkeeping the list needs, and the one place it could go wrong.
Where the memory goes
consume is what returns the memory. The Fragment holds the only reference to each expired node, so when consume moves past a node, the node is freed, and with it the payload, before the next node is handed to the handler. A payload that holds a file handle, a lock or a socket is closed at that moment, and there is no capacity left behind to shrink.
CHECKING THAT IT IS RIGHT
The array version is simple enough to read and be sure of, so the tests use it as the reference.
- t/30-SlidingWindow/20-vs-array.t
-
Drives both windows through 2,000 random steps with a fixed seed: 70% record, 30% expire, and a few expires jump far enough ahead to empty the window. After every step the contents must be the same; the counts, the sequence of handler calls and the final sizes must match.
- t/30-SlidingWindow/30-lifetime.t
-
Records events whose payload is a LinkedList::Single::Example::Probe, an object that notes when perl destroys it. Expiring shows each expired payload destroyed by the time
expirereturns, in order, after the handler has seen it, and nothing live destroyed. - t/30-SlidingWindow/40-invariants.t
-
Checks that the tail stands on the list's last node after a new window, records, a partial expire, a full expire, a refill, 50 record and expire cycles, and an expire on an empty window.
WHAT IT COSTS
Time
From benchmarks/30-SlidingWindow/compare, on one machine, best of 3, in seconds:
100,000 events, window of 1,000 expire every event every 100 events
list 3.78 0.86
array 0.15 0.08
burst of 200,000 events, expired down to 10
list 1.67
array 0.18
The array wins by a factor of ten or more. splice at the front of a perl array is done inside perl's C code; the list pays method calls, a closure and a try block for each node, plus the fixed cost of the Fragments and cursors on each sweep. Fragments pay off for batches. Expiring after every event drops about one event per sweep, the worst case for them, and the first column shows it: sweeping a hundred times less often cuts the list's time by more than four.
Memory
After the burst has expired down to 10 events, in bytes held by the container (from Devel::Size; the payload is not counted [6]):
list 1,056 10 live nodes, about 106 bytes each
array 1,754,784 the slots of the burst, still allocated
The list's figure is its ten nodes, each holding the link to the next node, the time and the payload. The array's figure counts only the array's own slots, about 8.8 bytes for each event of the burst; the ten small arrays the survivors live in are not in it. However it is counted, the array keeps the size of the largest burst it has seen, and the list keeps only what is live. While a window is full the two hold about the same per event, an array of the time and the payload, with one more slot in the list's node for the link; this was not measured separately.
Why the array keeps the room: shift does not move the elements, it moves the array's start pointer past the one removed [4] [9], so the front of the allocation is left empty while push fills the other end. That empty space is used again only when the array runs out of room at the back (or by an unshift); perl then moves the whole contents down to the start of the allocation [9], a copy as long as the number of events held, made now and then inside whichever call needed the room. The list has neither the idle gap nor the move: each node is released when its event expires, and no call copies the live events. What the list pays is the node itself, one more slot per event, and the tail cursor. [9] was read in the current development source of perl; the behaviour of the 5.40 source was not read.
No hash version was built (see above). For scale, the LRU example (LinkedList::Single::Example::LRU::POD) measured a plain hash of small integers at about 195 bytes per entry of resident memory, a different measurement from this one, and notes that perl's hashes, like its arrays, keep the memory they grew to.
Code
Counting statements, branches and index operations in expire (complexity in LinkedList::Single::Example::Bench, a rough count over the deparsed code):
statements branches index ops
list 23 8 3
array 18 9 5
The list has fewer index operations and one branch fewer, but more statements: cutting and rejoining Fragments takes lines. The difference is in the kind of code more than the amount. The array's lines are arithmetic on a position that has to stay right; the list's move whole pieces that cannot be the wrong size, at the price of keeping the tail cursor right.
READABILITY AND CHANGE
Readable here means that a reader who knows ordinary perl can check the code against the outline in "THE PROBLEM" without knowing anything else: what each line does, and why it is there. Easy to change means a new rule touches a few lines in one place and does not break something elsewhere. In this example the array is the simpler of the two on both counts; the list's one advantage is in finding where the window starts.
Adding an event
$self->{ tail }->push( $time, @payload )->advance; # list
push $self->{ window }->@*, [ $time, @payload ]; # array
Both are one line, but the array's line is the outline ("add at the end") and needs nothing else. The list's needs a tail cursor built in new, kept right in expire, and a reader who knows why the obvious $list->push (it walks the list; the module's comment says so) and $tail->append (the spurious []; only this paper says so) are not used.
Finding where the window starts
# list
my $live = $dead->first( sub( $time, @ ) { $time >= $cutoff } );
# array
++$count
while $count < $window->@*
&& $window->[ $count ][ 0 ] < $cutoff;
Here the list reads closer to the outline: "the first event whose time is not before cutoff" is the test passed to first. The array loop says the same thing through a position, with a bounds check and a subscript that must agree with the splice two lines later. In exchange, the array's $count is also the return value; the list counts separately.
Removing and handing over
my @dead = splice $window->@*, 0, $count; # array
$handler->( $_->@* ) for @dead;
The list's equivalent is the eleven lines from $list->fragment to $dead->consume quoted in "Expiring", plus a wrapper sub to count the dead events (trimmed there to $counting_handler). The extra lines are an if/else that either puts the live nodes back or, when nothing survives, gives the tail a new cursor. That branch is the bookkeeping a reader has to understand to trust the code; the splice has none, and the guard at the top of the list's expire (is the first event old?) exists only to skip building Fragments in the common case.
The array's position lasts one call
The only thing the array version keeps in step with the data is $count, used five times in expire (SlidingWindow/Array.pm lines 42-53: the bound, the subscript, the guard, the splice length and the return value). If the loop and the splice disagreed, the wrong events would go with no error. But $count is a lexical that is born and dies inside one call: it cannot go stale between calls, and no other sub has to know about it. The array marks nothing: an expired event is gone from $self->{ window }, so record, contents and size need no test for it. The list does no better here; there is nothing to improve.
The list's tail cursor outlives the call
The list keeps a piece of state that crosses calls: the tail cursor, built in new (SlidingWindow.pm line 31), used by record (line 50) and rebuilt in expire (line 99) when every event expires and the old sentinel leaves with $dead. Any future sub that removes nodes, a clear or a cap on the number of events, has to repeat that repair. Reasoning from the code (it was not tried): if one forgets, the tail is left on a node outside the list, and later records would go there instead of into the window, with no error. t/30-SlidingWindow/40-invariants.t guards it with seven checks that the tail stands on the list's last node. Two more rules from "A NOTE FOR LinkedList::Single" bind any such sub: no cursor may be held on the first node across a fragment call, and the last whence keeps a window's payloads alive until another object is built. The array has none of these to honour.
Both versions take the expired events out of the window before the handler sees any (splice on line 49; the tail repair on line 99 precedes consume on line 104), so a handler that dies leaves the window consistent in both. What consume does with the remaining events after such a die was not checked.
Changing it
Two plausible changes, reasoned from the code above:
- Keep the expired events instead of dropping them
-
The array replaces the handler line with
push $archive->@*, @dead. The list replacesconsumewith$dead->push_onto( $archive ), which moves the nodes without copying them, but then the count must come from elsewhere, since theconsumewrapper is what counts. Both are local; neither is simpler by much. - Also cap the window at a maximum number of events
-
The array computes
$window->@* - $maxand folds it into the samesplice. The list code has no notion of position: it would have to walk a cursor forward that many nodes before cutting, and repeat the tail fix-up if the cut takes everything. The array is simpler here.
Where the list is not simpler
Everywhere except the search. The list needs the tail cursor and its repair when the window empties, state that every sub that removes nodes must honour (above), where the array's only position is local; a guard to avoid making objects when nothing has expired, a wrapper to count what consume hands over, and the push( ... )->advance workaround for the module. Adding at the end and removing from the front is what perl arrays do natively, so the array version is close to the outline line for line.
Lines of code in this repository's .pm files (sub to closing brace, leaving out blank lines and comments), not counts of the best possible code:
new record expire
list 15 5 28
array 11 5 14
WHAT TO TAKE FROM IT
The list gives the memory of a burst back as the burst expires, destroys each expired payload as soon as the handler is done with it, and has no positions to count. It does not give speed: this is the array's best case, adding at the end and removing from the front, and the array is nine to twenty-five times faster in these runs. The code is not shorter either. Choose the list when the window is bursty and the memory should come back, or when expired events hold resources that need prompt release. Choose the array when the window is steady, sweeps are small, and speed matters most.
WHERE THE TECHNIQUE IS USED
fail2ban's jails are sliding windows of failures per host [2]. Cloudflare describes the trade made in large rate limiters: storing the timestamp of every request is more accurate but costs a great deal of processing and memory, so they use an approximation that keeps two numbers per counter and assumes a constant rate over the previous period [3]. The window in this example is the exact kind, which is affordable when the window is per user or per host and small. Simpler limiters keep no events at all: the Redis documentation's rate-limiter pattern counts the requests of each second in a key the server expires [7], and Stripe reports using the token bucket algorithm [8].
A NOTE FOR LinkedList::Single
Building this example turned up two things in the module, both of them open items for the module, not yet fixed and not in the CHANGES file of LinkedList::Single.
$cursor->appendat the sentinel stores a spurious[]-
appendat a sentinel passes a new sentinel node toset_data, which already supplies the link to the next node, so the event's data begin with an empty arrayref.pushat the sentinel is correct, sorecordusespush( ... )->advance. - The last
whenceis kept alive -
The
ADJUSTblock keeps its:$whenceparameter until the next object of the same class is built, so after the last reference to a window is dropped its list and payloads stay allocated until another list, cursor or Fragment is made. t/30-SlidingWindow/30-lifetime.t marks the assertion that depends on itTODO, and passes it once an object of each class has been built.
$list->fragment and $cursor->fragment move the contents of the first node they take into a new node, rather than relinking it. A cursor held on that node across the call would see an empty node. The expiry code holds no cursor across such a call except the tail, which is on the sentinel at the far end.
SEE ALSO
The numbers are the references in the text.
- [1] NIST SP 800-63B, "Digital Identity Guidelines: Authentication and Lifecycle Management", section 5.2.2
-
Revision 3: verifiers shall limit consecutive failed authentication attempts on one account to no more than 100 (read 2026-11-01). NIST's page says this revision was superseded by SP 800-63-4 on 1 August 2025; the new revision was not read, so check it before relying on the figure. https://pages.nist.gov/800-63-3/sp800-63b.html
- [2] fail2ban, config/jail.conf
-
The default configuration: "A host is banned if it has generated 'maxretry' during the last 'findtime' seconds" (read 2026-11-01). https://github.com/fail2ban/fail2ban/blob/master/config/jail.conf
- [3] Cloudflare, "How we built rate limiting capable of scaling to millions of domains"
-
Cloudflare blog, 7 June 2017. Exact timestamps against a two-counter sliding-window approximation, with its measured error (read 2026-11-01). https://blog.cloudflare.com/counting-things-a-lot-of-different-things/
- [4] perlguts, "Introduction to the Perl API"
-
The passage on the offset hack: perl arrays keep
AvARRAY(first visible element) apart fromAvALLOC(real start) "to enable efficient shifting and splicing off the beginning of the array"; the real start matters only when the array is freed (read 2026-11-02). https://perldoc.perl.org/perlguts - [5] Sedgewick and Wayne, Algorithms, 4th ed., section 1.3 "Bags, Queues, and Stacks"
-
Textbook FIFO queue on a linked list, adding at the end through a kept link to the last node and removing from the front, beside a resizing-array queue (read 2026-11-02). https://algs4.cs.princeton.edu/13stacks/
- [6] Devel::Size
-
sizereports only the memory of the structure itself, not its contents;total_sizefollows the contents. Memory lost to fragmentation and alignment is not counted (read 2026-11-02). https://metacpan.org/pod/Devel::Size - [7] Redis documentation, INCR, "Pattern: rate limiter"
-
Limits an API to ten requests a second per IP with a counter per second that Redis expires, and a variant on Redis lists (read 2026-11-02). https://redis.io/docs/latest/commands/incr/
- [8] Paul Tarjan, "Scaling your API with rate limiters"
-
Stripe blog, 30 March 2017. Stripe's API rate limiters; it reports using the token bucket algorithm, implemented with Redis (read 2026-11-02). https://stripe.com/blog/rate-limiters
- [9] perl source, av.c:
Perl_av_shiftandPerl_av_extend_guts -
av_shiftadvances the array's start pointer by one and moves no element; when a shifted array needs more room,av_extend_gutsmoves the contents back to the real start of the allocation (aMoveofAvFILLp + 1elements). Read from the main development branch on 2026-11-02. https://github.com/Perl/perl5/blob/blead/av.c