Skip to content

Deduplicate Android MDM Pub/Sub deliveries and protect against reordering - #49792

Merged
dantecatalfamo merged 10 commits into
mainfrom
43502-android-pubsub-dedup
Aug 6, 2026
Merged

Deduplicate Android MDM Pub/Sub deliveries and protect against reordering#49792
dantecatalfamo merged 10 commits into
mainfrom
43502-android-pubsub-dedup

Conversation

@dantecatalfamo

@dantecatalfamo dantecatalfamo commented Jul 22, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #43502

Checklist for submitter

  • Changes file added for user-visible changes in changes/.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements).

  • Timeouts are implemented and retries are limited to avoid infinite loops

  • If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes

Summary

Google Cloud Pub/Sub push delivery (used for Android MDM notifications at POST /api/v1/fleet/android_enterprise/pubsub) is at-least-once and unordered. The handler had no message-level deduplication and no timestamp-based staleness check, so:

  • Duplicate ENROLLMENT deliveries could re-queue the setup experience (duplicate VPP installs / activity rows).
  • A stale DELETED redelivered after a re-ENROLLMENT could unenroll a live host, and routine STATUS_REPORTs never restored host_mdm.enrolled, so the host stayed stuck showing unenrolled until a fresh ENROLLMENT.

This adds a per-host dedup + staleness mechanism and repairs the recovery path:

  • New last_pubsub_message_id / last_pubsub_event_time columns on android_devices. On ENROLLMENT/STATUS_REPORT, once the host is resolved, a notification whose messageId matches — or whose event timestamp (device LastStatusReportTime, falling back to the envelope publishTime) is older than — the last processed for that host is skipped.
  • Dedup state is recorded on every state-changing path, including the WIPE-ack unenroll (COMMAND), so a late in-flight STATUS_REPORT cannot re-enroll a just-wiped host.
  • A STATUS_REPORT from a host currently marked unenrolled restores host_mdm.enrolled=1, preserving the existing is_personal_enrollment classification.
  • Dedup reads are forced onto the primary so fast redeliveries are not missed on a lagging replica.
  • MarkAllPendingVPPInstallsAsFailedForAndroidHost is already scoped to still-pending rows (so duplicate DELETED deliveries emit no duplicate activities); this is now documented with a comment.

Known limitation (documented in code): dedup stores only the last-processed messageId, so it catches the common sequential redelivery but not two truly concurrent duplicate deliveries; the timestamp staleness check backstops ordering. Fully idempotent handling of concurrent duplicate setup-experience runs would require worker-level idempotency, which is out of scope for this change.

Testing

  • Added/updated automated tests
  • Where appropriate, automated tests simulate multiple hosts and test for host isolation
  • QA'd all new/changed functionality manually

Automated coverage:

  • server/mdm/android/service/pubsub_dedup_test.go — duplicate/stale ENROLLMENT and STATUS_REPORT are skipped; out-of-order DELETED is dropped by staleness; stale STATUS_REPORT does not trigger recovery; equal-timestamp distinct message is processed; WIPE ack records dedup state; successful paths record state.
  • server/datastore/mysql/android_test.goSetAndroidHostEnrolled (no-op when enrolled, recovers when unenrolled, preserves BYO/COBO classification) and Pub/Sub dedup state get/set round-trip.
  • Migration test for the new columns.

Reviewer commands:

MYSQL_TEST=1 go test ./server/datastore/mysql/ -run 'TestAndroid/(SetAndroidHostEnrolled|AndroidPubSubDedupState)'
MYSQL_TEST=1 go test ./server/datastore/mysql/migrations/tables/ -run TestUp_20260722203845
go test ./server/mdm/android/service/ -run TestPubSubDedupAndStaleness

Database migrations

  • Checked schema for all modified tables for columns that will auto-update timestamps during migration.
  • Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.
  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

The migration only ADDs two nullable columns to android_devices; existing rows get NULL and no ON UPDATE timestamp fires on ADD COLUMN.

Summary by CodeRabbit

  • Bug Fixes
    • Android Pub/Sub notifications are now protected against duplicate and out-of-order deliveries.
    • Duplicate notifications no longer trigger repeated setup, unenrollment, or failed-install activities.
    • Stale device-deletion notifications after re-enrollment no longer incorrectly leave devices unenrolled.
    • Valid status updates can restore enrollment when appropriate.
  • Reliability
    • Notification processing now records event details to improve consistency across retries and device reconciliation.

…ring

Google Pub/Sub push delivery is at-least-once and unordered, but the Android
MDM notification handler had no message-level dedup or staleness check, so
redeliveries could re-run the setup experience and a stale DELETED arriving
after a re-enrollment could leave a live host stuck showing unenrolled.

- Add last_pubsub_message_id / last_pubsub_event_time to android_devices and
  skip a notification whose messageId matches, or whose event timestamp is
  older than, the last one processed for that host.
