yetanalytics / colossal-squuid

Library for generating sequential UUIDs, or SQUUIDs

Home Page:https://cljdoc.org/d/com.yetanalytics/colossal-squuid/0.1.4/doc/readme

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Strict monotonicity not always held / race condition in generate-squuid*

ivarref opened this issue · comments

Hi,

and thanks for a great idea and library!

I believe there is a race condition in generate-squuid* though.

Here is a way to reproduce this using CountDownLatch:

(defonce latch (atom nil))


(defn generate-squuid*
  "Return a map containing the following:
   :squuid     The v8 sequential UUID made up of a base UUID and timestamp.
   :base-uuid  The base v4 UUID that provides the lower 80 bits.
   :timestamp  The timestamp that provides the higher 48 bits.
   
   See `generate-squuid` for more details."
  []
  (let [ts (java.time.Instant/ofEpochMilli 123) ; pretend we always get the same time
        {curr-ts :timestamp} @current-time-atom]
    (if (t/before? curr-ts ts)
      ;; No timestamp clash - make new UUIDs
      (do
        (.countDown @latch)
        (.await @latch) ; wait for 2 threads to arrive here at the same time,
                        ; i.e. after the compare is done.
        (swap! current-time-atom (fn [m]
                                   (-> m
                                       (assoc :timestamp ts)
                                       (merge (u/make-squuid ts))))))
      ;; Timestamp clash - increment UUIDs
      (swap! current-time-atom (fn [m]
                                 (-> m
                                     (update :base-uuid u/inc-uuid)
                                     (update :squuid u/inc-uuid)))))))

(comment
  (do
    (reset! current-time-atom {:timestamp t/zero-time
                               :base-uuid u/zero-uuid
                               :squuid    u/zero-uuid})
    (reset! latch (java.util.concurrent.CountDownLatch. 2))
    (let [f1 (future (:squuid (generate-squuid*)))
          f2 (future (:squuid (generate-squuid*)))
          mx (last (sort [@f1 @f2]))]
         ; Now we have generated two squuids.
         ; None of them is thought to be "clashy" by the existing code.
         ; This is due to the race condition.
      (let [new (:squuid (generate-squuid*)) ; new should always be higher than the max seen so far, right?
            new-max (last (sort [mx new]))]
        (if (not= new-max new)
          (do
            (println "new:    " new)
            (println "old max:" mx)
            (println "bug"))
          (do
            (println "new:    " new)
            (println "old max:" mx)
            (println "ok this time")))))))

Evaluate that comment form a few times, and you will (sooner or later) hit the race condition. Example output:

new:     #uuid "00000000-007b-8715-82ac-485927e454a0"
old max: #uuid "00000000-007b-885f-acaa-e83df5e15fff"
bug

Thus this proves the race condition.

The correct way to handle this is to put (if (t/before? curr-ts ts) inside the function sent to swap!:

(swap! current-time-atom (fn [m]
                                   (if (t/before? (:timestamp m) ts)
                                     (-> m
                                         (assoc :timestamp ts)
                                         (merge (u/make-squuid ts)))
                                     (-> m
                                         (update :base-uuid u/inc-uuid)
                                         (update :squuid u/inc-uuid)))))

Thus I'm proposing the following definition of generate-squuid*:

(defn generate-squuid*
  "Return a map containing the following:
   :squuid     The v8 sequential UUID made up of a base UUID and timestamp.
   :base-uuid  The base v4 UUID that provides the lower 80 bits.
   :timestamp  The timestamp that provides the higher 48 bits.
   
   See `generate-squuid` for more details."
  []
  (let [ts (t/current-time)]
    (swap! current-time-atom (fn [m]
                               (if (t/before? (:timestamp m) ts)
                                 (-> m
                                     (assoc :timestamp ts)
                                     (merge (u/make-squuid ts)))
                                 (-> m
                                     (update :base-uuid u/inc-uuid)
                                     (update :squuid u/inc-uuid)))))))

I hope this makes sense.

Thanks and kind regards.

Edit: Added full proposed definition of generate-squuid*.

Thank you for the bug report! This is actually an important bug to fix, since colossal-squuid is used in applications where multi-threading and concurrency are possible (e.g. concurrent database operations). I opened a PR to fix the bug and add multi-threading tests.