Skip to content

fix: stop orphaning google calendar events on schedule re-upload#501

Merged
jaspermayone merged 1 commit into
mainfrom
jaspermayone/500-dup-calendar-events
Jul 11, 2026
Merged

fix: stop orphaning google calendar events on schedule re-upload#501
jaspermayone merged 1 commit into
mainfrom
jaspermayone/500-dup-calendar-events

Conversation

@jaspermayone

Copy link
Copy Markdown
Member

Closes #500

Every course event was appearing twice (or more) in users' Google Calendars.

Root cause

Since 035a4bd (June 13), CourseProcessorService ran course.meeting_times.destroy_all on every POST /api/process_courses (every extension schedule upload). Meeting times carried has_many :google_calendar_events, dependent: :destroy, so the tracking rows were destroyed without any Google API call — the real events stayed on the calendar, now invisible to the app. The re-ingested meeting times got new IDs, so the next sync created a fresh copy of every event next to the stranded one. Each re-upload added another generation, for every user enrolled in the course, and no cleanup job could see the orphans because nothing reconciles against the Google side.

sequenceDiagram
    participant E as Extension
    participant P as CourseProcessorService
    participant DB as google_calendar_events
    participant G as Google Calendar

    E->>P: POST /api/process_courses
    P->>DB: meeting_times.destroy_all (cascade)
    Note over DB,G: rows destroyed, no API call —<br/>events stranded in Google
    P->>P: re-ingest meeting times (new IDs)
    P->>G: next sync: no tracked event for new IDs
    G->>G: duplicate created next to orphan
Loading

Changes

  • CourseProcessorService: upsert meeting times in place instead of destroy-all-and-recreate. MeetingTimesIngestService now returns the IDs it touched; only rows absent from the upload are destroyed. This keeps meeting time IDs (and therefore event tracking rows and event_preference records, which were also being wiped every upload) stable.
  • Course::MeetingTime / FinalExam: google_calendar_events association changed from dependent: :destroy to dependent: :nullify. The tracking row is the only pointer to the real Google event — orphaned rows are now picked up by CleanupOrphanedCalendarEventsJob, which deletes from Google before removing the row. (UniversityCalendarEvent already worked this way.)
  • CleanupUntrackedGoogleEventsJob (new, one-time remediation): lists events on each app-managed calendar via the service account and deletes any not tracked in google_calendar_events. Skips cancelled events and user-edited instances of tracked recurring series. Supports dry_run: true and per-calendar scoping.

Remediation rollout

CleanupUntrackedGoogleEventsJob.perform_now(nil, dry_run: true)  # inspect logs first
CleanupUntrackedGoogleEventsJob.perform_later                    # then delete for real

Tests

  • CourseProcessorService: meeting time IDs stable across re-processing, tracking rows preserved, stale meeting times removed with events nullified
  • MeetingTimesIngestService: returns touched IDs, reuses rows on re-ingest
  • GoogleCalendarEvent: nullified (not destroyed) when meeting time / final exam is destroyed
  • CleanupUntrackedGoogleEventsJob: deletes only untracked events, dry-run mode, 404 tolerance

Full suite passes locally (bundle exec rspec, 19 examples, 0 failures); rubocop clean.

@jaspermayone
jaspermayone merged commit 1d2f7a0 into main Jul 11, 2026
1 of 3 checks passed
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.

duplicate calendar events after schedule re-upload

1 participant