- Record dedup state on every state-changing path, including the WIPE-ack
  unenroll, so a late in-flight STATUS_REPORT cannot re-enroll a wiped host.
- Restore host_mdm.enrolled on a STATUS_REPORT from a host currently marked
  unenrolled, preserving its is_personal_enrollment classification.
- Force dedup reads onto the primary so fast redeliveries are not missed on a
  lagging replica.
- Document that MarkAllPendingVPPInstallsAsFailedForAndroidHost is already
  scoped to still-pending rows.

Fixes #43502
Make the last_pubsub_message_id column's collation explicit
(utf8mb4_unicode_ci) rather than relying on the table default.
@dantecatalfamo
dantecatalfamo requested a review from a team as a code owner July 22, 2026 21:25
Copilot AI lite review requested due to automatic review settings July 22, 2026 21:25
@dantecatalfamo
dantecatalfamo marked this pull request as draft July 22, 2026 21:25
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Android Pub/Sub messages now carry message IDs and publish times. Fleet stores per-host deduplication state in android_devices. Handlers skip duplicate and stale enrollment, status, command, and WIPE notifications. Live status reports can restore enrollment. Reconciliation records unenrollment timestamps. Enrollment helpers return host IDs for state recording. Tests cover datastore behavior, migrations, duplicate and stale deliveries, recovery, deleted devices, and WIPE acknowledgments.

Possibly related PRs

  • fleetdm/fleet#48535: Adds Android Pub/Sub notification flows that this change deduplicates and orders.
  • fleetdm/fleet#50177: Also changes Android Pub/Sub command handling and WIPE side effects.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements deduplication, staleness protection, enrollment recovery, and pending-install safeguards, but does not add worker-level setup-experience idempotency requested as defense in depth. Add worker-level idempotency for setup-experience jobs, or update issue scope explicitly before merging.
Docstring Coverage ⚠️ Warning Docstring coverage is 35.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: Android MDM Pub/Sub deduplication and protection against out-of-order deliveries.
Description check ✅ Passed The description identifies the issue, explains the change, documents testing, and completes the relevant checklist sections.
Out of Scope Changes check ✅ Passed The schema, datastore, handler, reconciliation, mock, tests, migration, and release note changes directly support the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 43502-android-pubsub-dedup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
server/mdm/android/service/pubsub.go (2)

542-574: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SetAndroidHostEnrolled runs on every non-stale STATUS_REPORT.

