Skip to content

Commit 4417eb2

Browse files
ADFA-4954 fix(kolibri): re-attach went silent, phase latched, margin truncated
Review fixes on the two commits below. Two of these break scenarios the ZIM client never meets, which is why copying its shape carried them in unnoticed. attach() went completely silent on a reused instance. `finished` short-circuits both pollOnce() and fail(), so re-attaching after a seed had completed produced no polling, no error and no callback — on the one method whose entire purpose is recovering after the service restarts. Run state is now cleared on entry, before the id check, and an empty id fails with its real cause instead of degrading into ten 404s and "lost contact with the content service". The indexing flag latched. RestContentClient can latch it because it handles one ZIM; a Kolibri job walks several channels and re-enters processing once per channel, so onIndexing() only ever fired for the first. Cleared on entering downloading rather than only set on leaving it. SeedPlan.requiredBytes divided before multiplying, truncating the remainder before it was scaled. Real impact is ~108 bytes on a multi-gigabyte decision, so this is small — but the test could not have caught it either way: it used 1000, which is divisible by 100 and therefore identical under both orderings. Added a case with 1099, which distinguishes them (1100 truncated vs 1208 scaled), and one at catalog scale to pin the absence of overflow now that the multiply comes first. Also seed() with an empty plan now routes through done() so the double-callback guard is armed rather than left open. Not fixed, and noted rather than silently carried: `listener` and `pollErrors` are non-volatile and touched from IO threads. The race is real but benign, and both are inherited verbatim from RestContentClient — correcting one client and not the other would be worse than the race. Worth its own pass over both. Verified: all seven files parse; the arithmetic checked numerically against the values the new tests assert. NOT COMPILED — no javac in the sandbox.
1 parent a13d625 commit 4417eb2

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

‎controller/app/src/main/java/org/iiab/controller/kolibri/data/KolibriRestClient.java‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,12 @@ private static String firstBlocker(JSONObject readyResponse) {
131131
*/
132132
public void seed(@NonNull SeedPlan plan, @NonNull Listener l) {
133133
this.listener = l;
134+
resetRunState();
134135
if (plan.isEmpty()) {
135136
// Nothing queued is a success, not an error: the caller drained an
136-
// empty wishlist.
137-
main.post(l::onDone);
137+
// empty wishlist. Routed through done() so the double-callback guard is
138+
// armed rather than left open.
139+
done();
138140
return;
139141
}
140142
AppExecutors.get().io().execute(() -> {
@@ -154,13 +156,37 @@ public void seed(@NonNull SeedPlan plan, @NonNull Listener l) {
154156
});
155157
}
156158

157-
/** Re-attaches to a job started earlier, e.g. after the service was restarted. */
159+
/**
160+
* Re-attaches to a job started earlier, e.g. after the service was restarted.
161+
*
162+
* <p>Resets the run state first, and that ordering matters: {@code finished}
163+
* short-circuits both {@link #pollOnce()} and {@link #fail(String)}, so on an
164+
* instance that already completed a seed this method would otherwise go
165+
* completely silent — no polling, no error, no callback.
166+
*/
158167
public void attach(@NonNull String existingJobId, @NonNull Listener l) {
159168
this.listener = l;
160-
this.jobId = existingJobId;
169+
resetRunState();
170+
171+
String id = existingJobId.trim();
172+
if (id.isEmpty()) {
173+
// Fail with the real cause. Left to the poller, an empty id would 404
174+
// ten times and surface as "lost contact with the content service",
175+
// pointing at the network instead of at the missing id.
176+
fail("no job to re-attach to");
177+
return;
178+
}
179+
this.jobId = id;
161180
main.postDelayed(pollTask, POLL_MS);
162181
}
163182

183+
/** Clears the per-run flags so the instance can start or re-attach again. */
184+
private void resetRunState() {
185+
finished = false;
186+
indexing = false;
187+
pollErrors = 0;
188+
}
189+
164190
/** The id of the in-flight job, or null. Persist it to survive a restart. */
165191
public String jobId() {
166192
return jobId;
@@ -208,6 +234,12 @@ private void pollOnce() {
208234

209235
switch (phase) {
210236
case "downloading":
237+
// Cleared on the way in, not only set on the way out: a job that
238+
// seeds several channels re-enters processing once per channel,
239+
// and a latching flag would announce the phase for the first
240+
// channel only. The ZIM client can latch because it handles one
241+
// item; this one cannot.
242+
indexing = false;
211243
if (percent >= 0) {
212244
final int p = percent;
213245
final String rate = formatRate(speed);

‎controller/app/src/main/java/org/iiab/controller/kolibri/domain/SeedPlan.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ public long requiredBytes() {
173173
*/
174174
public long requiredBytes(int marginPercent) {
175175
int margin = Math.max(100, marginPercent);
176-
return estimatedBytes() / 100L * margin;
176+
// Multiply first: dividing by 100 up front truncates the remainder before it
177+
// is scaled, losing up to ~99 bytes per call. No overflow risk — the whole
178+
// public catalog is ~775 GB (8.3e11), and 8.3e11 * 150 is 1.2e14 against a
179+
// long ceiling of 9.2e18.
180+
return estimatedBytes() * margin / 100L;
177181
}
178182

179183
/**

‎controller/app/src/test/java/org/iiab/controller/kolibri/domain/SeedPlanTest.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,30 @@ public void requiredBytesAddsHeadRoom() {
122122
assertEquals(1500L, p.requiredBytes(150));
123123
}
124124

125+
@Test
126+
public void requiredBytesScalesTheRemainderInsteadOfTruncatingIt() {
127+
// A size divisible by 100 cannot tell "* margin / 100" from "/ 100 * margin",
128+
// so the original test passed while the second, wrong, ordering was in place.
129+
// 1099 does distinguish them: truncating first gives 1100, scaling gives 1208.
130+
SeedPlan p = SeedPlan.of(
131+
Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)),
132+
sizes(STORYBOOK, 1099L));
133+
assertEquals(1208L, p.requiredBytes());
134+
assertEquals(1648L, p.requiredBytes(150));
135+
}
136+
137+
@Test
138+
public void requiredBytesDoesNotOverflowOnACatalogSizedPlan() {
139+
// The whole public catalog is ~775 GB; multiplying before dividing has to stay
140+
// inside long. 8.3e11 * 150 is 1.2e14, well under the 9.2e18 ceiling.
141+
SeedPlan p = SeedPlan.of(
142+
Collections.singletonList(ChannelSelection.wholeChannel(STORYBOOK)),
143+
sizes(STORYBOOK, 775L * GB));
144+
long required = p.requiredBytes(150);
145+
assertTrue("overflowed to a negative", required > 0L);
146+
assertEquals(775L * GB / 100L * 150L, required);
147+
}
148+
125149
@Test
126150
public void aMarginBelowOneHundredIsClampedNotHonoured() {
127151
// Planning to use less space than the content needs is never right.

0 commit comments

Comments
 (0)