diff --git a/idempotency/memory-store.lisp b/idempotency/memory-store.lisp new file mode 100644 index 0000000..4fedab1 --- /dev/null +++ b/idempotency/memory-store.lisp @@ -0,0 +1,72 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency.memory-store + (:use #:cl #:io.github.cl-sdk.wst.idempotency.store) + (:import-from #:io.github.cl-sdk.wst.idempotency + #:idempotency-engine) + (:documentation "In-memory idempotency backend. + +Suitable for single-process scenarios; not thread-safe.") + (:export + #:memory-idempotency-engine)) + +(in-package #:io.github.cl-sdk.wst.idempotency.memory-store) + +(defclass memory-idempotency-engine (idempotency-engine) + ((table :initarg :table + :initform (make-hash-table :test #'equal) + :reader memory-idempotency-engine-table)) + (:documentation "Hash-table-backed in-memory idempotency engine. +Not thread-safe. + +Instantiate with MAKE-INSTANCE and optional initargs: +- :table hash table used to persist entries (defaults to (make-hash-table :test #'equal)) +- :ttl-seconds, :clock, :cache-response-p (inherited from idempotency-engine).")) + +(defun %expired-p (entry now) + (and entry + (idempotency-entry-expires-at entry) + (>= now (idempotency-entry-expires-at entry)))) + +(defmethod create-entry ((engine memory-idempotency-engine) key fingerprint ttl-seconds now) + (let* ((table (memory-idempotency-engine-table engine)) + (entry (gethash key table))) + (when (%expired-p entry now) + (remhash key table) + (setf entry nil)) + (cond + ((null entry) + (let ((new-entry (make-idempotency-entry + :state :processing + :fingerprint fingerprint + :response nil + :expires-at (+ now ttl-seconds)))) + (setf (gethash key table) new-entry) + (values :started new-entry))) + ((not (string= fingerprint (idempotency-entry-fingerprint entry))) + (values :conflict entry)) + ((eq :completed (idempotency-entry-state entry)) + (values :replay entry)) + (t + (values :in-progress entry))))) + +(defmethod update-entry ((engine memory-idempotency-engine) key fingerprint response ttl-seconds now) + (let* ((table (memory-idempotency-engine-table engine)) + (entry (gethash key table))) + (when (%expired-p entry now) + (remhash key table) + (setf entry nil)) + (when (and entry + (eq :processing (idempotency-entry-state entry)) + (string= fingerprint (idempotency-entry-fingerprint entry))) + (setf (idempotency-entry-state entry) :completed + (idempotency-entry-response entry) response + (idempotency-entry-expires-at entry) (+ now ttl-seconds)) + t))) + +(defmethod delete-entry ((engine memory-idempotency-engine) key fingerprint) + (let* ((table (memory-idempotency-engine-table engine)) + (entry (gethash key table))) + (when (and entry + (eq :processing (idempotency-entry-state entry)) + (string= fingerprint (idempotency-entry-fingerprint entry))) + (remhash key table) + t))) diff --git a/idempotency/package.lisp b/idempotency/package.lisp new file mode 100644 index 0000000..439e49b --- /dev/null +++ b/idempotency/package.lisp @@ -0,0 +1,104 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency + (:use #:cl) + (:documentation "Core idempotency-key primitives independent of HTTP objects.") + (:import-from #:io.github.cl-sdk.wst.idempotency.store + #:create-entry + #:update-entry + #:delete-entry + #:idempotency-entry-response) + (:export + #:cached-response + #:make-cached-response + #:cached-response-status + #:cached-response-headers + #:cached-response-content + #:idempotency-engine + #:make-idempotency-engine + #:idempotency-engine-ttl-seconds + #:idempotency-engine-clock + #:idempotency-engine-cache-response-p + #:valid-idempotency-key-p + #:register-request + #:drop-request + #:store-response)) + +(in-package #:io.github.cl-sdk.wst.idempotency) + +(defstruct cached-response + "Serializable response snapshot for replay." + status + headers + content) + +(defclass idempotency-engine () + ((ttl-seconds :initarg :ttl-seconds + :initform 86400 + :reader idempotency-engine-ttl-seconds) + (clock :initarg :clock + :initform #'get-universal-time + :reader idempotency-engine-clock) + (cache-response-p :initarg :cache-response-p + :initform (lambda (response) + (< (cached-response-status response) 500)) + :reader idempotency-engine-cache-response-p)) + (:documentation "Core idempotency orchestration object. + +Engine subclasses can define their own storage slots and specialize +io.github.cl-sdk.wst.idempotency.store:create-entry, +io.github.cl-sdk.wst.idempotency.store:update-entry and +io.github.cl-sdk.wst.idempotency.store:delete-entry for storage behavior. +For a complete engine implementation, specialize all three methods.")) + +(defgeneric register-request (engine scope key fingerprint) + (:documentation "Register lifecycle processing ownership for SCOPE/KEY/FINGERPRINT. + +Returns two values: +- DECISION: one of :started, :replay, :in-progress, :conflict +- PAYLOAD: cached-response for :replay, NIL otherwise.")) + +(defgeneric store-response (engine scope key fingerprint response) + (:documentation "Persist RESPONSE for SCOPE/KEY/FINGERPRINT. + +Returns T when completion happened, NIL otherwise.")) + +(defgeneric drop-request (engine scope key fingerprint) + (:documentation "Release processing lock for SCOPE/KEY/FINGERPRINT. + +Returns T when release happened, NIL otherwise.")) + +(defun make-idempotency-engine () + (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.")) + +(defun valid-idempotency-key-p (value) + "Return T when VALUE is a non-empty key of at most 255 chars. + +Key formatting/normalization is caller-managed." + (and (stringp value) + (not (string= "" value)) + (<= (length value) 255))) + +(defmethod register-request ((engine idempotency-engine) scope key fingerprint) + (multiple-value-bind (status entry) + (create-entry engine + (list scope key) + fingerprint + (idempotency-engine-ttl-seconds engine) + (funcall (idempotency-engine-clock engine))) + (ecase status + (:started (values :started nil)) + (:in-progress (values :in-progress nil)) + (:conflict (values :conflict nil)) + (:replay (values :replay (idempotency-entry-response entry)))))) + +(defmethod store-response ((engine idempotency-engine) scope key fingerprint response) + (update-entry engine + (list scope key) + fingerprint + response + (idempotency-engine-ttl-seconds engine) + (funcall (idempotency-engine-clock engine)))) + +(defmethod drop-request ((engine idempotency-engine) scope key fingerprint) + (delete-entry engine + (list scope key) + fingerprint)) diff --git a/idempotency/store.lisp b/idempotency/store.lisp new file mode 100644 index 0000000..79b2b96 --- /dev/null +++ b/idempotency/store.lisp @@ -0,0 +1,47 @@ +(defpackage #:io.github.cl-sdk.wst.idempotency.store + (:use #:cl) + (:documentation "Storage backend protocol for idempotency key tracking. + +Implementations can provide atomic semantics for create/update/delete entry +operations in single-node or distributed stores.") + (:export + #:idempotency-entry + #:make-idempotency-entry + #:idempotency-entry-state + #:idempotency-entry-fingerprint + #:idempotency-entry-response + #:idempotency-entry-expires-at + #:create-entry + #:update-entry + #:delete-entry)) + +(in-package #:io.github.cl-sdk.wst.idempotency.store) + +(defstruct idempotency-entry + "Stored lifecycle record for a key. + +STATE is one of: +- :processing +- :completed" + state + fingerprint + response + expires-at) + +(defgeneric create-entry (store key fingerprint ttl-seconds now) + (:documentation "Try to claim KEY for FINGERPRINT. + +Returns two values: +- STATUS keyword: one of :started, :replay, :in-progress, :conflict +- ENTRY (or NIL): an idempotency-entry when useful for caller decisions.")) + +(defgeneric update-entry (store key fingerprint response ttl-seconds now) + (:documentation "Persist RESPONSE as completed for KEY/FINGERPRINT. + +Returns T when completion happened, NIL otherwise.")) + +(defgeneric delete-entry (store key fingerprint) + (:documentation "Release a processing entry for KEY/FINGERPRINT. + +Used when the current request should not be cached (for example 5xx). +Returns T when release happened, NIL otherwise.")) diff --git a/io.github.cl-sdk.wst.idempotency.asd b/io.github.cl-sdk.wst.idempotency.asd new file mode 100644 index 0000000..0a2ca7e --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.asd @@ -0,0 +1,9 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency + :description "Core idempotency-key lifecycle and replay policy." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on (#:io.github.cl-sdk.wst.idempotency.store) + :pathname "idempotency" + :serial t + :components ((:file "package"))) diff --git a/io.github.cl-sdk.wst.idempotency.memory-store.asd b/io.github.cl-sdk.wst.idempotency.memory-store.asd new file mode 100644 index 0000000..e19a05e --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.memory-store.asd @@ -0,0 +1,10 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.memory-store + :description "Built-in in-memory storage backend for idempotency keys." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on (#:io.github.cl-sdk.wst.idempotency.store + #:io.github.cl-sdk.wst.idempotency) + :pathname "idempotency" + :serial t + :components ((:file "memory-store"))) diff --git a/io.github.cl-sdk.wst.idempotency.store.asd b/io.github.cl-sdk.wst.idempotency.store.asd new file mode 100644 index 0000000..b7b7ea3 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.store.asd @@ -0,0 +1,9 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.store + :description "Storage backend protocol for idempotency keys." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on () + :pathname "idempotency" + :serial t + :components ((:file "store"))) diff --git a/io.github.cl-sdk.wst.idempotency.test.asd b/io.github.cl-sdk.wst.idempotency.test.asd new file mode 100644 index 0000000..c0e0c64 --- /dev/null +++ b/io.github.cl-sdk.wst.idempotency.test.asd @@ -0,0 +1,12 @@ +(asdf:defsystem #:io.github.cl-sdk.wst.idempotency.test + :description "Tests for io.github.cl-sdk.wst.idempotency." + :author "Bruno Dias" + :license "Unlicense" + :version "0.1.0" + :depends-on (#:io.github.cl-sdk.wst.test.support + #:io.github.cl-sdk.wst.idempotency.store + #:io.github.cl-sdk.wst.idempotency.memory-store + #:io.github.cl-sdk.wst.idempotency) + :pathname "t" + :serial t + :components ((:file "idempotency-tests"))) diff --git a/io.github.cl-sdk.wst.test.asd b/io.github.cl-sdk.wst.test.asd index 544c95e..7466572 100644 --- a/io.github.cl-sdk.wst.test.asd +++ b/io.github.cl-sdk.wst.test.asd @@ -7,6 +7,7 @@ #:io.github.cl-sdk.wst.routing.dsl.test #:io.github.cl-sdk.wst.circuit-breaker.test #:io.github.cl-sdk.wst.rate-limit.test + #:io.github.cl-sdk.wst.idempotency.test #:io.github.cl-sdk.wst.cors.test #:io.github.cl-sdk.wst.routing.woo.test #:io.github.cl-sdk.wst.routing.response.dsl.test diff --git a/readme.md b/readme.md index 7e707af..954965c 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,7 @@ Behavior is built from pipelines and composition, where middleware and handlers - CORS middleware - Static file routing - Rate limiting with pluggable stores +- Idempotency core lifecycle API with pluggable stores - Circuit breaker core and routing middleware integration - Accept-aware response selection helpers (`io.github.cl-sdk.wst.request-accept`) - W3C Trace Context propagation (`traceparent`/`tracestate`) with routing adapter (`io.github.cl-sdk.wst.trace-context`) diff --git a/t/idempotency-tests.lisp b/t/idempotency-tests.lisp new file mode 100644 index 0000000..c733168 --- /dev/null +++ b/t/idempotency-tests.lisp @@ -0,0 +1,140 @@ +(in-package :io.github.cl-sdk.wst.test) + +(5am:def-suite wst.idempotency.suite + :description "Tests for the wst.idempotency package.") + +(5am:in-suite wst.idempotency.suite) + +(defclass test-idempotency-engine (io.github.cl-sdk.wst.idempotency:idempotency-engine) + ((calls :initform nil + :accessor test-idempotency-engine-calls) + (custom-storage :initarg :custom-storage + :reader test-idempotency-engine-custom-storage))) + +(defmethod io.github.cl-sdk.wst.idempotency.store:create-entry ((engine test-idempotency-engine) + key + fingerprint + ttl-seconds + now) + (declare (ignore key fingerprint ttl-seconds now)) + (push :create (test-idempotency-engine-calls engine)) + (values :started nil)) + +(defmethod io.github.cl-sdk.wst.idempotency.store:update-entry ((engine test-idempotency-engine) + key + fingerprint + response + ttl-seconds + now) + (declare (ignore key fingerprint response ttl-seconds now)) + (push :update (test-idempotency-engine-calls engine)) + t) + +(defmethod io.github.cl-sdk.wst.idempotency.store:delete-entry ((engine test-idempotency-engine) + key + fingerprint) + (declare (ignore key fingerprint)) + (push :delete (test-idempotency-engine-calls engine)) + t) + +(5am:def-test valid-idempotency-key-rejects-empty-and-too-long () + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p nil)) + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "")) + (5am:is-true (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p "short-key")) + (5am:is-false (io.github.cl-sdk.wst.idempotency:valid-idempotency-key-p (make-string 256 :initial-element #\a)))) + +(5am:def-test main-api-register-store-response-works () + (let* ((now 100) + (engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine + :clock (lambda () now))) + (scope "checkout") + (key "k-main") + (fingerprint "f-main") + (cached (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers nil + :content "ok"))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response engine scope key fingerprint cached)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (5am:is (eq :replay decision)) + (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) + +(5am:def-test register-store-then-replay () + (let* ((now 100) + (engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine + :clock (lambda () now))) + (scope "checkout") + (key "k1") + (fingerprint "f1") + (cached (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers '(:content-type "text/plain") + :content "ok"))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :in-progress decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response + engine scope key fingerprint cached)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (5am:is (eq :replay decision)) + (5am:is (= 200 (io.github.cl-sdk.wst.idempotency:cached-response-status replayed))) + (5am:is (string= "ok" (io.github.cl-sdk.wst.idempotency:cached-response-content replayed)))))) + +(5am:def-test key-conflict-when-fingerprint-differs () + (let ((engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-a") + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "same-key" "fingerprint-b") + (declare (ignore replayed)) + (5am:is (eq :conflict decision))))) + +(5am:def-test drop-request-releases-key () + (let* ((engine (make-instance 'io.github.cl-sdk.wst.idempotency.memory-store:memory-idempotency-engine)) + (scope "scope") + (key "key") + (fingerprint "f")) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request + engine scope key fingerprint)) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine scope key fingerprint) + (declare (ignore replayed)) + (5am:is (eq :started decision))))) + +(5am:def-test register-store-drop-can-be-specialized-on-engine () + (let* ((storage (make-hash-table)) + (engine (make-instance 'test-idempotency-engine + :custom-storage storage))) + (multiple-value-bind (decision replayed) + (io.github.cl-sdk.wst.idempotency:register-request engine "scope" "key" "fingerprint") + (declare (ignore replayed)) + (5am:is (eq :started decision))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:store-response + engine "scope" "key" "fingerprint" + (io.github.cl-sdk.wst.idempotency:make-cached-response + :status 200 + :headers nil + :content "ok"))) + (5am:is-true (io.github.cl-sdk.wst.idempotency:drop-request + engine "scope" "key" "fingerprint")) + (5am:is (hash-table-p (test-idempotency-engine-custom-storage engine))) + (5am:is (eq storage (test-idempotency-engine-custom-storage engine))) + (5am:is (equal '(:delete :update :create) + (test-idempotency-engine-calls engine)))))