Retention
The server keeps a log of every change, so each device can pull what it missed. Retention drops the log's old entries, the stored answers to pushes, and the devices that stopped syncing, by itself: nothing to schedule.
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: memoryStore(),
auth: async (request) => (await getSession(request))?.userId ?? null,
retention: { days: 30 }, // default 90; `false` keeps everything
});What it drops
Only history. Every row's current state stays, whatever its age: the log's entries are the changes that led there, which devices need only to catch up from where they left off.
It also forgets the answers the server stored to pushes (ferry_push_results) once they are older
than days. The server keeps them so that a device that never heard the answer can resend its
transaction and get the same one. After days, a resend of that transaction gets forgotten
instead: the change is not applied twice, and the device works out what became of it from the
server's data once it has caught up.
- If the server's rows show the change (every row it creates or edits is there, as it left it), the change is confirmed.
- Otherwise the device rolls it back, as for any refused write, and
onRejectedgets the code'forgotten': the server refused it, someone changed those rows since, or it was a delete, which the device cannot tell from a row it no longer sees.
Either way the device ends with what the server has. Only what your app hears can differ from the
first answer, so choose days above how long your users' devices stay offline. Devices on a release
from before forgotten existed get applied instead, and see such a change as accepted until their
next pull brings the server's rows.
And it forgets the devices that stopped sending changes: see the next section.
Devices that stop syncing
Every app install that has sent a change has one small row on the server (ferry_clients): which
user it belongs to, and how many of its changes the server has handled, so that a change sent twice
is applied once. An app that is deleted never says goodbye, so without pruning these rows would pile
up forever.
So the server forgets a device that has sent no change for clients.days: 365 days by default, or
days if you keep the log longer. Its rows on the server are gone, and so are the answers stored
for it. Nothing on the device is touched.
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: memoryStore(),
auth: async (request) => (await getSession(request))?.userId ?? null,
// Keep the log 90 days, and remember a device for two years after its last change.
retention: { days: 90, clients: { days: 730 } },
});When a forgotten device comes back
The app on that phone still works, and may hold changes it never sent. This is what happens when it syncs again:
- push as phone-A: 7 rename list, 8 add task
- forgottenClient: I don't remember you
- pull: what changed while I was away?
- the changes, up to now
- 7 shows in the data: it got there before. Confirmed, not sent again
- 8 does not show: take a new id, phone-B, and number it 1
- push as phone-B: 1 add task
- applied
-
The device sends its waiting changes. The server does not know it any more, so it answers
forgottenClient("I don't remember you") and applies nothing.You should see: nothing in the app. The device keeps its changes and keeps showing them.
-
The device downloads what changed while it was away, as on any sync (or everything, if it was away longer than
days). -
It checks its waiting changes against what it downloaded, oldest first. A change that already shows in the server's data reached the server before it was forgotten (its answer was lost on the way back): it is confirmed, and not sent again.
-
From the first change the data does not show, the device takes a new identity (a new client id) and sends the rest again, numbered from 1, as a new install would.
You should see: the changes on the user's other devices, once. The device's status goes back to
idlewith nothing pending.
Your app has nothing new to handle. A change the device knows the server had already handled (it had
an answer for a later one) is never sent again: it is confirmed from the data, or rolled back with
the code 'forgotten', as above.
When it runs
After a request, at most once an hour per server instance, the server drops the entries and the push
answers older than days, and forgets the devices quiet for longer than clients.days. It runs in
the background of that request: through waitUntil when the server has it,
so serverless platforms keep the function alive for it.
No cron job, no scheduled function: a server that gets requests prunes, and one that gets none has
nothing new to prune. To run it yourself (a script, a test), call
syncServer.protocol.pruneClients(before) with an epoch ms.
To see it work, turn on metrics: ferry_log_entries shrinks after a prune, and
ferry_retention_pruned_total adds up the entries each prune dropped.
A device behind the log
A device that was offline longer than the retention finds its place in the log gone. The server tells it so, and it downloads everything again, as on its first sync:
- its pending changes are kept, and sent afterwards;
- the user sees nothing but the time a full download takes.
So choose days above how long your users' devices stay offline: a phone in a drawer, a laptop over
the holidays. The default, 90, covers most apps.
Options
retention | What it does |
|---|---|
{ days: 90 } | The default: drop entries older than 90 days |
{ days: n } | Any positive number of days; 0.5 is 12 hours |
{ days: 90, clients: { days: 730 } } | Forget devices quiet for two years (default 365, or days) |
{ days: 90, clients: false } | Drop old entries, but remember every device |
false | Keep the whole log, every push answer and every device |
Gotchas
- Each instance prunes on its own clock, at most hourly. With several, the log is pruned about as often as each instance gets requests. That is fine: pruning twice drops nothing more.
- Retention is not deletion. A deleted row stays in the store as a tombstone, so a late update from an offline device cannot bring it back. Pruning the log does not remove tombstones. To erase a user's data for good, see Deleting users.
- A failed prune goes to
onError, fails no request, and is tried again an hour later. - A change whose answer was lost right before the device went quiet is the one case the server
cannot settle once it has forgotten the device. If the server applied it, and someone changed
those rows afterwards, its effects no longer show, and the device cannot tell it from a change
that never arrived. It sends it again, and it lands as a late write does: last writer wins, and a
create can bring back a row someone deleted. The same goes for a device whose very first push was
answered and lost. It takes a lost answer, then more than
clients.daysof silence, then someone editing the same rows, so keepclients.dayswell above how long your devices stay away. - A device that only reads is forgotten too, since only pushes count. That costs it nothing: it keeps pulling, and its next change takes the steps above.
- After upgrading to this release (store version 3), devices the server knew before have no time
yet. The first prune gives them its own, so none is forgotten until
clients.daysafter it. - Apps on an older release do not know
forgottenClient: they getconflict, take a new client id and send all their waiting changes again, without checking the data first. - A store of your own forgets devices only if it has
pruneClients(see stores); without it the server keeps every device.