feat(square): walk the migration newest month first, so stopping early is safe
migrate-all! drove the charge split from all-order-ids, which streams :aevt — ascending entity id, so oldest first. On the production copy that means the first several hours are spent on 2019 and 2020 data no import will ever read, leaving the recent end (the part the importer actually touches) for last. Interrupt it there and the data is unmigrated exactly where it matters. Now: - refunds, payouts and cash-drawer shifts run first. Together they are ~266k records and take seconds, so an interruption cannot leave them half done. - the long order walk then runs a month at a time from the current month backwards, logging ::month-complete with per-month counts. Stop it after any month and everything from that month forward is fully scoped, so imports can resume against a partially migrated database and the older tail can be finished later — the re-run skips what is done. While a tail remains unmigrated, existing-id's ownership guard is what keeps it safe. order-months-newest-first tiles [start end] windows with no gaps (each month ends the day before the next begins) and is bounded below by a constant comfortably older than the oldest order. Windows are walked via the :sales-order/client+date index. Verified against the restored copy: 141 windows from 2026-08 back, August returning 241,126 orders and July 476,235, each dated inside its window. all-order-ids keeps its old behaviour but its docstring now warns that a prefix of it is the oldest orders, not a sample — the trap that made an earlier verification gate read 2019-2021 data. 31 tests, 76 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.logging :as alog]
|
||||
[datomic.api :as dc]))
|
||||
[datomic.api :as dc]
|
||||
[iol-ion.query]))
|
||||
|
||||
(def refund-prefix "square/refund/")
|
||||
(def charge-prefix "square/charge/")
|
||||
@@ -288,10 +289,51 @@
|
||||
(dc/datoms db :aevt attr))])))
|
||||
|
||||
(defn all-order-ids
|
||||
"Every sales order in the database, streamed."
|
||||
"Every sales order in the database, streamed in `:aevt` order — which is ascending entity id,
|
||||
so OLDEST first. Fine for counting; wrong for anything that takes a prefix. `(take n ...)` of
|
||||
this returns the oldest n orders, not a sample: on the production copy the first 400,000 are
|
||||
all from 2019 to 2021. Use `order-months-newest-first` to walk the data in migration order."
|
||||
[db]
|
||||
(map :e (dc/datoms db :aevt :sales-order/external-id)))
|
||||
|
||||
(def ^:private earliest-orders
|
||||
"How far back the month walk goes. Comfortably before the oldest order in the database
|
||||
(2019-12-31 on the production copy); months with no orders cost one index seek per client."
|
||||
#inst "2015-01-01T00:00:00.000-00:00")
|
||||
|
||||
(defn- ->date [^java.time.LocalDate d]
|
||||
(java.util.Date/from (.toInstant (.atStartOfDay d (java.time.ZoneId/systemDefault)))))
|
||||
|
||||
(defn order-months-newest-first
|
||||
"`[start end]` month windows from now back to `earliest`, newest month first.
|
||||
|
||||
The migration walks months in this order on purpose. It is the difference between an
|
||||
interrupted run leaving the data safe to import against and leaving it dangerous: the importer
|
||||
works on recent data, so having the newest months fully scoped is what lets imports resume
|
||||
while the older tail is still unmigrated. Walking oldest-first would spend hours on 2019 before
|
||||
touching anything this month's import will read.
|
||||
|
||||
Windows tile without gaps — each month's end is the day before the next month's start — and
|
||||
because the migration is idempotent an order landing in two windows is a no-op the second
|
||||
time, so boundary precision is not safety-critical."
|
||||
([] (order-months-newest-first earliest-orders))
|
||||
([^java.util.Date earliest]
|
||||
(let [zone (java.time.ZoneId/systemDefault)
|
||||
floor (java.time.YearMonth/from (.toLocalDate (.atZone (.toInstant earliest) zone)))]
|
||||
(->> (iterate (fn [^java.time.YearMonth m] (.minusMonths m 1)) (java.time.YearMonth/now zone))
|
||||
(take-while (fn [^java.time.YearMonth m] (not (.isBefore m floor))))
|
||||
(map (fn [^java.time.YearMonth m]
|
||||
[(->date (.atDay m 1)) (->date (.atEndOfMonth m))]))))))
|
||||
|
||||
(defn orders-in-window
|
||||
"Sales order ids for every client between `start` and `end` inclusive, via the
|
||||
`:sales-order/client+date` index."
|
||||
[db clients start end]
|
||||
(map first (iol-ion.query/scan-sales-orders db clients start end)))
|
||||
|
||||
(defn- all-client-ids [db]
|
||||
(map first (dc/q '[:find ?c :where [?c :client/code _]] db)))
|
||||
|
||||
(defn migrate-all!
|
||||
"The complete migration, over the whole database rather than a chosen subset.
|
||||
|
||||
@@ -300,16 +342,33 @@
|
||||
Nine client pairs contended in the past and no longer share one; their records are still mixed,
|
||||
and a migration scoped to the current configuration would miss every one of them.
|
||||
|
||||
**Ordered so that an interrupted run is recoverable.** Refunds, payouts and cash-drawer shifts
|
||||
go first: together they are a quarter of a million records and take seconds, so finishing them
|
||||
up front means an interruption cannot leave them half done. The long part — walking every order
|
||||
to split shared charges — then runs a month at a time from the current month backwards, logging
|
||||
each month as it completes. Stop it after any month and the data from that month forward is
|
||||
fully scoped, which is the part the importer reads, so imports can resume against it while the
|
||||
older tail waits. Re-running picks up where it left off because each month's work is idempotent.
|
||||
|
||||
Returns the split counts and the completeness report, which should read zero legacy across the
|
||||
board when this finishes."
|
||||
[batch-size]
|
||||
(let [split (split-and-rekey-charges! (all-order-ids (dc/db conn)) batch-size)]
|
||||
(doseq [{:keys [attr prefix]} scoped-attrs
|
||||
:when (not= attr :charge/external-id)]
|
||||
(let [p (plan (dc/db conn) attr prefix)]
|
||||
(when-let [c (seq (collisions (:new-keys p)))]
|
||||
(throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
|
||||
(migrate! attr (:new-keys p) batch-size)))
|
||||
(doseq [{:keys [attr prefix]} scoped-attrs
|
||||
:when (not= attr :charge/external-id)]
|
||||
(let [p (plan (dc/db conn) attr prefix)]
|
||||
(when-let [c (seq (collisions (:new-keys p)))]
|
||||
(throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
|
||||
(migrate! attr (:new-keys p) batch-size)))
|
||||
(let [clients (all-client-ids (dc/db conn))
|
||||
split (reduce (fn [acc [start end]]
|
||||
(let [ids (orders-in-window (dc/db conn) clients start end)
|
||||
r (split-and-rekey-charges! ids batch-size)]
|
||||
(alog/info ::month-complete
|
||||
:month (subs (str (.toInstant ^java.util.Date start)) 0 7)
|
||||
:rekeyed (:rekeyed r) :cloned (:cloned r))
|
||||
(merge-with + acc r)))
|
||||
{:rekeyed 0 :cloned 0}
|
||||
(order-months-newest-first))]
|
||||
;; charges no order refers to — payout stubs — are scoped from the deposit that holds them.
|
||||
;; Collision-checked like the others: this is the largest attribute in the database, so it is
|
||||
;; the last one that should discover a clash as a mid-run exception.
|
||||
|
||||
Reference in New Issue
Block a user