Notes

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:

  1. Order trn#1 is paid.
  2. Order trn#2 is paid.
  3. Order trn#3 is paid.
  4. The shop captures trn#1.
  5. The shop captures trn#3.
  6. A customer subscribes, sub#1.
  7. The shop charges sub#1, which creates trn#4.
  8. The shop refunds part of trn#1.
  9. The shop captures trn#4.
  10. 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 it FOR UPDATE at 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:

  1. 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.
  2. 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:

  1. Look the order up in your database. If it is paid, say so.
  2. If not, synchronize once, as a ping would, and look again.
  3. 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.