diff --git a/.gitignore b/.gitignore index 39a4f9c..5118106 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ compiled/ # Dependency tracking files *.dep +/*.bak +/private/*.bak +/scribblings/*.bak +/scribblings/*.html +/scribblings/*.css +/scribblings/*.js diff --git a/info.rkt b/info.rkt new file mode 100644 index 0000000..e9dcec5 --- /dev/null +++ b/info.rkt @@ -0,0 +1,20 @@ +#lang info + +(define pkg-authors '(hnmdijkema)) +(define version "0.1.1") +(define license 'Apache-2.0) +(define pkg-desc "An expiring Least Recently Used (LRU) Cache implementation for racket. O(n) behaviour, not optimized for O(1).") + +(define scribblings + '( + ("scribblings/lru-cache.scrbl" () (library) "lru-cache") + ) + ) + +(define deps + '("base")) + +(define build-deps + '("racket-doc" + "rackunit-lib" + "scribble-lib")) diff --git a/main.rkt b/main.rkt new file mode 100644 index 0000000..6421282 --- /dev/null +++ b/main.rkt @@ -0,0 +1,6 @@ +#lang racket/base + +(require "private/lru-cache.rkt") + +(provide (all-from-out "private/lru-cache.rkt")) + diff --git a/private/lru-cache.rkt b/private/lru-cache.rkt new file mode 100644 index 0000000..fea4242 --- /dev/null +++ b/private/lru-cache.rkt @@ -0,0 +1,171 @@ +#lang racket/base + +(require racket/contract) + +(provide make-lru + lru? + lru-max-count + set-lru-max-count! + lru-expires? + lru-expire + set-lru-expire! + lru-has? + lru-add! + lru-count + lru->list + lru-clear + ) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Struct +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define-struct lru* + (cache + compare + [max-count #:mutable] + [expire-in-seconds #:mutable] + ) + ) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Supporting functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define (max-count? c) + (and (integer? c) (> c 0))) + +(define (larger-equal0? c) + (and (integer? c) (>= c 0))) + +(define (cmp-procedure? cmp-f) + (procedure? cmp-f)) + +(define (expire? e) + (or (eq? e #f) (max-count? e))) + +(define (find-and-bubble count max-count e cache cmp expire-s current-s el) + (if (or (null? cache) + (>= count max-count)) + '() + (let ((front (car cache))) + (if (and (not (eq? expire-s #f)) + (< (cadr front) expire-s)) + (find-and-bubble count max-count e (cdr cache) cmp expire-s current-s el) + (if (cmp e (car front)) + (find-and-bubble count max-count e (cdr cache) cmp expire-s current-s (car front)) + (cons front + (find-and-bubble (+ count 1) max-count + e (cdr cache) cmp expire-s current-s el)) + ) + ) + ) + ) + ) + +(define (find cache cmp-f el) + (if (null? cache) + #f + (if (cmp-f el (caar cache)) + #t + (find (cdr cache) cmp-f el) + ) + ) + ) + +(define (cleanup-expired lru) + (if (eq? (lru*-expire-in-seconds lru) #f) + 'done + (let ((expire-s (- (current-seconds) (lru*-expire-in-seconds lru))) + (lcache (unbox (lru*-cache lru)))) + (letrec ((f (λ (cache) + (if (null? cache) + '() + (if (< (cadar cache) expire-s) + (f (cdr cache)) + (cons (car cache) (f (cdr cache)))))))) + (set-box! (lru*-cache lru) (f lcache))) + 'cleaned))) + +(define (find-and/or-add lru el) + (let ((ncache (find-and-bubble + 1 (lru*-max-count lru) + el (unbox (lru*-cache lru)) + (lru*-compare lru) + (if (eq? (lru*-expire-in-seconds lru) #f) + #f + (- (current-seconds) (lru*-expire-in-seconds lru))) + (current-seconds) + #f))) + (set-box! (lru*-cache lru) (cons (list el (current-seconds)) ncache))) + lru) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Supporting functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define/contract (make-lru max-count #:cmp [compare equal?] #:expire [expire-in-seconds #f]) + (->* (max-count?) (#:cmp cmp-procedure? #:expire expire?) lru*?) + (make-lru* (box '()) compare max-count expire-in-seconds)) + +(define/contract (lru-has? lru el) + (-> lru*? any/c boolean?) + (cleanup-expired lru) + (find (unbox (lru*-cache lru)) (lru*-compare lru) el) + ) + +(define/contract (lru-add! lru el) + (-> lru*? any/c lru*?) + (find-and/or-add lru el) + lru) + +(define/contract (lru-clear lru) + (-> lru*? lru*?) + (set-box! (lru*-cache lru) '()) + lru) + +(define/contract (lru->list lru #:with-expire [we #f]) + (->* (lru*?) (#:with-expire boolean?) list?) + (cleanup-expired lru) + (let ((c (current-seconds))) + (map (λ (e) + (if we + (list (car e) (- c (cadr e))) + (car e))) (unbox (lru*-cache lru)))) + ) + +(define/contract (lru-count lru) + (-> lru*? larger-equal0?) + (cleanup-expired lru) + (length (unbox (lru*-cache lru)))) + +(define (lru? l) + (lru*? l)) + +(define/contract (lru-max-count l) + (-> lru*? max-count?) + (lru*-max-count l)) + +(define/contract (lru-expire l) + (-> lru*? expire?) + (lru*-expire-in-seconds l)) + +(define/contract (set-lru-max-count! l c) + (-> lru*? max-count? lru*?) + (set-lru*-max-count! l c) + l) + +(define/contract (set-lru-expire! l e) + (-> lru*? expire? lru*?) + (set-lru*-expire-in-seconds! l e) + l) + +(define/contract (lru-expires? l) + (-> lru*? boolean?) + (let ((e (lru*-expire-in-seconds l))) + (if e + (and (integer? e) (> e 0)) + #f))) + + + diff --git a/scribblings/lru-cache.scrbl b/scribblings/lru-cache.scrbl new file mode 100644 index 0000000..468a5f9 --- /dev/null +++ b/scribblings/lru-cache.scrbl @@ -0,0 +1,195 @@ +#lang scribble/manual +@(require scribble/example) + +@title{LRU Cache (Least Recently Used) for Racket} +@author{You} + +@defmodule[lru-cache]{ +An in-memory LRU cache with optional item expiration. The cache keeps items in +recency order and evicts the least recently used item when the maximum capacity +is reached. Optionally, items can expire after a given number of seconds. +} + +@section{Overview} + +This module provides: + +@itemlist[ + @item{@racket[make-lru] to construct a cache with a @italic{max-count}, an + optional equality/compare function for items, and optional expiration (seconds).} + @item{Mutation operations: @racket[lru-add!], @racket[lru-clear], + @racket[set-lru-max-count!], @racket[set-lru-expire!].} + @item{Query operations: @racket[lru-has?], @racket[lru-count], @racket[lru->list], + @racket[lru-expires?], @racket[lru-expire], @racket[lru-max-count], @racket[lru?].} +] + +The cache stores @emph{items} (values) and uses a comparison function to decide +whether an item is already present. Each item is associated with a last-access +timestamp that drives recency; on insert or hit, the item is bubbled to the front. + +@bold{Thread-safety:} The implementation uses a @racket[box] internally and is +@emph{not} intrinsically thread-safe. Use external synchronization for concurrent access. + +@section{Data Types} + +@defstruct*[lru* ([cache box?] + [compare procedure?] + [max-count (and/c integer? (>/c 0))] + [expire-in-seconds (or/c #f (and/c integer? (>/c 0)))])]{ +Internal representation of the LRU cache. Use @racket[lru?] to test for this type; +do not rely on fields directly. Create instances with @racket[make-lru]. +} + +@defproc[(lru? [v any/c]) boolean?]{ +Predicate that returns @racket[#t] when @racket[v] is an LRU instance. +} + +@section{Construction} + +@defproc[(make-lru + [max-count (and/c integer? (>/c 0))] + [#:cmp compare (-> any/c any/c boolean?) equal?] + [#:expire expire-in-seconds (or/c #f (and/c integer? (>/c 0))) #f]) + lru?]{ +Create a new LRU cache. + +@itemlist[ + @item{@racket[max-count] — Maximum number of items kept in the cache. When + the limit is exceeded, the least recently used item is evicted.} + @item{@racket[#:cmp compare] — Binary comparison function that returns + @racket[#t] when two items are considered equal (default: @racket[equal?]).} + @item{@racket[#:expire expire-in-seconds] — If a positive integer: items expire + automatically after that many seconds of inactivity. @racket[#f] disables + expiration (default).} +] + +Returns A new empty LRU cache with the given parameters. +} + +@section{Core Operations} + +@defproc[(lru-add! [l lru?] [item any/c]) lru?]{ +Insert @racket[item]. If it already exists (per @racket[compare]), it is +considered a hit: moved to the front and its access time is refreshed. +If capacity is reached, the least recently used item is removed. +Returns @racket[l]. +} + +@defproc[(lru-has? [l lru?] [item any/c]) boolean?]{ +Returns @racket[#t] if @racket[item] is currently present (and not expired), +otherwise @racket[#f]. This call also performs lazy cleanup of expired items. +} + +@defproc[(lru-clear [l lru?]) lru?]{ +Clear all items from the cache. Returns @racket[l]. +} + +@defproc[(lru-count [l lru?]) (and/c integer? (>=/c 0))]{ +Return the number of @emph{non-expired} items currently in the cache. +} + +@defproc[(lru->list [l lru?] [#:with-expire with-expire? boolean? #f]) list?]{ +Return the cache contents in recency order (most recent first). +When @racket[with-expire?] is @racket[#f] (default), returns just the list of items. +When @racket[with-expire?] is @racket[#t], returns @racket[(list item age-in-seconds)] +pairs, where @racket[age-in-seconds] is the elapsed time since last access/insert. +} + +@section{Parameters and Configuration} + +@defproc[(lru-max-count [l lru?]) (and/c integer? (>/c 0))]{ +Read the maximum capacity. +} + +@defproc[(set-lru-max-count! [l lru?] [n (and/c integer? (>/c 0))]) lru?]{ +Set the maximum capacity. If the current size exceeds @racket[n], trimming +will occur on subsequent mutations or cleanups, depending on usage. Returns @racket[l]. +} + +@defproc[(lru-expire [l lru?]) (or/c #f (and/c integer? (>/c 0)))]{ +Read the expiration period in seconds, or @racket[#f] if expiration is disabled. +} + +@defproc[(set-lru-expire! [l lru?] [expire (or/c #f (and/c integer? (>/c 0)))]) lru?]{ +Set the expiration period (seconds) or disable it with @racket[#f]. Returns @racket[l]. +} + +@defproc[(lru-expires? [l lru?]) boolean?]{ +Return @racket[#t] when expiration is enabled (i.e., @racket[(lru-expire l)] is +a positive integer), otherwise @racket[#f]. +} + +@section{Examples} + +@examples[ + #:eval (make-base-eval) + (require racket/base) + + ;; Assuming your module is "lru.rkt" next to this file: + (require "../private/lru-cache.rkt") + + (define L (make-lru 3)) ;; max 3 items, no expiration + (lru-add! L 'a) + (lru-add! L 'b) + (lru-add! L 'c) + (lru->list L) ;; '(c b a) + + (lru-add! L 'b) ;; hit: bubble to front + (lru->list L) ;; '(b c a) + + (lru-add! L 'd) ;; capacity 3 -> evicts LRU ('a) + (lru->list L) ;; '(d b c) + (lru-count L) ;; 3 + + (lru-has? L 'c) ;; #t + (lru-has? L 'a) ;; #f + + (set-lru-expire! L 1) ;; items expire after 1 second + (sleep 2) + (lru-has? L 'b) ;; #f (expired) + (lru->list L) ;; '() + + (lru-add! L 'x) + (lru->list L #:with-expire #t) ;; '((x 0)) -- age ~ 0 sec + + (set-lru-max-count! L 1) + (lru-add! L 'y) + (lru->list L) ;; '(y) + (lru-clear L) + (lru->list L) ;; '() +] + +@section{Behavior Details} + +@subsection{Recency and Hits} +Any insertion or hit refreshes the access time and moves the item to the front. +When capacity is reached, the least recently used item is evicted. + +@subsection{Expiration} +If @racket[lru-expire] is a positive integer, items become expired when their +last access is older than that threshold. Cleanup is performed lazily on calls +such as @racket[lru-has?], @racket[lru-count], and @racket[lru->list]. + +@subsection{Comparison Function} +The @racket[#:cmp] function decides item equality (e.g., @racket[equal?], +@racket[=] for numbers, or a custom predicate). For predictable behavior, it +should be symmetric and transitive. + +@section{Errors and Contracts} +Contracts enforce: +@itemlist[ + @item{@racket[max-count], @racket[lru-max-count], and @racket[set-lru-max-count!] + use positive integers.} + @item{@racket[#:expire] and @racket[set-lru-expire!] accept @racket[#f] or + positive integers.} + @item{@racket[#:cmp] is a two-argument function returning a @racket[boolean?].} +] +Invalid inputs raise contract violations with descriptive error messages. + +@section{Notes} +@itemlist[ + @item{The implementation is not thread-safe; use locks for concurrent access.} + @item{Expiration cleanup is lazy. If you need stricter guarantees, trigger + periodic queries (e.g., @racket[lru-count]) or add an explicit cleanup function.} +] +