Repository navigation
Conversation
a104d99 to
7870fb7
Compare
voidmatcha
left a comment
There was a problem hiding this comment.
Thanks for working on this. I found two correctness cases worth addressing before merge:
-
In personalized mode, GET_NOTE returns the user-specific note, but updateParagraph() checks baseChecksum against the master paragraph before selecting the user paragraph. Collaborative mode can also be active on a personalized note. A valid user-specific commit can therefore be rejected, and GET_PARAGRAPH currently returns the master paragraph for recovery. Could the checksum guard and recovery response use the paragraph version the client is editing, or explicitly handle this combined mode, with a regression test?
-
When a patch diverges, the client requests GET_PARAGRAPH. If another local edit occurs before the reply, updateAllScopeTexts() can replace paragraph.text while leaving originalText at a different version. That risks losing the later edit or building the next patch against the wrong base. Could recovery preserve edits made after the request or ignore a stale reply, with a test for this ordering?
This branch also currently conflicts with master, so please rebase once these cases are addressed.
patch_apply never throws: it drops a hunk whose context is gone, and fuzzy matching can place one at the wrong offset while still reporting success, so neither shape is visible to the receiver. A diverged client then commits its whole paragraph and the server stores it unconditionally, making that text authoritative for everyone. Senders now attach checksums of their text before and after the patch. A receiver that started from the same text but did not reach the same result knows it diverged and asks for that single paragraph again via GET_PARAGRAPH, instead of refetching the whole note. Commits carry the checksum of the text the client believed the server held, and the server keeps its own copy when they disagree. Both fields are optional, so clients that omit them behave as before. PatchParagraphSend and PatchParagraphReceived were mapped to the opposite directions, which left the receiving side without noteId or the new fields; they are swapped to match what is actually sent and received.
…ts made during recovery
Addresses two correctness cases raised in review.
In personalized mode GET_NOTE hands the client its own copy of a paragraph,
but updateParagraph() compared baseChecksum against the master paragraph,
because the switch to the user copy happened after the guard. Once the two
texts differ, a valid commit from a non-owner is rejected, and the rejection
answered with the master paragraph, so the client had no way back to the copy
it was editing. getParagraph() had the same problem. Both now resolve the
paragraph the client edits before using it. getUserParagraph() clones the
master when no copy exists yet, so a first edit still compares equal.
A diverged patch makes the client ask for the paragraph again. That answer
carries text the client has not seen, and an edit typed while the request was
in flight is not in it. The reply now echoes the request msgId, the way the
other paragraph broadcasts already do, and the client tracks recovery requests
separately from saves: pendingParagraphSaves acknowledges text this client
sent, while a recovery answer must not discard what came after it. The later
edit stays in dirtyText and the editor re-bases on the server text.
Verified with:
./mvnw test -pl zeppelin-server --am -Dtest=NotebookServiceTest
cd zeppelin-web-angular && npm run test:shell
16 server tests and 251 frontend tests pass. Reverting each fix fails the
matching test rather than passing by construction:
expected: <user1 edit> but was: <user1 text>
expected: <user1 text> but was: <master text>
AssertionError: expected undefined to be 'edit typed while waiting'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7870fb7 to
e78fa6b
Compare
|
@voidmatcha Thank you for the careful review. Both cases were real, and I have addressed them. 1. Personalized mode checked the wrong paragraph
2. Recovery could drop an edit made during the request
Tests 16 server and 251 frontend tests pass. Reverting each fix fails the matching test: |
What is this PR for?
In collaborative mode paragraph text is synchronised with diff-match-patch, and both the receiving client (
paragraph-base.ts) and the server (NotebookService.java) use only element[0]ofpatch_apply, discarding the per-hunk success array. A patch that cannot be applied does not throw, and it fails in two different shapes:patch_applyreturns the original text with[false], so the sender's edit silently disappears on that client[true]. If one user changesbravotobravo!while another has deleted that line, the second client ends up withalpha\n!charlie\nand a success flagThe second shape is why inspecting the success array is not enough, and the ticket already ruled out both that approach and
Match_Threshold = 0.A diverged paragraph does not stay local. Focusing and blurring calls
commitParagraph, which sends the client's whole paragraph, and the server stores it throughp.setText(text)without comparing it to what it holds, then broadcasts it to every connection. Whichever client commits first makes its own text authoritative for everyone.This PR keeps the existing patch mechanism and makes divergence detectable instead:
GET_PARAGRAPHmessage. The ticket rejected refetching the note because it recreates every editor while the user is typing, so recovery is per paragraph.broadcastParagraphalready sends a single paragraph, but a client had no way to ask for one; the response reuses the existingOP.PARAGRAPHhandler.Both fields are optional: clients that do not send them behave exactly as before.
Two things I deliberately left out of scope, and some type-level changes I had to include:
patchApply(...)[0]is unchanged. The server holds a single authoritative copy and applies every patch in order, so it does not diverge the way a client with local edits does, and the commit check backs it up. I can add a warning there in a follow-up if you would prefer it for diagnostics.zeppelin-web/src/app/notebook/paragraph/paragraph.controller.jshas the same line, but with the new UI as default the classic one is served under/classicand has to be opened deliberately. Since the fields are optional it keeps working unchanged, and mixing the two UIs is safe apart from the classic client losing the check. Would you prefer I cover it here, or file a follow-up?PatchParagraphSendandPatchParagraphReceivedwere mapped to the opposite directions inmessage-data-type-map.interface.ts, which left the receiving side typed withoutnoteIdor the new fields, so they are swapped to match what is actually sent. And after rebasing onto the merged ZEPPELIN-6582, the checksums are computed inside itsmakeParagraphPatch()rather than inline in the component; reachingtextChecksumfrom that spec needs vitest to resolve@zeppelin/sdk, sovitest.shell.config.mtsmaps that one alias to the SDK source. Only the SDK is mapped, since the others pull in the JIT compiler and monaco. Happy to split either out if you would rather review them separately.Finally, this does not make the sync conflict-free. diff-match-patch locates a hunk by matching surrounding context, so a concurrent edit that removes that context can still make a patch land in the wrong place. Editors like Google Docs avoid this class of bug with Operational Transformation or CRDTs, where each edit carries an explicit position the server transforms against concurrent operations. Replacing Zeppelin's sync engine would be far larger than this ticket, so this PR takes the narrower path the ticket points at: make divergence detectable, and stop one client's diverged text from becoming everyone's.
What type of PR is it?
Bug Fix
Todos
GET_PARAGRAPHso a single paragraph can be resentWhat is the Jira issue?
How should this be tested?
New test
NotebookServiceTest#testUpdateParagraphChecksIsBasedOnCurrentServerTextcovers the commit check: a commit based on the current server text is stored, one based on text the server no longer holds is rejected and leaves the server copy untouched, and a commit without a checksum keeps the previous behaviour.Result:
Tests run: 10, Failures: 0, Errors: 0.--ammatters here: this addsGET_PARAGRAPHtoMessage.OPinzeppelin-common, so buildingzeppelin-serveralone compiles the tests against a stale copy and fails on an unrelated symbol. If a local Zeppelin server is running it holds the Lucene index lock and every test errors out insetUp, so stop it first or pointZEPPELIN_SEARCH_INDEX_PATHat a scratch directory.The checksum uses the same algorithm as
String.hashCode()so both sides compute the same value; I verified Java and TypeScript agree on empty strings, newlines, non-ASCII text, emoji (surrogate pairs) and long input.Result:
Tests 31 passed (31), includingparagraph-patch.spec.tscovering the checksums the sender attaches.Manual: open the same note in two browser sessions and edit the same paragraph concurrently, with one session deleting a line while the other edits it. I confirmed the built bundle ships the new fields and that ordinary collaborative sync still works, but I could not reproduce the divergence by hand — on a local server the patch round trip is fast enough that the window never opens. This matches the ticket's note that existing e2e coverage does not exercise failed or misapplied patches, which is why the commit check is covered by unit tests instead.
Rejecting stale commit of paragraphin the server log marks a refused commit.Screenshots (if appropriate)
N/A
Questions: