-
Notifications
You must be signed in to change notification settings - Fork 0
Add idempotency core request lifecycle API with pluggable store #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
c67151a
Add idempotency key core, store, routing middleware, and tests
Copilot 5c2446f
Apply idempotency review cleanups
Copilot a70142b
Apply idempotency review cleanups
Copilot 6b1a664
Drop idempotency key validation since it's user's responsability.
diasbruno defaf3a
Add pure idempotency closure and use it in routing middleware
Copilot 5fccfcf
Refine idempotency closure call style and shared key normalization
Copilot f3216da
Add key validator and tighten idempotency lifecycle tests
Copilot b06b4ff
Use normalized idempotency key for routing validity check
Copilot 4963c2d
Avoid double normalization in idempotency key validation
Copilot 0fc4f54
Clarify routing key validation branches and reuse trim chars constant
Copilot 1447b2e
Document engine precedence and streamline key validation path
Copilot 8332d8c
Remove idempotency routing middleware layer and tests
Copilot 341307b
Remove idempotency key normalization API
Copilot 699d94c
Clarify caller-managed idempotency key formatting
Copilot 79e6827
Remove idempotency closure pipeline helper
Copilot 8c1164b
Split idempotency main and store methods
Copilot ffb1145
Remove redundant scope type check
Copilot 68ab33a
Rename idempotency API to register/drop/store
Copilot a2fd04a
Use class for idempotency engine
Copilot ac2c7e3
Allow idempotency engine subclasses to define storage behavior
Copilot aa31870
Make storage slot subclass-defined in idempotency engine
Copilot 3e7f20f
Clarify subclass storage method contract
Copilot 8b2c764
Tighten subclass storage docs and test assertion
Copilot a2e852c
Move memory store implementation out of idempotency package
Copilot fb7ad77
Make core engine constructor explicitly abstract
Copilot 7b830ab
Clean up abstract engine constructor and formatting
Copilot b4623e4
Group idempotency generics before methods
Copilot be52a73
Inline storage into memory idempotency engine
Copilot d1868e5
Remove redundant memory engine constructor
Copilot cd7dcf2
Wrap abstract engine error message lines
Copilot 374e790
Document memory engine instantiation pattern
Copilot 681c4f0
Refine idempotency messaging docs
Copilot 22af3e1
Polish idempotency constructor guidance
Copilot 59bfceb
Remove obsolete idempotency-engine-store generic
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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))) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 () | ||
|
diasbruno marked this conversation as resolved.
|
||
| ((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)) | ||
|
diasbruno marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.")) | ||
|
diasbruno marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.