Skip to content

Commit 9254b4d

Browse files
authored
Add idempotency core request lifecycle API with pluggable store (#35)
1 parent 38c2119 commit 9254b4d

10 files changed

Lines changed: 405 additions & 0 deletions

idempotency/memory-store.lisp

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
(defpackage #:io.github.cl-sdk.wst.idempotency.memory-store
2+
(:use #:cl #:io.github.cl-sdk.wst.idempotency.store)
3+
(:import-from #:io.github.cl-sdk.wst.idempotency
4+
#:idempotency-engine)
5+
(:documentation "In-memory idempotency backend.
6+
7+
Suitable for single-process scenarios; not thread-safe.")
8+
(:export
9+
#:memory-idempotency-engine))
10+
11+
(in-package #:io.github.cl-sdk.wst.idempotency.memory-store)
12+
13+
(defclass memory-idempotency-engine (idempotency-engine)
14+
((table :initarg :table
15+
:initform (make-hash-table :test #'equal)
16+
:reader memory-idempotency-engine-table))
17+
(:documentation "Hash-table-backed in-memory idempotency engine.
18+
Not thread-safe.
19+
20+
Instantiate with MAKE-INSTANCE and optional initargs:
21+
- :table hash table used to persist entries (defaults to (make-hash-table :test #'equal))
22+
- :ttl-seconds, :clock, :cache-response-p (inherited from idempotency-engine)."))
23+
24+
(defun %expired-p (entry now)
25+
(and entry
26+
(idempotency-entry-expires-at entry)
27+
(>= now (idempotency-entry-expires-at entry))))
28+
29+
(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now)
30+
(let* ((table (memory-idempotency-engine-table engine))
31+
(entry (gethash key table)))
32+
(when (%expired-p entry now)
33+
(remhash key table)
34+
(setf entry nil))
35+
(cond
36+
((null entry)
37+
(let ((new-entry (make-idempotency-entry
38+
:state :processing
39+
:fingerprint fingerprint
40+
:response nil
41+
:expires-at (+ now ttl-seconds))))
42+
(setf (gethash key table) new-entry)
43+
(values :started new-entry)))
44+
((not (string= fingerprint (idempotency-entry-fingerprint entry)))
45+
(values :conflict entry))
46+
((eq :completed (idempotency-entry-state entry))
47+
(values :replay entry))
48+
(t
49+
(values :in-progress entry)))))
50+
51+
(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now)
52+
(let* ((table (memory-idempotency-engine-table engine))
53+
(entry (gethash key table)))
54+
(when (%expired-p entry now)
55+
(remhash key table)
56+
(setf entry nil))
57+
(when (and entry
58+
(eq :processing (idempotency-entry-state entry))
59+
(string= fingerprint (idempotency-entry-fingerprint entry)))
60+
(setf (idempotency-entry-state entry) :completed
61+
(idempotency-entry-response entry) response
62+
(idempotency-entry-expires-at entry) (+ now ttl-seconds))
63+
t)))
64+
65+
(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint)
66+
(let* ((table (memory-idempotency-engine-table engine))
67+
(entry (gethash key table)))
68+
(when (and entry
69+
(eq :processing (idempotency-entry-state entry))
70+
(string= fingerprint (idempotency-entry-fingerprint entry)))
71+
(remhash key table)
72+
t)))

idempotency/package.lisp

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
(defpackage #:io.github.cl-sdk.wst.idempotency
2+
(:use #:cl)
3+
(:documentation "Core idempotency-key primitives independent of HTTP objects.")
4+
(:import-from #:io.github.cl-sdk.wst.idempotency.store
5+
#:create-entry
6+
#:update-entry
7+
#:delete-entry
8+
#:idempotency-entry-response)
9+
(:export
10+
#:cached-response
11+
#:make-cached-response
12+
#:cached-response-status
13+
#:cached-response-headers
14+
#:cached-response-content
15+
#:idempotency-engine
16+
#:make-idempotency-engine
17+
#:idempotency-engine-ttl-seconds
18+
#:idempotency-engine-clock
19+
#:idempotency-engine-cache-response-p
20+
#:valid-idempotency-key-p
21+
#:register-request
22+
#:drop-request
23+
#:store-response))
24+
25+
(in-package #:io.github.cl-sdk.wst.idempotency)
26+
27+
(defstruct cached-response
28+
"Serializable response snapshot for replay."
29+
status
30+
headers
31+
content)
32+
33+
(defclass idempotency-engine ()
34+
((ttl-seconds :initarg :ttl-seconds
35+
:initform 86400
36+
:reader idempotency-engine-ttl-seconds)
37+
(clock :initarg :clock
38+
:initform #'get-universal-time
39+
:reader idempotency-engine-clock)
40+
(cache-response-p :initarg :cache-response-p
41+
:initform (lambda (response)
42+
(< (cached-response-status response) 500))
43+
:reader idempotency-engine-cache-response-p))
44+
(:documentation "Core idempotency orchestration object.
45+
46+
Engine subclasses can define their own storage slots and specialize
47+
io.github.cl-sdk.wst.idempotency.store:create-entry,
48+
io.github.cl-sdk.wst.idempotency.store:update-entry and
49+
io.github.cl-sdk.wst.idempotency.store:delete-entry for storage behavior.
50+
For a complete engine implementation, specialize all three methods."))
51+
52+
(defgeneric register-request (engine scope key fingerprint)
53+
(:documentation "Register lifecycle processing ownership for SCOPE/KEY/FINGERPRINT.
54+
55+
Returns two values:
56+
- DECISION: one of :started, :replay, :in-progress, :conflict
57+
- PAYLOAD: cached-response for :replay, NIL otherwise."))
58+
59+
(defgeneric store-response (engine scope key fingerprint response)
60+
(:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT.
61+
62+
Returns T when completion happened, NIL otherwise."))
63+
64+
(defgeneric drop-request (engine scope key fingerprint)
65+
(:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT.
66+
67+
Returns T when release happened, NIL otherwise."))
68+
69+
(defun make-idempotency-engine ()
70+
(error "No default store-backed idempotency engine is provided in io.github.cl-sdk.wst.idempotency. Use a concrete engine implementation such as (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine) or your own subclass."))
71+
72+
(defun valid-idempotency-key-p (value)
73+
"Return T when VALUE is a non-empty key of at most 255 chars.
74+
75+
Key formatting/normalization is caller-managed."
76+
(and (stringp value)
77+
(not (string= "" value))
78+
(<= (length value) 255)))
79+
80+
(defmethod register-request ((engine idempotency-engine) scope key fingerprint)
81+
(multiple-value-bind (status entry)
82+
(create-entry engine
83+
(list scope key)
84+
fingerprint
85+
(idempotency-engine-ttl-seconds engine)
86+
(funcall (idempotency-engine-clock engine)))
87+
(ecase status
88+
(:started (values :started nil))
89+
(:in-progress (values :in-progress nil))
90+
(:conflict (values :conflict nil))
91+
(:replay (values :replay (idempotency-entry-response entry))))))
92+
93+
(defmethod store-response ((engine idempotency-engine) scope key fingerprint response)
94+
(update-entry engine
95+
(list scope key)
96+
fingerprint
97+
response
98+
(idempotency-engine-ttl-seconds engine)
99+
(funcall (idempotency-engine-clock engine))))
100+
101+
(defmethod drop-request ((engine idempotency-engine) scope key fingerprint)
102+
(delete-entry engine
103+
(list scope key)
104+
fingerprint))

idempotency/store.lisp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
(defpackage #:io.github.cl-sdk.wst.idempotency.store
2+
(:use #:cl)
3+
(:documentation "Storage backend protocol for idempotency key tracking.
4+
5+
Implementations can provide atomic semantics for create/update/delete entry
6+
operations in single-node or distributed stores.")
7+
(:export
8+
#:idempotency-entry
9+
#:make-idempotency-entry
10+
#:idempotency-entry-state
11+
#:idempotency-entry-fingerprint
12+
#:idempotency-entry-response
13+
#:idempotency-entry-expires-at
14+
#:create-entry
15+
#:update-entry
16+
#:delete-entry))
17+
18+
(in-package #:io.github.cl-sdk.wst.idempotency.store)
19+
20+
(defstruct idempotency-entry
21+
"Stored lifecycle record for a key.
22+
23+
STATE is one of:
24+
- :processing
25+
- :completed"
26+
state
27+
fingerprint
28+
response
29+
expires-at)
30+
31+
(defgeneric create-entry (store key fingerprint ttl-seconds now)
32+
(:documentation "Try to claim KEY for FINGERPRINT.
33+
34+
Returns two values:
35+
- STATUS keyword: one of :started, :replay, :in-progress, :conflict
36+
- ENTRY (or NIL): an idempotency-entry when useful for caller decisions."))
37+
38+
(defgeneric update-entry (store key fingerprint response ttl-seconds now)
39+
(:documentation "Persist RESPONSE as completed for KEY/FINGERPRINT.
40+
41+
Returns T when completion happened, NIL otherwise."))
42+
43+
(defgeneric delete-entry (store key fingerprint)
44+
(:documentation "Release a processing entry for KEY/FINGERPRINT.
45+
46+
Used when the current request should not be cached (for example 5xx).
47+
Returns T when release happened, NIL otherwise."))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency
2+
:description "Core idempotency-key lifecycle and replay policy."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.1.0"
6+
:depends-on (#:io.github.cl-sdk.wst.idempotency.store)
7+
:pathname "idempotency"
8+
:serial t
9+
:components ((:file "package")))
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.memory-store
2+
:description "Built-in in-memory storage backend for idempotency keys."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.1.0"
6+
:depends-on (#:io.github.cl-sdk.wst.idempotency.store
7+
#:io.github.cl-sdk.wst.idempotency)
8+
:pathname "idempotency"
9+
:serial t
10+
:components ((:file "memory-store")))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.store
2+
:description "Storage backend protocol for idempotency keys."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.1.0"
6+
:depends-on ()
7+
:pathname "idempotency"
8+
:serial t
9+
:components ((:file "store")))
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.test
2+
:description "Tests for io.github.cl-sdk.wst.idempotency."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.1.0"
6+
:depends-on (#:io.github.cl-sdk.wst.test.support
7+
#:io.github.cl-sdk.wst.idempotency.store
8+
#:io.github.cl-sdk.wst.idempotency.memory-store
9+
#:io.github.cl-sdk.wst.idempotency)
10+
:pathname "t"
11+
:serial t
12+
:components ((:file "idempotency-tests")))

io.github.cl-sdk.wst.test.asd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#:io.github.cl-sdk.wst.routing.dsl.test
88
#:io.github.cl-sdk.wst.circuit-breaker.test
99
#:io.github.cl-sdk.wst.rate-limit.test
10+
#:io.github.cl-sdk.wst.idempotency.test
1011
#:io.github.cl-sdk.wst.cors.test
1112
#:io.github.cl-sdk.wst.routing.woo.test
1213
#:io.github.cl-sdk.wst.routing.response.dsl.test

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Behavior is built from pipelines and composition, where middleware and handlers
1919
- CORS middleware
2020
- Static file routing
2121
- Rate limiting with pluggable stores
22+
- Idempotency core lifecycle API with pluggable stores
2223
- Circuit breaker core and routing middleware integration
2324
- Accept-aware response selection helpers (`io.github.cl-sdk.wst.request-accept`)
2425
- W3C Trace Context propagation (`traceparent`/`tracestate`) with routing adapter (`io.github.cl-sdk.wst.trace-context`)

0 commit comments

Comments
 (0)