Repository navigation
Conversation
|
I'll take a look at it later, but in the meantime tell Claude to be more succinct in its comments. Could you briefly sketch what it actually looks like from the API side? |
|
PUT is unchanged apart from an {"document":"<content>",
"identifiers":[{"type":"content","value":"<content>"},
{"type":"structure","value":"<structure>"},
{"type":"metadata","value":"<metadata>"}],
"percentage":0.42,"progress":"/body/DocFragment[20]/body/p[22]","device":"my kpw"}A GET has no body, so the same list goes in one query parameter: The response is v1 plus two fields: {"document":"<the digest the record is stored under>", "percentage":0.42,
"progress":"/body/DocFragment[20]/body/p[22]", "device":"my kpw", "timestamp":1758472800,
"match":"content", "progress_match":"metadata"}
Version comes from |
|
Alright, thanks. Is it correct that this version of v2 is missing the equivalent of #56? |
|
Yes, it is missing. v2 checks Identifier values are not affected: they travel in the I would rather rebase this on #56 than duplicate the check here, once you have decided about that one. Say which and I will push it. |
74ec483 to
5ded8c7
Compare
|
Rebased on master and v2 now has the same guard, as its own commit. 66 tests pass, of which 28 are the master suite unchanged. The helper is duplicated rather than lifted somewhere shared, the way |
|
all set |
|
Thanks. The text in the README could also be (much) briefer. Incidentally, did you run any kind of performance benchmarks to get a rough idea of the impact of the additional options? |
|
Sure, I'll attack both. No, I didn't do any performance testing, but maybe we can use the conformance test for that? I'll give it a shot, update here with performance testing results, and, if its worth it, I'll push another PR that can do CI performance regression tests. |
|
Benchmarked. This is a laptop, so the percentages are the useful part and the absolute rates are not. Method: both trees built from source against the same OpenResty 1.29.2.3, the same rock tree and the same redis over a unix socket. Arms interleaved across five repetitions, FLUSHDB and a seed write before every measurement, 3s warmup discarded each time. wrk at 4 threads and 8 connections for throughput, 1 connection for service time. CONFIG RESETSTAT and INFO commandstats around each measured run. Roughly 400k requests per arm. Each percentage is the mean of five ratios against v1 on master paired within the same repetition. Between-repetition CV was 0.4 to 1.6% and there were no errors or non-2xx responses in any of the 100 measurements.
Serial p50, PUT and GET: v1 627 and 621 us, v2 with 8 identifiers 678 and 672 us. v1 is unchanged. v2 costs 4 to 9% on writes, rising with the identifier count because the walk runs inside the single EVAL rather than in extra round trips. Reads are flat at 6 to 7.5% whatever the count. Not covered: concurrent writers, so the create-if-absent and alias-healing branches were not exercised under contention. The harness is in #57, as an optional workflow that gates nothing, in case it is worth having. |
|
Alright, looks decent. Pinging @poire-z @hius07 @Commodore64user @mergen3107 for a second opinion on the proposed API. @pid1 I assume you also intend to open a corresponding PR for the kosync plugin? |
|
Yeah, I figured we'd get the implementation agreed upon here before we did that. I'll make the corresponding PR and link it here once y'all have a chance to agree that this implementation looks good. |
|
My usual questions are:
|
It seems the main motivation is working around some rather bizarre-sounding behavior by something called CrossInk, that involves recompressing an EPUB. Though I assume it must strip out unused CSS or some such along the way, since just recompressing a zip file sounds rather nonsensical. But setting that aside, I can see why some people might think the current options are a bit restrictive.
This is the server side. The only noticeable performance difference on the client side would be if the server became noticeably slower. I.e., it says here this is at least 5% slower which may or may not be a big deal depending on how close to capacity you are. PS Forgot to ping @NiLuJe. |
|
Thanks! |
FWIW, I can build the corresponding plugin-side code for this, test it from a Kindle, and we can ship that all together, if that's preferable. |
|
If the 5% is a concern (given the thousands of ops/second required to expose it), we can land some improvements for Redis (connection pooling would be one of the first) to improve the scalability of the server. |
|
Not as such; it's within my expectations given that it has more to do. |
|
Can you provide a TL:DR ELI5 explanation of the new API, i understand previously was either a hash comparison or file name comparison. |
Yeah, that's correct. Instead of picking one identifier, this allows sending several in priority order. The server tries each in turn and tells you which one matched, and whether the stored position was written by a copy that shares that identifier. A renamed or recompressed copy still finds its place, and a weak match is a signal to seek by percentage rather than trust the xpointer. |
|
I'm not familiar with/interesting in/ using kosync, so no real opinion. |
Me neither; I just know Redis and servers (and even syncing to a point) so I can say half sensible things about it. |
A client may send several opaque identifiers for one document, in its own order
of preference, so a renamed or recompressed copy finds the reading position
stored for another copy. Identifiers other than the one a record is stored
under become aliases, user:{user}:alias:{digest} -> {type}:{canonical_digest}.
`identifiers` in the PUT body and `ids` on the GET are both optional; a request
that names none behaves as before. One that does also gets back `match`, the
identifier type that found the record, and `progress_match`, the strongest
identifier it shares with the client that wrote the current progress string.
a91cec1 to
0c0f5ad
Compare
|
Fair point, dropped the version bump. It's optional fields on v1 now: That also leaves a v2 free for the wider thing in koreader/koreader#15182, which seems like a better use of one. |
SPEC.md gains §5.8 for the `identifiers` array and `ids` query parameter of koreader/koreader-sync-server#55, marked proposed and pinned to the branch commit rather than to the reference commits of §1.1, because the pull request is not merged. verify.mjs gains eighteen [K-ID-…] assertions behind --identifiers. Without the flag it records none of them, so an existing server's report is unchanged byte for byte. With the flag, a capability probe skips the whole family when the server ignores `identifiers`. coverage.mjs counts proposed requirements separately and fails if an assertion for one escapes the verifier's opt-in block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SPEC.md gains §5.8: a reading position matched by several document identifiers, as proposed in koreader/koreader-sync-server#55. The section is pinned to the proposal's branch commit and marked as describing an unmerged pull request. Its eighteen requirements are MAY, under the optional feature `identifiers`. The verifier pushes one position naming identifiers and reads the answer: a `match` field means the feature is implemented and the family is checked, its absence means the family is recorded as SKIP. A server that does not implement §5.8 stays conformant; one that does is held to all of it. An assertion whose id falls under a feature's requirement prefix has to name that feature, and coverage.mjs fails if a proposed requirement belongs to no feature at all. Together those stop §5.8 reaching MUST and being charged to every server. Measured 2026-09-22: koreader/kosync:latest 49 passed, 0 MUST, 2 SHOULD, 18 skipped pid1/tsundoku main 47 passed, 0 MUST, 0 SHOULD, 20 skipped koreader-sync-server#55 at 0c0f5ad 69 passed, 0 MUST, 0 SHOULD, 0 skipped The results table also gains a Skipped column, and tsundoku's SHOULD-failure count is corrected from 2 to 0: that 2 was its skipped registration assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both progress endpoints take an optional, ordered list of identifiers for one
book - {type, value} in the body on the push, flattened into an ?ids parameter
on the read - so a recompressed or re-downloaded copy keeps its position. The
response gains match, and the read also progress_match: how the record was
found and how much of it the caller shares with whoever wrote the position are
different questions.
Identifiers other than the record's own digest become aliases for it, per
account, created and never repointed, and never shadowing a digest that is a
document in its own right. They are separate from the manual merges in
document_aliases: a merge is followed by every request, an identifier alias
only by a request that names identifiers. A request that names none is answered
exactly as before.
Tracks koreader/koreader-sync-server#55, which is open and unmerged, and
SPEC.md 5.8 of pid1/kosync-conformance.
The list is the client's order of preference, so pinning `document` to the first position makes preference and identity the same thing. They are not: KOReader set to match documents by filename is addressed by the weakest digest it holds, and would have to offer that one first and be matched on it, reporting `progress_match: "filename"` for a copy whose content is byte-identical. Two devices differing only in that setting also report a different `match` for one book. The list now has to contain an entry equal to `document`, wherever it sits. When nothing resolves, the record is created under `document` rather than under the first entry, so it stays reachable by a client that names no identifiers and follows no alias to get there.
The list is the client's order of preference, so pinning `document` to the first position makes preference and identity the same thing. KOReader set to match documents by filename is addressed by the weakest digest it holds, and would report `progress_match: "filename"` for a copy whose content is byte-identical. K-ID-8 now asserts the list contains an entry equal to `document`. K-ID-8b asserts that entry need not be first, that the record is created under `document`, and that a read naming no identifiers still finds it. Measured against koreader/koreader-sync-server#55: the new assertion fails on the branch before the corresponding fix and passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matching on a weak identifier is a guess. Registering the caller's stronger digests as aliases to that guess makes a wrong one permanent: two books a library tagged alike share only their weakest identifier, and the second one pushed took over the first one's record, destroyed the position stored there, and could not be separated again by correcting the tagging, because its own content digest had been glued to the other record on the way through. An identifier the caller ranks above the match is now left alone, so a wrong guess is confined to the identifier that made it and correcting that identifier frees the copy. On a create the record is the caller's own and every identifier it offers describes it, so all of them are registered as before.
|
Two fixes pushed, both from finding a way to lose a reading position. A match on a weak identifier was registering the caller's stronger digests as aliases to it. Two books a library had tagged alike shared only their weakest identifier, so the second one pushed took over the first one's record and overwrote the position stored there — and correcting the tagging did not separate them again, because the second book's own content digest had been glued to the other record on the way through. An identifier ranked above the one that matched is now left alone, so a wrong guess stays confined to the identifier that made it. I've also dropped the |
`type` is opaque to the server and not to clients: `progress_match` decides whether one device follows another's position, so a label without a recipe is not interoperable. The registry gives `content`, `structure` and `filename` theirs, and K-ID-14 says an unrecognised label is never sufficient to follow a position. `structure` covers no file contents. CrossPoint's EPUB optimizer re-encodes images, injects a stylesheet into every chapter and rewrites `img src`, so every entry's bytes change while the spine does not; a digest over entry CRCs fails against the tool this exists for. The spine href list is also what an xpointer counts. There is no `metadata` type. It is the only identifier that can match two different files, and an alias is never repointed. K-ID-12b, asserted: an identifier ranked above the one that matched is not registered. Measured against koreader/koreader-sync-server#55 — the assertion fails on the branch before that fix and passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Multi-device syncing is exercised end to end on a Kindle Paperwhite 5 SE. The client side is in koreader/koreader#16110. |
Could you clarify the reasoning? I find the recompression thing by far the dumbest that hardly needs to be accommodated. |
|
|
An entry in the PUT body may carry `weak: true`. A walk that resolves only through such an entry writes under `document`, creating it if absent, rather than adopting the record it found. A read is untouched: adoption is a property of a write, so the `ids` grammar has no equivalent. A write that does not adopt creates the caller's own record, so every identifier it offers is registered against it. The weak digest that produced the match already points elsewhere, and an alias is never repointed.
|
Alright. |
|
This should be ready for re-review now. I built out the client-side implementation of this for KOReader, CrossPoint, and Readest, and tested each successfully, including the weird CrossPoint "optimize epub" option. |
Both progress endpoints take an optional, ordered list of identifiers for one
book - {type, value} in the body on the push, flattened into an ?ids parameter
on the read - so a recompressed or re-downloaded copy keeps its position. The
response gains match, and the read also progress_match: how the record was
found and how much of it the caller shares with whoever wrote the position are
different questions.
Identifiers other than the record's own digest become aliases for it, per
account, created and never repointed, and never shadowing a digest that is a
document in its own right. They are separate from the manual merges in
document_aliases: a merge is followed by every request, an identifier alias
only by a request that names identifiers. A request that names none is answered
exactly as before.
Tracks koreader/koreader-sync-server#55, which is open and unmerged, and
SPEC.md 5.8 of pid1/kosync-conformance.
SPEC.md gains §5.8: a reading position matched by several document identifiers, as proposed in koreader/koreader-sync-server#55. The section is pinned to the proposal's branch commit and marked as describing an unmerged pull request. Its eighteen requirements are MAY, under the optional feature `identifiers`. The verifier pushes one position naming identifiers and reads the answer: a `match` field means the feature is implemented and the family is checked, its absence means the family is recorded as SKIP. A server that does not implement §5.8 stays conformant; one that does is held to all of it. An assertion whose id falls under a feature's requirement prefix has to name that feature, and coverage.mjs fails if a proposed requirement belongs to no feature at all. Together those stop §5.8 reaching MUST and being charged to every server. Measured 2026-09-22: koreader/kosync:latest 49 passed, 0 MUST, 2 SHOULD, 18 skipped pid1/tsundoku main 47 passed, 0 MUST, 0 SHOULD, 20 skipped koreader-sync-server#55 at 0c0f5ad 69 passed, 0 MUST, 0 SHOULD, 0 skipped The results table also gains a Skipped column, and tsundoku's SHOULD-failure count is corrected from 2 to 0: that 2 was its skipped registration assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list is the client's order of preference, so pinning `document` to the first position makes preference and identity the same thing. KOReader set to match documents by filename is addressed by the weakest digest it holds, and would report `progress_match: "filename"` for a copy whose content is byte-identical. K-ID-8 now asserts the list contains an entry equal to `document`. K-ID-8b asserts that entry need not be first, that the record is created under `document`, and that a read naming no identifiers still finds it. Measured against koreader/koreader-sync-server#55: the new assertion fails on the branch before the corresponding fix and passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`type` is opaque to the server and not to clients: `progress_match` decides whether one device follows another's position, so a label without a recipe is not interoperable. The registry gives `content`, `structure` and `filename` theirs, and K-ID-14 says an unrecognised label is never sufficient to follow a position. `structure` covers no file contents. CrossPoint's EPUB optimizer re-encodes images, injects a stylesheet into every chapter and rewrites `img src`, so every entry's bytes change while the spine does not; a digest over entry CRCs fails against the tool this exists for. The spine href list is also what an xpointer counts. There is no `metadata` type. It is the only identifier that can match two different files, and an alias is never repointed. K-ID-12b, asserted: an identifier ranked above the one that matched is not registered. Measured against koreader/koreader-sync-server#55 — the assertion fails on the branch before that fix and passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PUT /syncs/progresstakes an optionalidentifiersarray andGET /syncs/progress/:documentan optionalidsquery parameter, so a renamed or recompressed copy can find the position stored for another copy.A request that names none behaves exactly as before: same code path, same response.
config/routes.luais unchanged and there is no new API version.Identifiers are
{ "type", "value" }pairs in the client's order of preference, and the first must equaldocument. A type is an opaque label the server stores and echoes without interpreting, so new identifiers need no server change.Two fields come back, and only for a request that named identifiers.
matchis the identifier type that resolved the lookup.progress_matchis the strongest identifier shared with whoever wrote the currentprogressstring, and is the one that says whether the xpointer can be followed. They differ when a weaker identifier let another copy write into the record, which is the case where a reader should seek by percentage instead.Identifiers other than the record's own become aliases, per account, removed with the account. An alias is only created, never repointed, and never shadows a document that exists in its own right, so a weak identifier can fail to match but cannot move a position onto the wrong record. At most 8 per request.
The server sees only digests: no title, no author, no filename.
65 tests pass.
spec/controllers/1/syncs_controller_spec.luais untouched, so the existing suite is the guard that a request naming no identifiers is unaffected.This change is