Skip to content

Deduplicate scheduled syncs so a manual sync reschedules instead of doubling#15052

Open
rtibblesbot wants to merge 5 commits into
learningequality:developfrom
rtibblesbot:issue-14988-1dc828
Open

Deduplicate scheduled syncs so a manual sync reschedules instead of doubling#15052
rtibblesbot wants to merge 5 commits into
learningequality:developfrom
rtibblesbot:issue-14988-1dc828

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

The first manual "Sync" to Kolibri Data Portal spawned a second, concurrent dataportalsync task alongside the scheduled recurring one; the two ran at once and morango's deserializing stage failed. This gives the manual sync the same deterministic job_id as the scheduled recurring job, so the manual request reschedules the existing job to run now instead of minting a duplicate, and preserves the job's recurrence when it is brought forward. The same convergence is extended to scheduled peer facility syncs.

References

Fixes #14988. First seen: v0.19.5-rc0 (reproduces on 0.19.4).

Reviewer guidance

Verify fix of #14988

AI usage

Used Claude Code to implement the fix from a pre-approved plan with test-driven development. Verified with the auth-task and task-storage pytest suites and prek/ruff.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-07-24 03:00 UTC

Storage.schedule()'s upsert overwrote interval/repeat/retry_interval with
the incoming call's values. So enqueuing a plain one-off onto an existing
recurring row (e.g. a manual "Sync now" landing on a scheduled recurring
sync) silently cancelled the recurrence.

Keep the existing schedule when the incoming enqueue is a plain one-off,
moving only scheduled_time forward. After the immediate run completes the
worker reschedules to now + interval, re-anchoring the recurrence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added DEV: backend Python, databases, networking, filesystem... SIZE: medium labels Jul 23, 2026
@rtibblesbot
rtibblesbot marked this pull request as ready for review July 23, 2026 01:54
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@rtibbles rtibbles 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.

This makes sense, I would prefer to keep the changes more localized rather than changing global schedule semantics for the sake of two tasks.

Comment thread kolibri/core/tasks/storage.py Outdated
}

if orm_job:
# Bringing a recurring job forward for an immediate run (e.g. a

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.

I would prefer we handle this internally to the syncing tasks only, rather than changing the semantics of the global schedule method for every single task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — reverted the Storage.schedule() change so global scheduling semantics are untouched, and moved the recurrence handling into the sync tasks. SyncJobValidator now brings an already-scheduled recurring sync forward via enqueue_args (re-asserting the existing interval/repeat/retry_interval) when a manual sync converges onto its deterministic job_id; a running scheduled job is left alone so the plain enqueue dedups onto it. Only syncs get this behaviour now. See 14148ad.

rtibblesbot and others added 4 commits July 23, 2026 14:28
A manual "Sync" to KDP minted a random job id, so it never collided with
the scheduled recurring sync (deterministic id "kdp_sync_<facility>").
Both ran at once and morango's deserializing stage failed (learningequality#14988).

Give the manual dataportalsync path the same deterministic id via a new
DataPortalSyncJobValidator, so a second sync collapses onto the scheduled
row and brings it forward instead of running concurrently. resumesync
keeps its own id — it targets one specific session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply the same deterministic-id dedup to peerfacilitysync: a manual "Sync
now" to a peer with a scheduled recurring sync now collapses onto the
existing job ("peer_sync_<facility>_<device>") when a concrete device is
known. Peer facility imports are one-off pulls and explicitly drop any
inherited id so they never collapse onto a schedule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The readable dedup seeds "kdp_sync_<facility>" (41 chars) and
"peer_sync_<facility>_<device>" (75 chars) overflow Job.id, which is
capped at varchar(36). SQLite ignores the length so it passed locally,
but Postgres rejects the insert with StringDataRightTruncation, failing
the real-storage dedup tests.

Hash the seed to a stable 32-char id (same approach as
discovery.tasks.generate_job_id). Both the scheduled and manual paths go
through the shared helpers, so dedup convergence is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the change to the global Storage.schedule() upsert and instead keep
a scheduled recurring sync's recurrence in the syncing tasks themselves.
When a manual sync converges onto an already-scheduled recurring sync (they
share a deterministic job_id), SyncJobValidator now brings the scheduled job
forward via enqueue_args that re-assert its interval/repeat, rather than
relying on schedule() to preserve recurrence for every task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@rtibbles rtibbles 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 looks good - just needs manual verification of the fix.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Ran a manual end-to-end verification against the real task queue (job_storage on an isolated KOLIBRI_HOME), reproducing both doubling paths from the issue.

1. Scheduled recurring sync waiting for its next daily run + manual "Sync"

  • enqueue_automatic_kdp_sync(facility) schedules the deterministic kdp_sync_<hash> job, next run ~1 day out.
  • The manual dataportalsync validates to the same job_id, converging onto that row.
  • After the manual sync: exactly one dataportalsync job exists.
  • Its recurrence is preserved (repeat=None, interval=86400).
  • Its scheduled_time is brought forward to now.

2. Scheduled sync already RUNNING + manual "Sync" (the ~25-min running sync in the report)

  • The validator sees RUNNING and emits no bring-forward reschedule.
  • enqueue_job hits the deterministic id and catches JobRunning internally.
  • It returns the existing running job's id.
  • After the manual sync: still exactly one job, running job untouched — no concurrent second sync to collide on deserializing.

Not covered here: a live KDP registration push, which needs a real portal token — worth one QA pass against staging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Device > Facilities - The syncing task is doubled in the Task manager and takes too long

2 participants