Skip to content

fix: save the profile without waiting for an unchanged copy - #1865

Merged
ovitrif merged 4 commits into
masterfrom
fix/profile-save-one-write
Nov 11, 2026
Merged

ovitrif merged 4 commits into
masterfrom
fix/profile-save-one-write

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1866
Refs: #1801

Description

  • Fixes Edit Profile Save taking about twice as long as before feat: use the pubky.app profile directly #1801, because a save waited for the pubky.app write and then for the Paykit copy write. A save with no tag change now returns after the pubky.app write and lets the copy write follow in the background. The next save's copy write waits for it, so an older copy never overwrites newer tags; a deletion refuses or waits for it; and it leaves the unread flag a load may have set since alone. A save that changes tags still waits for the copy, since a failed tag write has to be reported.
  • @jvsena42 found this on the iOS twin (feat: use the pubky.app profile directly bitkit-ios#1235, review); the same change is there in synonymdev/bitkit-ios@718b20221.

Design

N/A — no UI changes.

Preview

N/A — no user-visible changes beyond the save being faster.

QA Notes

Manual Tests

  1. Edit Profile → change only the name → Save: "Profile saved" shows after the pubky.app write, and a few seconds later Bitkit's Paykit copy (/pub/paykit/profile.json) has the new name.
  2. Edit Profile → add a tag → Save: "Profile saved" shows once the copy has the tag.
  3. Edit Profile → change the name → Save, then at once Delete Profile: both profiles are gone and no copy is written back.

Automated Checks

  • saveProfile without a tag change does not wait for the profile copy, a background copy write keeps a later load's unread tags, a save that reads the copy itself lets the next edit remove a tag, a tag save after a failed read and write keeps the stored tags without the error's base and a name save after a failed read and write keeps the stored tags fail without their change and pass with it.
  • Unit tests (5353) and detekt pass.

Evidence

A save with no tag change writes pubky.app and lets the Paykit copy write follow in the background, so Edit Profile Save takes one write again.
@ovitrif ovitrif self-assigned this Nov 11, 2026
@ovitrif
ovitrif requested review from a team, ben-kaufman and coreyphillips and removed request for a team November 11, 2026 13:47
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T13:49:32.508593Z f114a8e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f114a8ee68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@greptile-apps

greptile-apps Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects profile saves and their follow-up writes.

The copy writes need ordering protection before merging because an earlier save can overwrite tags from a later save.

Findings

  1. P1 Earlier copies overwrite newer tags ▶

Summary

Saves with unchanged tags now return before the Paykit copy finishes. Saves with changed tags still wait for that write.

  • Profile saves return sooner when their tags stay the same.

Reviews (1) · Last reviewed commit: "fix: save the profile without waiting fo..." · Reviewed by Greptile

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from e571a6a (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovitrif ovitrif added this to the 3.2.0 milestone Nov 11, 2026
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Milestone 3.2.0, tracked in #1866: it fixes a slower save that #1801 brought into 3.2.0.

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No findings. A save with no tag change now returns after the pubky.app write and launches the Paykit copy write on the repo scope; a tag change still waits for it. Reviewed f114a8e, full tier, static: Bitkit Android branches are not built here, so this is reasoned from the code and CI.

What I checked, and 4 candidates I ruled out

Read in full: saveProfile, writeProfile, profileWrite, deleteProfile, loadOwnProfile, publishPaykitProfileCopy, storedCopy in PubkyRepo.kt, and the new test
CI: unit tests, detekt, lint, build and UI tests green on f114a8e; two e2e jobs still pending when I read it

Open thread that stands

  • @ben-kaufman's point on the launched copy clearing a later load's tagsUnavailable: traced it. loadOwnProfile reads the copy outside the write lock and sets tagsUnavailable = true with empty tags on a failed read; the background writeCopy then runs .onSuccess { tagsUnavailable = false }; the next profileEditBase() hands tagsUnread = false, so storedCopy returns tags = null and the save writes the form's empty list as the copy. Before this change the copy write finished inside the save, so a load could not slot in between on the normal edit flow.

Ruled out

  • An earlier copy overwriting a later save's tags (the Greptile and Codex threads): profileWriteMutex is a FIFO Mutex. The next save's pubky.app write takes the lock before its copy write queues, so the stale copy lands after the newer one only if the IO dispatcher has not started the launched coroutine through the user reopening the editor and a whole pubky.app round trip.
  • A copy written after a deletion: deleteProfile bumps profileDeletionGeneration under the same lock, so the launched write either holds the lock first or fails the generation check.
  • A failed background copy leaving the stored copy stale: the next loadOwnProfile sees paykitData != profile.toProfileData() and republishes it through publishPaykitProfileCopy.
  • The new test leaking a suspended coroutine: appScope runs on the test's UnconfinedTestDispatcher, and finishCopy is completed before the test ends.

Merge confidence: 3/5, the open tagsUnavailable thread is a real tag loss on a reopen with a failed copy read.

A save waits for a copy write an earlier save left running before writing its own, and the background write no longer clears the unread flag a load may have set.
@ovitrif
ovitrif requested a review from ben-kaufman November 11, 2026 14:27

@jvsena42 jvsena42 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code scan at cd6aa23fe: no findings. This is the Android port of the iOS fix in synonymdev/bitkit-ios#1235 (718b20221).

  • Unchanged tags: a save with unchanged tags launches the copy write in scope and returns after the pubky.app write. copyWriteOrder serializes pendingCopyWrite, and each save joins the earlier write before starting its own, so an older copy never lands over a newer one.
  • Deletion: the background write still goes through profileWrite(generation), so a deletion that bumped the generation refuses it.
  • Tag changes still wait for the copy, and only that path clears tagsUnavailable, so a failed tag write is still reported.
  • A background write that fails is logged, and the next load refreshes the copy, as on iOS.

perf: relevant. This is the fix for the Edit Save slowdown measured on #1801 (median 13.2 s vs 8.8 s on master, under load). On iOS the same change brought a name-only save to 4.3–5.4 s.

Device gate (partial): cd6aa23fe — not run yet. Timing a name-only Save against master on the emulator, and manual tests 1–3, are next.

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovitrif ovitrif removed this from the 3.2.0 milestone Nov 11, 2026

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two independent reviews, nothing blocking a merge.

worth doing, does not block

  • Background-path save keeps a stale unread flag it just resolved (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1350-1356)

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked after 1 new commit (9f3ecc7). 2 points still block a merge.

addressed

  • Background-path save keeps a stale unread flag it just resolved (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1350-1356). Fixed: writeProfileOnce captures readEpoch at PubkyRepo.kt:1293. storedCopy clears tagsUnavailable after a successful read when no load has bumped tagsReadEpoch (1382), and both loads bump it (954, 1405). I traced the scenario: the edit opens with [a, b], a load fails, the user removes b, and the save takes the background path. The flag now ends false, so the next edit writes [a], which is what the new test a save that reads the copy itself lets the next edit remove a tag asserts. Not run locally. The same clear causes a new problem on the synchronous path, filed separately.

new since the last review

needs changing before merge

  • Unread flag is cleared before the copy write, so a failed write loses tags (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1382)
  • A failed copy write lets the next save erase unread tags (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1382)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Device gate on cd6aa23fe; the head is now 9f3ecc7b6, which I reviewed and is clean. Approving.

9f3ecc7b6 clears the unread-tags flag after a save that read the copy itself, but only when tagsReadEpoch hasn't moved since the save began, so a load that found the copy unreadable meanwhile wins. It doesn't touch the name-only save path timed below. It matches iOS 49f305a3f on synonymdev/bitkit-ios#1235.

Name-only Save → "Profile saved" (Gate_Throwaway emulator, staging; builds alternated; host load 5.7–15 at each tap):

run this PR master d642de94b
1 5.45 s 16.70 s
2 5.47 s 17.92 s
3 9.01 s 21.41 s

On master the toast follows the Paykit copy write. Here it comes first, and the copy follows 3–8 s later with the new name.

Case Result
1. Name-only save Pass: pubky.app has the new name before the toast, and the copy follows.
2. Tag-change save Pass: the toast comes only once the copy holds the tag (13.5 s, 9.7 s).
3. Save, then Delete at once Pass. Delete was confirmed while the background copy write was still pending; the write landed first, then both profile.json files returned 404 and stayed 404 for about 110 s.
Relaunch Pass: name and tags kept.

Device gate: cd6aa23fe (head 9f3ecc7b6, a flag-only change, reviewed) — manual: 3 passed plus relaunch; perf: name-only Save 5.5–9.0 s (master 16.7–21.4 s).

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked after 1 new commit (e571a6a). Nothing blocking remains.

addressed

  • Unread flag is cleared before the copy write, so a failed write loses tags (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1382). Fixed: storedCopy (PubkyRepo.kt:1379-1384) no longer touches tagsUnavailable. The synchronous branch clears it only in onSuccess (1362), and markTagsRead runs only on the background branch (1359). In your Profile-screen sequence the failed write in step 2 now leaves the flag true, so step 3's profileEditBase() has tagsUnread = true, re-reads the copy and writes [friend, work, vip]. I traced this, and the new test a tag save after a failed read and write keeps the stored tags without the error's base covers it without the error's base. I could not run it because the bitkit-core and paykit artifacts are missing from the local Gradle cache.
  • A failed copy write lets the next save erase unread tags (app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:1382). Fixed: For the name-only save, keptTags is [friend, work] and baseTags is [], so the save takes the synchronous branch (1362). The failed write leaves the flag set because storedCopy no longer clears it. The next save re-reads the copy and writes [friend, work]. Clearing on the background branch (1359) is safe: there keptTags == baseTags and the stored profile carries every tag the copy holds, even if that write later fails. I confirmed this by tracing and by reading a name save after a failed read and write keeps the stored tags. I did not run the tests.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of e571a6a77: no findings. Approving. markTagsRead(stored, readEpoch) now runs when the save that read the copy itself launches its background write, still guarded by tagsReadEpoch, and the write's outcome leaves the flag alone. It doesn't touch the name-only save path timed at cd6aa23fe, so those timings stand.

Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovitrif

ovitrif commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA for 9f3ecc7:

Tested on a Pixel emulator (Android 16): dev debug build of 9f3ecc7 against staging, with a log-only timing hook that is not part of this PR. The stored profiles were read from the homeserver with the wallet's own key. e571a6a, the head since, only changes when a save clears the unread-tags flag after a failed copy write. The UI can't stage a failed write, so its two new unit tests cover it.

🟢 1. Edit only the name
With the tag family on the profile, renaming it to Charlie shows "Profile saved" 1.3 s before the copy write ends (app log). pubky.app then has the name Charlie, and the Paykit copy has Charlie and still the tag family. After a relaunch, both stored profiles are unchanged and the name and tag show.
case1-name2x.mp4

🟢 2. Edit the name and the tags
Renaming to Bravo and adding a second tag, family, shows "Profile saved" only once the copy write has ended. pubky.app has the name Bravo, and the copy has Bravo with friends and family. Removing friends then leaves only family in the copy and on screen, and pubky.app is unchanged.
case2-tag2x.mp4

🟢 3. Save, then delete at once
Renaming to Lima and saving, then going straight to Edit Profile → Delete Profile → Yes, Delete leaves both `profile.json` files at 404, still 404 ten seconds later, and the app ends on onboarding. Under this host's load the copy write ended before the delete. @jvsena42's run covered the delete landing while the copy write was still pending.
case3-delete2x.mp4

🟢 Save time, name only

Measured from the start of the save to "Profile saved", with the app log's write times. The host's load average swung from about 120 to 540 during these runs.

Run Load Save → toast pubky.app write Copy write Toast
this PR 1 181 2.51 s 2.0 s 2.7 s 2.6 s before the copy ends
this PR 2 288 4.06 s 4.0 s 2.3 s 2.2 s before the copy ends
this PR 3 430 19.68 s 16.4 s 26.9 s 25.8 s before the copy ends
master 1 327 6.74 s 3.3 s 3.3 s after the copy
master 2 226 8.09 s 3.6 s 3.7 s after the copy
master 3 201 4.27 s 2.1 s 2.1 s after the copy

Here a save takes as long as the pubky.app write; on master, as long as both writes. Three runs per build under this load don't give a reliable median.

timing-pr2x.mp4

One open observation: in an earlier run of case 1, at load 184, the stored profiles were right and the app logged the toast, but it never appeared on screen. Every other save showed it, including master's under higher load, so I can't tell whether it was the load or this PR.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Merging: approved by @coreyphillips and @jvsena42 on e571a6a, CI is green, the QA passed, and every thread is answered. It closes #1866 and ships with the pubky.app profile work in 3.1.1.

@ovitrif
ovitrif merged commit 831c4d2 into master Nov 11, 2026
23 checks passed
@ovitrif
ovitrif deleted the fix/profile-save-one-write branch November 11, 2026 16:14
@piotr-iohk piotr-iohk mentioned this pull request Nov 12, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: profile save takes twice as long

5 participants