Deleting an account when the response never arrives
The server accepted the deletion. The phone never heard back. Should it wipe itself? Answering that safely took a receipt, a status check that works without a session, and one database row that settles a race.
Deleting an account in Miuchio has two halves. The server removes the account and settles every album it belongs to. The phone erases everything it holds: private keys, album keys, cached photos, names and the upload queue.
The two halves have to agree, and getting it wrong is costly in both directions. A phone that wipes itself for a deletion that never happened destroys its keys for nothing. Miuchio has no backup as of now, and the server will not accept a new identity key for an existing account, so that person loses access to every album. A phone that keeps its data after the server deleted the account leaves decrypted photos and keys on a device whose owner asked for them to be gone.
When the network behaves, agreeing is easy. This post is about the moment it does not.
The moment that breaks it
You tap Delete. The phone sends the request. In one transaction the server accepts it, marks the account as deleting and ends every session the account has. Then the response is lost: the connection drops, the app is killed, or the request times out.
Now the phone knows nothing. There are three possible explanations, and they call for opposite actions:
- The request never reached the server. The account is fine. Wiping would be a disaster.
- The request reached the server and was accepted. The phone must wipe.
- The request is still on its way. It may be accepted a second from now.
The obvious move is to ask the server. But if the deletion was accepted, every session ended with it, so the phone can no longer prove who it is. The question has to be answerable without an account.
A receipt, written before anything is sent
Before the request leaves, the phone creates a receipt: 32 random bytes. It saves the receipt to a small file, and only then sends it with the request:
// No request leaves without a marker, or a lost response strands the data
await _marker.write(pending);
await _api.request(pending.sharedAlbumIds, pending.receipt);The file is written under a temporary name and then renamed, so a crash never leaves half a receipt behind. It holds only the random receipt and the IDs of the shared albums the person agreed to delete, all of which the server already knows.
In the same transaction that accepts the deletion, the server stores the SHA-256 of the receipt in a table of its own. That table has no account column. A receipt proves that some deletion was accepted, without saying whose. Receipts are kept for 180 days, long enough for a phone that went offline in the middle of a deletion to come back and ask.
Looking up a receipt needs no session: GET /account-deletions/{receipt} answers whether a deletion with that receipt was accepted. Only the phone that made the receipt knows it, and 32 random bytes cannot be guessed, so the lookup is safe to leave open.
Retrying without deleting twice
When the response is lost, the phone asks about its receipt straight away. If the answer is yes, it wipes. If the answer is no, it sends the same request again, with the same receipt.
Reusing the receipt is what makes the retry harmless. If the first request arrives late, the server sees a receipt it already holds and treats the retry as the same deletion, not a new one. A retry can also be refused precisely because the first request landed and ended the session. So after any refusal, the phone asks about the receipt once more before it concludes anything.
If the phone still cannot tell, it does not guess. It tells the person what it knows, that Miuchio has not confirmed the deletion yet, and lets them choose: send it again, or keep the account.
A restart keeps the question open
The receipt file survives the app being killed. It also survives the wipe itself, because the wipe must not delete the one thing that says a wipe is due. The code that empties the app's folders skips that file by name.
When the app starts and finds the file, it does not open the albums. It opens the deletion screen, which cannot be dismissed, and settles the question first:
- If the file already records an acceptance, the phone wipes, even with no network.
- If the server says the receipt was accepted, the phone wipes.
- If the server says it was not, the phone does not send the request again on its own. Another request might still be in flight, and resending at launch would turn a restart into a decision the person never made. It asks them instead.
Keeping the account is the hard part
Suppose the person chooses to keep their account. The simple version deletes the receipt file and returns to the app. There is a race hiding in it.
The first request may still be in flight. The phone forgets the receipt, the person carries on, and a minute later the delayed request arrives. The server accepts it. Every session ends, the account starts deleting, and the phone no longer holds the receipt that would explain why. The person chose to keep an account the server is now removing.
Cancelling something that may still happen only works if the cancel and the original meet in one place and agree on a winner. In Miuchio they meet on the receipt row.
Keeping the account first sends an abandon call for the receipt. The server tries to insert the receipt row, marked as abandoned. Accepting a deletion starts by inserting the same row, keyed by the same hash:
-- Keep my account
INSERT INTO account_deletion_receipts (receipt_hash, abandoned)
VALUES ($1, TRUE) ON CONFLICT DO NOTHING
-- Accept the deletion, the first write in its transaction
INSERT INTO account_deletion_receipts (receipt_hash)
VALUES ($1) ON CONFLICT DO NOTHING- If the abandon inserts first, the row is marked abandoned. Any request carrying that receipt, now or later, finds it and is refused. Only then does the phone forget the receipt and return to the app.
- If the acceptance inserts first, the abandon finds a row that is not abandoned and reports that the deletion already won. The phone does not return to the app. It wipes, because the account really is being deleted.
The phone deletes its file only after the server has answered. If the abandon call cannot get through, the file stays and the question stays open.
A test runs both calls at the same moment, 25 times, against a real database (TestAccountDeletion_RaceBetweenAcceptAndAbandonHasOneWinner). Every round must end one of two ways: the deletion was accepted and the abandon said so, or the request was refused and the account is untouched.
The same path handles a plan that changed. Before confirming, the person sees exactly what will happen to each album, including shared albums that will be deleted for everyone because they are the admin. The server checks that plan again when it accepts, under a lock on the account row that invites also take. If an album became shared in the meantime, the request is refused and the phone abandons its receipt in the same way.
The server finishes on its own
Accepting a deletion only records it. From that moment the account cannot sign in, join an album or publish keys. A background job then does the removal, one album per transaction, and re-reads what is left before each step, so a crash anywhere resumes cleanly on the next run.
- An album where the person is the only member is deleted.
- An album they administer with other members is deleted for everyone, as the plan said.
- An album where they are a plain member is left: their photos and avatar are removed, and the album is marked as owing a new key, because the departing phone still holds the current one. Removing someone without leaking the next photo explains why that mark has to be stored.
- When no memberships remain, the account row is deleted under the same lock that album creation and invites take, so nothing can be added at the last moment.
The job claims work with FOR UPDATE SKIP LOCKED and moves its own next attempt time forward before it starts. If a worker crashes mid run, its claim simply expires and another run picks the job up. Failed runs back off from 5 minutes to at most 6 hours.
A wipe that checks its own work
On the phone, the wipe is a list of small steps: stop uploads and realtime, clear the upload queue, caches, names, avatars, pins and activity, delete every key in the key store, and empty the app's folders. Every step is safe to repeat.
The wipe runs every step, then looks for anything left: files, secure storage entries, preferences and keys. If something remains, it runs the whole list again, up to three times, before reporting what it could not remove.
The receipt file is deleted only after that check passes. If the wipe cannot finish, the file stays and still records the acceptance, so the next launch goes straight back to wiping instead of opening a half erased app.
What this does not do
- It cannot reach beyond the app. When a shared album is deleted, the app on other members' phones removes it too, but it cannot take back screenshots or copies saved outside the app.
- It has no undo. Once the server accepts, deletion is final. Keeping the account is only possible while the request has not been accepted.
- It cannot answer without the network. A phone that never reaches the server again cannot learn the outcome, so it stays on the deletion screen and keeps its data rather than guessing.
What I took from it
- A timeout is an unknown outcome, not a failure. Design for the third answer.
- Write down what you are about to do before you do it, so a crash leaves evidence instead of a mystery.
- Give a retry the identity of the first attempt, so the server can tell a repeat from a new request.
- A cancel has to compete with the thing it cancels for the same record. A cancel that only changes local state is a hope.
- Anything that must survive a wipe has to be excluded on purpose, and the wipe has to check its own result.