STATUS_REPORTs are a hot path, and SetAndroidHostEnrolled performs an AppConfig read plus a transaction/SELECT on each call even when the host is already enrolled (the common case), where it just no-ops. Consider gating the recovery call on a cheaper signal (e.g. only when the host is currently unenrolled) to avoid the per-report DB round trip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub.go` around lines 542 - 574, Gate the
recovery call in the STATUS_REPORT handling flow on the host’s current
unenrolled state before invoking SetAndroidHostEnrolled. Preserve the existing
stale-message protection and logging/error behavior when recovery is needed,
while avoiding the AppConfig read and transaction for already-enrolled hosts.

370-391: 🩺 Stability & Availability | 🔵 Trivial

Dedup gate is check-then-act; concurrent redeliveries can still both process.

isDuplicateOrStalePubSub reads state and recordPubSubProcessed writes it in separate steps with no lock. Forcing the primary closes the replica-lag window, but two at-least-once redeliveries handled concurrently (same instance goroutines or different instances) can both pass this gate before either records, re-running enrollment/unenroll work. This is the residual risk your PR objectives already flag for worker-level idempotency on setup-experience jobs. Worth confirming the setup-experience worker (and the unenroll activity path) is idempotent enough to absorb this, since dedup alone does not serialize concurrent deliveries.

This affects both handlePubSubEnrollment (Lines 707-744) and handlePubSubStatusReport (Lines 542-574), which share this helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub.go` around lines 370 - 391, The
deduplication check in isDuplicateOrStalePubSub is not atomic with
recordPubSubProcessed, allowing concurrent deliveries to process the same
message. Add an atomic claim/record mechanism shared by handlePubSubEnrollment
and handlePubSubStatusReport, or otherwise serialize the check and write across
instances, and ensure both handlers proceed only when the claim succeeds; verify
the setup-experience and unenroll paths remain idempotent for concurrent
delivery.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/datastore/mysql/android.go`:
- Around line 580-612: Update SetAndroidPubSubDedupState to inspect the result
of its UPDATE via RowsAffected and, when zero rows match, return a wrapped
notFound("AndroidDevice").WithID(hostID) error consistent with
GetAndroidPubSubDedupState. Preserve existing wrapping for execution errors, and
clarify GetAndroidPubSubDedupState’s comment to distinguish an absent
AndroidDevice row from a present row with no recorded dedup state.

---

Nitpick comments:
In `@server/mdm/android/service/pubsub.go`:
- Around line 542-574: Gate the recovery call in the STATUS_REPORT handling flow
on the host’s current unenrolled state before invoking SetAndroidHostEnrolled.
Preserve the existing stale-message protection and logging/error behavior when
recovery is needed, while avoiding the AppConfig read and transaction for
already-enrolled hosts.
- Around line 370-391: The deduplication check in isDuplicateOrStalePubSub is
not atomic with recordPubSubProcessed, allowing concurrent deliveries to process
the same message. Add an atomic claim/record mechanism shared by
handlePubSubEnrollment and handlePubSubStatusReport, or otherwise serialize the
check and write across instances, and ensure both handlers proceed only when the
claim succeeds; verify the setup-experience and unenroll paths remain idempotent
for concurrent delivery.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f8947160-4b54-427e-9f2c-8b3086f1c713

📥 Commits

Reviewing files that changed from the base of the PR and between 568fc0e and 8406d0b.

📒 Files selected for processing (13)
  • changes/43502-android-pubsub-dedup
  • server/datastore/mysql/android.go
  • server/datastore/mysql/android_test.go
  • server/datastore/mysql/migrations/tables/20260722203845_AddPubSubDedupToAndroidDevices.go
  • server/datastore/mysql/migrations/tables/20260722203845_AddPubSubDedupToAndroidDevices_test.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/vpp.go
  • server/fleet/datastore.go
  • server/mdm/android/pubsub.go
  • server/mdm/android/service/enterprises_test.go
  • server/mdm/android/service/pubsub.go
  • server/mdm/android/service/pubsub_dedup_test.go
  • server/mock/datastore_mock.go

Comment thread server/datastore/mysql/android.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

This PR hardens Fleet’s Android MDM Pub/Sub push handler against Pub/Sub’s at-least-once and unordered delivery by adding per-host deduplication and staleness checks, and by improving recovery when a valid STATUS_REPORT arrives after an out-of-order DELETED.

Changes:

  • Adds per-host Pub/Sub dedup/staleness tracking using new android_devices.last_pubsub_message_id and android_devices.last_pubsub_event_time.
  • Applies dedup + staleness checks across ENROLLMENT/STATUS_REPORT/COMMAND flows and restores host_mdm.enrolled on live STATUS_REPORTs when needed.
  • Adds/updates datastore APIs, migrations, and unit tests covering duplicate/stale/out-of-order cases.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
server/mock/datastore_mock.go Extends datastore mock with new Android dedup/enrollment recovery methods.
server/mdm/android/service/pubsub.go Implements dedup + staleness logic, records processing state, and adds STATUS_REPORT enrollment recovery.
server/mdm/android/service/pubsub_dedup_test.go New unit tests for duplicate/stale/out-of-order handling and WIPE ack dedup recording.
server/mdm/android/service/enterprises_test.go Initializes new mock datastore funcs for tests.
server/mdm/android/pubsub.go Adds Pub/Sub envelope fields messageId and publishTime to the parsed struct.
server/fleet/datastore.go Adds AndroidDatastore interface methods for dedup state and enrollment recovery.
server/datastore/mysql/vpp.go Documents idempotency of pending-VPP-fail path to avoid duplicate activities on duplicate DELETED.
server/datastore/mysql/schema.sql Adds new columns to android_devices and updates migration status table seed.
server/datastore/mysql/migrations/tables/20260722203845_AddPubSubDedupToAndroidDevices.go Migration adding the new dedup columns.
server/datastore/mysql/migrations/tables/20260722203845_AddPubSubDedupToAndroidDevices_test.go Migration test validating NULL defaults and writability of new columns.
server/datastore/mysql/android.go Implements Get/SetAndroidPubSubDedupState and SetAndroidHostEnrolled.
server/datastore/mysql/android_test.go Adds tests for enrollment recovery behavior and dedup state round-trip.
changes/43502-android-pubsub-dedup Release note entry (content excluded from review).
Files excluded by content exclusion policy (1)
  • changes/43502-android-pubsub-dedup

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/mdm/android/service/pubsub_dedup_test.go Outdated
Comment thread server/datastore/mysql/android.go
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 60.83916% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.99%. Comparing base (0fd5739) to head (981c2bc).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
server/mdm/android/service/pubsub.go 59.72% 14 Missing and 15 partials ⚠️
server/datastore/mysql/android.go 65.51% 15 Missing and 5 partials ⚠️
...s/20260728120000_AddPubSubDedupToAndroidDevices.go 60.00% 3 Missing and 1 partial ⚠️
server/mdm/android/service/reconcile_devices.go 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #49792      +/-   ##
==========================================
- Coverage   68.04%   67.99%   -0.05%     
==========================================
  Files        3929     3898      -31     
  Lines      250276   249942     -334     
  Branches    13431    13020     -411     
==========================================
- Hits       170294   169958     -336     
+ Misses      64681    64654      -27     
- Partials    15301    15330      +29     
Flag Coverage Δ
backend 69.38% <60.83%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…dedup

# Conflicts:
#	server/datastore/mysql/schema.sql
- SetAndroidPubSubDedupState now checks RowsAffected and returns a NotFound
  error when no android_devices row matches, so a missing row surfaces via the
  caller's log instead of silently dropping dedup state (clientFoundRows is
  enabled, so a no-op update on an existing row still reports 1 matched row).
- Clarify GetAndroidPubSubDedupState doc: distinguish an absent row (NotFound)
  from a present row with nothing recorded yet.
- Drop the unused esID parameter from the makeEnrollmentEnvelope test helper.
- Add a datastore test asserting set-on-missing-row returns NotFound.
Copilot AI review requested due to automatic review settings July 28, 2026 19:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.

Files excluded by content exclusion policy (1)
  • changes/43502-android-pubsub-dedup
Comments suppressed due to low confidence (1)

server/datastore/mysql/android.go:612

  • SetAndroidPubSubDedupState unconditionally overwrites last_pubsub_event_time with the provided eventTime. When eventTime is nil (e.g. envelope/device timestamp missing or unparseable), this UPDATE will set last_pubsub_event_time to NULL and effectively disable staleness protection for future deliveries until a later message happens to carry a parseable timestamp. Consider preserving the existing last_pubsub_event_time (and similarly last_pubsub_message_id when messageID is empty) instead of clobbering it with NULL/empty values.
func (ds *Datastore) SetAndroidPubSubDedupState(ctx context.Context, hostID uint, messageID string, eventTime *time.Time) error {
	result, err := ds.writer(ctx).ExecContext(ctx,
		`UPDATE android_devices SET last_pubsub_message_id = ?, last_pubsub_event_time = ? WHERE host_id = ?`,
		messageID, eventTime, hostID)
	if err != nil {

@dantecatalfamo
dantecatalfamo marked this pull request as ready for review July 28, 2026 20:04
- Reconcile janitor now records dedup state after unenrolling a host missing
  from AMAPI, so a STATUS_REPORT already queued before the deletion cannot
  revert the unenroll (recovery flip-flop / duplicate mdm_unenrolled activity).
- SetAndroidHostEnrolled does a cheap enrolled-state read before opening a write
  transaction, avoiding a BEGIN/COMMIT on the primary for every STATUS_REPORT in
  the common already-enrolled case.
- WIPE-ack unenroll records dedup state only when it actually flips state, so a
  redelivered terminal wipe cannot move last_pubsub_event_time backwards.
- recordPubSubProcessed logs a failed dedup write at Warn, not Error: a NotFound
  there is a benign host-deletion race, not an alert-worthy fault.
- Note the cross-clock caveat in pubSubEventTime.
Copilot AI review requested due to automatic review settings July 28, 2026 20:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.

Files excluded by content exclusion policy (1)
  • changes/43502-android-pubsub-dedup
Comments suppressed due to low confidence (3)

server/mdm/android/service/pubsub.go:685

  • In the ENROLLMENT->DELETED branch, SetAndroidHostUnenrolled's boolean return value is ignored, but the code always emits an mdm_unenrolled activity afterwards. If the host was already unenrolled (e.g. retry/out-of-order delivery that isn't caught by messageId dedup), this can generate duplicate mdm_unenrolled activities. Consider matching the STATUS_REPORT deleted path: capture didUnenroll and return early (after recording dedup state) when no state change occurred.
			if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, host.Host.ID); err != nil {
				return ctxerr.Wrap(ctx, err, "clear byo wipe-ref on DELETED state (ENROLLMENT)")
			}

			if _, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID); err != nil {
				return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state (ENROLLMENT)")
			}

server/mdm/android/service/pubsub.go:373

  • pubSubEventTime parses timestamps using time.RFC3339, but Google Pub/Sub publishTime (and many Google APIs) commonly include fractional seconds (RFC3339 with nanos). time.Parse(time.RFC3339, ...) will fail on those values, causing eventTime to be nil (staleness check disabled) and potentially clearing last_pubsub_event_time when recording state.
func pubSubEventTime(deviceTime, publishTime string) *time.Time {
	for _, ts := range []string{deviceTime, publishTime} {
		if ts == "" {
			continue
		}
		if t, err := time.Parse(time.RFC3339, ts); err == nil {
			return &t
		}
	}

server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices_test.go:10

  • PR description's reviewer command suggests running TestUp_20260722203845, but this PR adds TestUp_20260728120000. Consider updating the PR description command so reviewers run the correct migration test.
func TestUp_20260728120000(t *testing.T) {
	db := applyUpToPrev(t)

Comment thread server/mdm/android/service/pubsub.go Outdated
Comment thread server/mdm/android/service/pubsub.go
SetAndroidPubSubDedupState overwrote last_pubsub_event_time unconditionally,
so a notification with no parseable timestamp (nil eventTime) cleared the
column to NULL and disabled staleness protection for that host until some
later message happened to carry one. Same for last_pubsub_message_id when
messageID was empty, which is how ReconcileAndroidDevices records an
out-of-band unenroll.

An empty messageID or nil eventTime now leaves that column at its previous
value, so the recorded state only ever moves forward. The RowsAffected == 0
-> NotFound check still holds on a value-preserving write because the DSN
sets clientFoundRows=true, so RowsAffected counts matched rows rather than
changed rows; the test pins that.
The ENROLLMENT DELETED branch discarded SetAndroidHostUnenrolled's return
value and emitted mdm_unenrolled unconditionally, while the STATUS_REPORT
DELETED branch returns early when the flip was a no-op. A DELETED that
arrives under both notification types — or a redelivery that messageId
dedup does not catch — therefore added a duplicate activity row.

Match the STATUS_REPORT branch: record dedup state, then skip the activity
when the host was already unenrolled.
Recording Pub/Sub dedup state on the ENROLLMENT path depended on a
getExistingHost call that ran *after* enrollment and the setup-experience
job had already succeeded. When that read failed (replica lag, a DB hiccup,
a deploy returning 5xx) the handler logged and returned nil: Pub/Sub got a
200 ACK with no dedup state written, so the redelivery re-queued the setup
experience — the exact duplicate this dedup exists to prevent.

enrollHost and addNewHost now return the Fleet host ID they resolved, so the
dedup write uses it directly and the re-fetch is gone. The STATUS_REPORT
re-enroll path still re-reads, but only because it needs the full
*fleet.AndroidHost for updateHost/updateHostSoftware, and a failure there
returns an error so Pub/Sub retries rather than silently acking.
Copilot AI review requested due to automatic review settings August 5, 2026 20:04
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.

Files excluded by content exclusion policy (1)
  • changes/43502-android-pubsub-dedup
Suppressed comments (1)

server/mdm/android/service/pubsub.go:373

  • pubSubEventTime parses timestamps using time.RFC3339, but Google Pub/Sub publishTime commonly includes fractional seconds (RFC3339Nano). If parsing fails, eventTime becomes nil and staleness protection is silently skipped, weakening the core dedup/reordering defense.
// pubSubEventTime derives the AMAPI event timestamp used for staleness comparison.
// It prefers the device's LastStatusReportTime (present on STATUS_REPORT and,
// usually, ENROLLMENT device payloads) and falls back to the Pub/Sub envelope
// publishTime. Returns nil when neither is a parseable RFC3339 timestamp, in which
// case the staleness check is skipped and only messageId dedup applies.

Comment thread server/datastore/mysql/android.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
server/mdm/android/service/reconcile_devices.go (1)

99-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that this timestamp comes from the Fleet server clock.

time.Now().UTC() writes a Fleet-server timestamp into last_pubsub_event_time. handlePubSubStatusReport compares that value against AMAPI device times and Pub/Sub publish times, which come from Google clocks. pubSubEventTime in server/mdm/android/service/pubsub.go already documents a cross-clock caveat for its two Google sources. This call adds a third clock.

The direction is safe here. Reconcile only runs for devices already absent from AMAPI, so suppressing later status reports is the wanted result. Server clock skew only widens the suppression window. Add a short note so a future reader does not assume all recorded event times share one clock.

📝 Proposed comment addition
 			// mdm_unenrolled activity on the next reconcile). Best-effort: a missed record only
 			// weakens dedup for the redelivery window, so log and continue.
+			// Note: this is the Fleet server clock, not a Google clock. It is compared against
+			// AMAPI device times and Pub/Sub publish times, so server skew widens or narrows the
+			// window in which later status reports are dropped as stale.
 			now := time.Now().UTC()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/reconcile_devices.go` around lines 99 - 102, Add a
