Synchronizing with seq
Synchronization is where integrations go wrong, and they go wrong under load, not in testing. This note is the long version of Synchronization: what to store, how to apply a change, and how to keep two synchronizations from running at once.
We have a database of your shop's transactions and subscribers, and you have a database of orders and customers. Synchronizing is merging ours into yours, change by change, without losing one or applying an old one over a new one.
Matching our objects to yours
Each change that seq returns is a whole object, a transaction, a charge, or a subscriber. Find your record for it by what you gave us when you created it:
- A transaction has the
"orderid"of the payment link that created it. Look up your order by it. - A subscriber has the
"ref"you gave Create subscriber. Look up your customer by it. - A charge has the
"orderid"you gave Charge subscriber, or the subscriber link for a first charge, and the subscriber's"id"and"ref"in its"subscriber". Use the"orderid", which is optional for a charge but which you should always give, and the subscriber to check it.
Then store our "id" with your record. You need it for every request
about the object, which names it in its URL, and from then on, look
the object up by its "id", which never changes, rather than by your
own ID.
In v1, the URL names a transaction as
/v1/transactions/{id}/capture, and "orderid" is optional, but
without it nothing in the transaction leads back to your order: always
give one. The answer to Create payment link
has no ID, so a transaction's "id" comes to you from seq alone.
In v2, the URL names it by its URI, as {shopid}:trn:{id}, and the
answer to Create payment link has the
link's "id" too.
Make your IDs unique: an "orderid" that two orders share can't tell
you which one a transaction paid. We don't check that they are unique,
as an order can have more than one payment, such as a second link
after the first expired. See Orders and payment links.
Applying a change: the higher revision wins
A change is the object as it is now, never the difference from before:
if a transaction was captured and then refunded since your last call,
seq returns it once, with both acts. Its "rev" counts its versions,
from 1, so of two copies of an object, the one with the higher "rev"
is the newer.
Apply a change only if its "rev" is higher than the one you have
stored, in the same database transaction as the check:
BEGIN;
SELECT rev FROM payments WHERE trnid = ? FOR UPDATE;
-- no row: the object is new to you
INSERT INTO payments (trnid, rev, captured, …) VALUES (?, ?, ?, …);
-- a row with a lower rev: the change is newer
UPDATE payments SET rev = ?, captured = ?, … WHERE trnid = ? AND rev < ?;
-- a row with the same or a higher rev: nothing
COMMIT;
Then it does not matter how often, or in what order, a change reaches
you: a second copy of the same "rev" changes nothing, and an older
one can't overwrite a newer. That makes the rest of this note a matter
of efficiency, not of correctness.
If you need to know what changed, such as to send an email when an order is first paid, compare the change with what you stored, before you overwrite it, in the same database transaction. Seq can't tell you: it records which objects changed, not how.
How seq scales
Say that ten things happen in a shop, while you are not looking:
- Order trn#1 is paid.
- Order trn#2 is paid.
- Order trn#3 is paid.
- The shop captures trn#1.
- The shop captures trn#3.
- A customer subscribes, sub#1.
- The shop charges sub#1, which creates trn#4.
- The shop refunds part of trn#1.
- The shop captures trn#4.
- Order trn#5 is paid.
Three calls fetch all ten events, and a fourth finds nothing new. The
calls are v1's, GET /v1/seq/{seq}; v2's are
GET /v2/{shopid}:seq:{seq}, and return the same. Each
call returns as many changes as fit in one response, and the "seq" to
start the next call from. The third call moves four events on but
returns three changes, as an object that changed twice can be returned
once, in its latest state. It can also be returned twice, even in one
response, and then both copies are its latest state. So don't count
changes to find your place: store the "seq" that we return.
A busy shop's ten thousand changes take a few hundred calls, not ten thousand, however they came about.
One synchronization at a time
We ping you as soon as something changes, without waiting for your answer to the last ping. Two payments a second apart give two pings, and if each starts a synchronization, the second starts before the first has finished:
Both apply trn#1, and the slow one saves its older "seq" over the
newer one, so the next synchronization fetches trn#2 again. With the
revision check above, nothing is lost or corrupted, but the work is
done twice, and side effects, such as emails, happen twice unless they
are in the same database transaction as the check.
Run one synchronization at a time, and store the
"seq" only if it is higher than the one you have.
Locks that work
Take a lock that is released when its holder dies, so that a crash can't leave it held:
- A row lock in a database transaction. Keep a row with your
"seq", and select itFOR UPDATEat the start; the lock goes when the transaction commits, rolls back, or its connection closes. With persistent connections, as PHP's, roll back any transaction that a request left open before you start. flock()on a lock file. The lock goes when the file is closed, which the operating system does when the process ends, however it ends. PHP closes it at the end of each request, fatal errors included.- One process. A single long-running worker that does all the synchronizing, as below, needs no lock at all.
Avoid locks that can outlive their holder: a flag in a table that says
a synchronization is running, a lock file whose existence is the lock,
or a named lock, such as MariaDB's GET_LOCK(), on a persistent
connection, which the connection can keep after the request that took
it has died.
Whatever the lock, a synchronization that finds it taken must not just give up. The one that holds it may already have made its last call to seq, before the change that the new ping is about. Wait for the lock, and then synchronize.
A single worker
The most efficient way is one process that calls seq in a loop for as
long as it runs, and sleeps when it has caught up. Your ping handler
only records the highest "seq" that pings have announced, and wakes
the worker:
global announced := 0
function OnPing(ping)
Lock()
if announced < ping.seq then
announced := ping.seq
Signal()
end if
Unlock()
return "ok"
end function
process Synchronize
seq := LoadSeq()
loop
data := GET("https://api.gjold.com/v1/seq/" ‖ seq)
for each change in data.changes do
Apply(change)
end for
if seq < data.seq then
seq := data.seq
StoreSeq(seq)
else
Lock()
while seq ≥ announced do
Wait()
end while
announced := 0
Unlock()
end if
end loop
end process
The examples call v1's seq. For v2, the URL is
https://api.gjold.com/v2/{shopid}:seq:{seq}, and the rest is the
same.
Lock, Unlock, Signal, and Wait are a mutex and a condition
variable shared by the handler and the worker, or their equivalent
across processes, such as a queue that the handler pushes to and the
worker blocks on. The handler answers ok at once, as it has handed
the work on.
Call Apply for every change, even one you have no use for, and store
the "seq" only after every change in the response is applied. If the
worker dies in between, it fetches the same changes again when it
starts, and the revision check makes that harmless.
Synchronizing in the ping handler
If you can't run a worker, as on shared PHP hosting, synchronize in the ping handler, under a lock that it waits for:
function OnPing(ping)
Lock() -- waits for a synchronization that is running
seq := LoadSeq()
while seq < ping.seq do
data := GET("https://api.gjold.com/v1/seq/" ‖ seq)
for each change in data.changes do
Apply(change)
end for
if data.seq = seq then break
seq := data.seq
StoreSeq(seq)
end while
Unlock()
return "ok"
end function
We wait 7 seconds for your answer, and a long synchronization, such as
the first, outlasts that: we hang up, and the handler must run on
without us, as PHP's ignore_user_abort() lets it. A ping that comes
meanwhile waits for the lock, and then finds little to do.
Store only a "seq" that seq returned. Seq does not check the number
you give it: one beyond the end of the sequence returns no changes and
the same number, so a corrupted "seq" waits, silently, until the shop
has had that many changes.
When something fails
If a call to seq fails, or applying a change does, stop, and don't
store the new "seq". The next synchronization starts from the last
"seq" you stored, and fetches the same changes again. Nothing is
lost, as seq keeps every change for as long as the shop exists.
For the same reason, you can start over from 0 at any time, such as
after restoring your database from a backup, and seq brings back every
object as it is now.
What seq guarantees
Seq does not return changes in the order they happened, since an object that changed twice is returned once. It does guarantee the order that matters for merging:
- Objects arrive in the order they were created. A transaction first appears after every transaction with a lower ID, and so does a subscriber. So if you have seen trn#7, you have seen trn#1 to trn#6: there are no gaps.
- A subscriber arrives before its charges. A charge first appears after the subscriber, in the revision that it names or a later one. So when a charge arrives, you know its subscriber.
These hold for the sequence as a whole, read from 0. If you start
later, you see the end of the sequence only: a charge may then arrive
for a subscriber that came before your starting point, and that you
never fetched. And they hold across all the changes of a call, not
for one type alone, so apply the changes of a response in the order
they come.
Questions
Is the ping there before the payer reaches the success page?
Usually, but not always. A payment is in seq within moments, but not
always before the payment window sends the payer on to your
"successurl", nor in every case before a capture or a charge
answers. So on your success page, don't rely on the ping having come:
- Look the order up in your database. If it is paid, say so.
- If not, synchronize once, as a ping would, and look again.
- If it is still not paid, say that the payment is being processed, and that the payer gets an email when it is through, rather than that it failed. Then send the email when the ping comes.
The payer may also never reach the success page, if they close the browser, so the order must be marked as paid by the synchronization, never by the success page.
Why pull changes, rather than have them pushed?
Because pushing them, with a webhook that carries the data, has every problem of pulling, and worse ones: a push that fails must be resent, and a receiver that is down for a day is either flooded or loses data; pushes arrive out of order, unless each waits for the last; and data pushed to a URL goes wherever that URL now points. A ping carries no data, so a lost or misdirected ping costs nothing, and seq lets you fetch what you missed, from any point, as often as you like.