concise inline comment immediately before the time.Now().UTC() assignment in the
reconcile flow, documenting that the timestamp uses the Fleet server clock and
may be compared with Google/AMAPI clock values. Keep the existing dedup-state
update and error handling unchanged.
server/mdm/android/service/pubsub_dedup_test.go (2)

79-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case that sets LastStatusReportTime.

makeStatusEnvelope never sets device.LastStatusReportTime. pubSubEventTime prefers that field and falls back to publishTime only when the field is empty or unparseable. Every STATUS_REPORT subtest therefore exercises the fallback branch alone.

In production the STATUS_REPORT ordering comparison normally uses the device time. Add one staleness case with LastStatusReportTime set, and set it to a value that disagrees with publishTime. That proves the device time wins.

💚 Proposed helper change
-func makeStatusEnvelope(t *testing.T, esID, messageID, publishTime string, deleted bool) *android.PubSubMessage {
+func makeStatusEnvelope(t *testing.T, esID, messageID, publishTime string, deleted bool) *android.PubSubMessage {
+	return makeStatusEnvelopeAt(t, esID, messageID, publishTime, "", deleted)
+}
+
+// makeStatusEnvelopeAt additionally sets the device's LastStatusReportTime, which
+// pubSubEventTime prefers over the envelope publishTime.
+func makeStatusEnvelopeAt(t *testing.T, esID, messageID, publishTime, deviceTime string, deleted bool) *android.PubSubMessage {
 	device := androidmanagement.Device{
 		Name: createAndroidDeviceId("dedup"),
+		LastStatusReportTime: deviceTime,
 		HardwareInfo: &androidmanagement.HardwareInfo{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub_dedup_test.go` around lines 79 - 105,
Update makeStatusEnvelope to set device.LastStatusReportTime from a helper
argument, then add a STATUS_REPORT staleness test case where that value differs
from publishTime and confirms device time is preferred. Preserve existing
fallback cases by leaving the field empty where needed.

333-384: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a subtest for the WIPE ack path that must not record dedup state.

This subtest covers the didUnenroll == true branch. handleAndroidWipeAckUnenroll in server/mdm/android/service/pubsub.go also has a !didUnenroll branch at Lines 260-272. That branch deliberately returns without calling recordPubSubProcessed, so a redelivered older wipe cannot move last_pubsub_event_time backwards.

That rule is the subtlest new behavior in this change, and no test asserts it. Add a subtest where SetAndroidHostUnenrolledFunc returns false and assert that SetAndroidPubSubDedupStateFuncInvoked stays false.

💚 Proposed subtest
t.Run("WIPE ack on an already-unenrolled host records no dedup state", func(t *testing.T) {
	// A redelivered wipe must not move last_pubsub_event_time backwards when the
	// state flip was a no-op.
	svc, mockDS := createAndroidService(t)
	mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
		return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil
	}
	stored := &android.MDMAndroidCommand{
		CommandUUID:   "cmd-wipe",
		HostUUID:      hostUUID,
		OperationName: "enterprises/E/devices/D/operations/wipe-ack",
		CommandType:   string(android.MDMAndroidCommandTypeWipe),
		Status:        string(android.MDMAndroidCommandStatusPending),
	}
	mockDS.GetMDMAndroidCommandByOperationNameFunc = func(ctx context.Context, opName string) (*android.MDMAndroidCommand, error) {
		return stored, nil
	}
	mockDS.UpdateMDMAndroidCommandStatusFunc = func(ctx context.Context, commandUUID, status string, errorCode, errorMessage *string) error {
		return nil
	}
	mockDS.AndroidHostLiteByHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.AndroidHost, error) {
		return &fleet.AndroidHost{Host: &fleet.Host{ID: hostID, UUID: hUUID}}, nil
	}
	mockDS.GetHostMDMFunc = func(ctx context.Context, id uint) (*fleet.HostMDM, error) {
		return &fleet.HostMDM{IsPersonalEnrollment: false}, nil
	}
	mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, id uint) error { return nil }
	mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) {
		return false, nil // already unenrolled
	}
	mockDS.SetAndroidPubSubDedupStateFunc = func(ctx context.Context, id uint, messageID string, eventTime *time.Time) error {
		return nil
	}

	body, err := json.Marshal(androidmanagement.Operation{Name: stored.OperationName, Done: true})
	require.NoError(t, err)
	msg := &android.PubSubMessage{
		Attributes:  map[string]string{"notificationType": string(android.PubSubCommand)},
		Data:        base64.StdEncoding.EncodeToString(body),
		MessageID:   "wipe-msg-redelivered",
		PublishTime: "2026-07-22T12:00:00Z",
	}
	require.NoError(t, svc.ProcessPubSubPush(t.Context(), dedupToken, msg))

	require.True(t, mockDS.SetAndroidHostUnenrolledFuncInvoked, "the unenroll must still be attempted")
	require.False(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked,
		"a no-op wipe ack must not move the recorded event time backwards")
	require.False(t, mockDS.ListHostsLiteByIDsFuncInvoked, "no duplicate mdm_unenrolled activity")
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub_dedup_test.go` around lines 333 - 384, Add
a subtest alongside the existing WIPE acknowledgement test covering the
already-unenrolled path in handleAndroidWipeAckUnenroll: configure
SetAndroidHostUnenrolledFunc to return false, process a completed WIPE message,
and assert the unenrollment is attempted while SetAndroidPubSubDedupStateFunc is
not invoked (and no duplicate unenrollment activity is created).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices_test.go`:
- Around line 35-47: Update the round-trip assertions in the Android devices
migration test to use a timestamp with nonzero microseconds, then assert that
last_pubsub_event_time equals the exact stored timestamp including its
fractional precision instead of only checking it is non-NULL. Keep the existing
last_pubsub_message_id verification unchanged.

In
`@server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices.go`:
- Around line 25-26: Implement Down_20260728120000 to remove both columns added
by the corresponding up migration, executing the required DROP COLUMN statements
through the provided transaction and returning any errors. Ensure the downgrade
fully reverses the schema changes so reapplying the migration does not encounter
existing columns.

---

Nitpick comments:
In `@server/mdm/android/service/pubsub_dedup_test.go`:
- Around line 79-105: Update makeStatusEnvelope to set
device.LastStatusReportTime from a helper argument, then add a STATUS_REPORT
staleness test case where that value differs from publishTime and confirms
device time is preferred. Preserve existing fallback cases by leaving the field
empty where needed.
- Around line 333-384: Add a subtest alongside the existing WIPE acknowledgement
test covering the already-unenrolled path in handleAndroidWipeAckUnenroll:
configure SetAndroidHostUnenrolledFunc to return false, process a completed WIPE
message, and assert the unenrollment is attempted while
SetAndroidPubSubDedupStateFunc is not invoked (and no duplicate unenrollment
activity is created).

In `@server/mdm/android/service/reconcile_devices.go`:
- Around line 99-102: Add a concise inline comment immediately before the
time.Now().UTC() assignment in the reconcile flow, documenting that the
timestamp uses the Fleet server clock and may be compared with Google/AMAPI
clock values. Keep the existing dedup-state update and error handling unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5adb6ce8-f228-410a-ba66-2f9130825c71

📥 Commits

Reviewing files that changed from the base of the PR and between 0fd5739 and abbe08e.

📒 Files selected for processing (14)
  • changes/43502-android-pubsub-dedup
  • server/datastore/mysql/android.go
  • server/datastore/mysql/android_test.go
  • server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices.go
  • server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices_test.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/vpp.go
  • server/fleet/datastore.go
  • server/mdm/android/pubsub.go
  • server/mdm/android/service/enterprises_test.go
  • server/mdm/android/service/pubsub.go
  • server/mdm/android/service/pubsub_dedup_test.go
  • server/mdm/android/service/reconcile_devices.go
  • server/mock/datastore_mock.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • server/datastore/mysql/vpp.go
  • server/mdm/android/service/enterprises_test.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/android_test.go
  • server/mdm/android/pubsub.go
  • server/fleet/datastore.go
  • changes/43502-android-pubsub-dedup
  • server/mock/datastore_mock.go
  • server/datastore/mysql/android.go

Comment on lines +35 to +47
// The columns are writable and round-trip.
_, err = db.Exec(`UPDATE android_devices
SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.000000'
WHERE device_id = 'd1'`)
require.NoError(t, err)

require.NoError(t, db.Get(&messageID, `SELECT last_pubsub_message_id FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, messageID)
require.Equal(t, "msg-123", *messageID)

require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, eventTime)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the event-time value and microsecond precision.

The test only checks that last_pubsub_event_time is non-NULL. A column or write path that truncates microseconds would still pass. Store a nonzero fractional value and assert the exact returned value.

Proposed fix
-		SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.000000'
+		SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.123456'
@@
 	require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`))
 	require.NotNil(t, eventTime)
+	require.Equal(t, "2026-07-22 10:00:00.123456", *eventTime)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The columns are writable and round-trip.
_, err = db.Exec(`UPDATE android_devices
SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.000000'
WHERE device_id = 'd1'`)
require.NoError(t, err)
require.NoError(t, db.Get(&messageID, `SELECT last_pubsub_message_id FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, messageID)
require.Equal(t, "msg-123", *messageID)
require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, eventTime)
}
// The columns are writable and round-trip.
_, err = db.Exec(`UPDATE android_devices
SET last_pubsub_message_id = 'msg-123', last_pubsub_event_time = '2026-07-22 10:00:00.123456'
WHERE device_id = 'd1'`)
require.NoError(t, err)
require.NoError(t, db.Get(&messageID, `SELECT last_pubsub_message_id FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, messageID)
require.Equal(t, "msg-123", *messageID)
require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`))
require.NotNil(t, eventTime)
require.Equal(t, "2026-07-22 10:00:00.123456", *eventTime)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices_test.go`
around lines 35 - 47, Update the round-trip assertions in the Android devices
migration test to use a timestamp with nonzero microseconds, then assert that
last_pubsub_event_time equals the exact stored timestamp including its
fractional precision instead of only checking it is non-NULL. Keep the existing
last_pubsub_message_id verification unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pedantic. But a fairly easy fix.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 5, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.

Files excluded by content exclusion policy (1)
  • changes/43502-android-pubsub-dedup
Suppressed comments (3)

server/datastore/mysql/migrations/tables/20260728120000_AddPubSubDedupToAndroidDevices_test.go:33

  • This migration test scans last_pubsub_event_time (TIMESTAMP(6)) into *string, which is likely to fail with parseTime enabled. Use *time.Time so the scan type matches the column type.
	var eventTime *string
	require.NoError(t, db.Get(&eventTime, `SELECT last_pubsub_event_time FROM android_devices WHERE device_id = 'd1'`))
	require.Nil(t, eventTime)

server/mdm/android/service/pubsub.go:748

  • The ENROLLMENT handler forces the primary for the pre-dedup host lookup, but then calls enrollHost with the original context. If the host was created by a prior delivery seconds earlier, replica lag can make enrollHost's internal AndroidHostLite lookup miss the host and attempt a duplicate insert (likely hitting hosts.uuid uniqueness) and trigger retries/duplicate work. Use the same primary-required context for the enrollHost call (and ideally reuse it for the pre-check too).
	// Force the primary so a redelivered ENROLLMENT sees a host that a prior delivery just
	// created (and thus its recorded dedup state), instead of missing it on a lagging replica
	// and re-queuing the setup experience.
	existing, herr := svc.getExistingHost(ctxdb.RequirePrimary(ctx, true), &device)
	if herr != nil {
		return ctxerr.Wrap(ctx, herr, "getting existing Android host for enrollment dedup")

server/mdm/android/service/pubsub.go:373

  • pubSubEventTime parses timestamps using time.RFC3339, but Google Pub/Sub publishTime and AMAPI timestamps commonly include fractional seconds. With RFC3339, those values won't parse and staleness protection will be silently skipped. Use RFC3339Nano (it also accepts non-fractional RFC3339).
func pubSubEventTime(deviceTime, publishTime string) *time.Time {
	for _, ts := range []string{deviceTime, publishTime} {
		if ts == "" {
			continue
		}
		if t, err := time.Parse(time.RFC3339, ts); err == nil {
			return &t
		}

Comment on lines +3 to +7
import (
"testing"

"github.com/stretchr/testify/require"
)
ksykulev
ksykulev previously approved these changes Aug 6, 2026
…dedup

# Conflicts:
#	server/datastore/mysql/schema.sql
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/mdm/android/service/pubsub_dedup_test.go (1)

126-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Verify that every skipped notification does not overwrite deduplication state.

These stale and duplicate cases verify selected processing side effects. They do not verify that SetAndroidPubSubDedupState was not called. A regression could overwrite the stored message ID on a skipped delivery and still pass these tests.

Proposed test assertions
 require.False(t, mockDS.UpdateAndroidHostFuncInvoked, "stale enrollment must not re-run updateHost")
 require.False(t, mockDS.NewJobFuncInvoked, "stale enrollment must not re-queue setup experience")
+require.False(t, mockDS.SetAndroidPubSubDedupStateFuncInvoked, "stale enrollment must not overwrite dedup state")

Add the equivalent assertion to the duplicate STATUS_REPORT, stale DELETED, and stale STATUS_REPORT cases.

Also applies to: 248-260, 262-276, 299-315

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub_dedup_test.go` around lines 126 - 140,
Extend the skipped-notification tests to verify deduplication state is
unchanged: in the stale ENROLLMENT case and the duplicate STATUS_REPORT, stale
DELETED, and stale STATUS_REPORT cases, assert that SetAndroidPubSubDedupState
was not invoked. Keep the existing processing-side-effect assertions intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/mdm/android/service/pubsub_dedup_test.go`:
- Around line 126-140: Extend the skipped-notification tests to verify
deduplication state is unchanged: in the stale ENROLLMENT case and the duplicate
STATUS_REPORT, stale DELETED, and stale STATUS_REPORT cases, assert that
SetAndroidPubSubDedupState was not invoked. Keep the existing
processing-side-effect assertions intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cf18ece6-6adc-431f-92ae-d386289250f2

📥 Commits

Reviewing files that changed from the base of the PR and between 1e23b10 and 2ff66d9.

📒 Files selected for processing (14)
  • changes/43502-android-pubsub-dedup
  • server/datastore/mysql/android.go
  • server/datastore/mysql/android_test.go
  • server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices.go
  • server/datastore/mysql/migrations/tables/20260806210232_AddPubSubDedupToAndroidDevices_test.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/vpp.go
  • server/fleet/datastore.go
  • server/mdm/android/pubsub.go
  • server/mdm/android/service/enterprises_test.go
  • server/mdm/android/service/pubsub.go
  • server/mdm/android/service/pubsub_dedup_test.go
  • server/mdm/android/service/reconcile_devices.go
  • server/mock/datastore_mock.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • server/mdm/android/service/enterprises_test.go
  • server/datastore/mysql/vpp.go
  • server/mdm/android/pubsub.go
  • server/datastore/mysql/android.go
  • changes/43502-android-pubsub-dedup
  • server/datastore/mysql/android_test.go
  • server/datastore/mysql/schema.sql
  • server/fleet/datastore.go
  • server/mdm/android/service/reconcile_devices.go
  • server/mock/datastore_mock.go
  • server/mdm/android/service/pubsub.go

@dantecatalfamo
dantecatalfamo merged commit 3c8df41 into main Aug 6, 2026
6 checks passed
@dantecatalfamo
dantecatalfamo deleted the 43502-android-pubsub-dedup branch August 6, 2026 21:24
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.

Android MDM PubSub handler has no protection against duplicate or out-of-order deliveries

4 participants