π₯ Actual behavior
When host_expiry_settings.host_expiry_enabled = true (any value of host_expiry_window), Android hosts that have re-enrolled β after their previous host record was deleted by an admin, by host-expiry, or by any other path β get immediately auto-deleted again at the next cleanups_then_aggregation cron tick. This loops every hour for as long as host expiry is enabled and the device keeps trying to talk to Fleet.
Sentinel mismatch:
EnrollOrbit (server/datastore/mysql/hosts.go) initializes a new host's detail_updated_at, label_updated_at, policy_updated_at, and last_enrolled_at to:
zeroTime := time.Unix(0, 0).Add(24 * time.Hour) // = 1970-01-02 00:00:00 UTC
CleanupExpiredHosts (server/datastore/mysql/hosts.go) nullifies the other sentinel:
COALESCE(GREATEST(COALESCE(hst.seen_time, ne.last_seen_at), COALESCE(ne.last_seen_at, hst.seen_time)),
NULLIF(h.detail_updated_at, '2000-01-01 00:00:00'),
h.created_at) < DATE_SUB(NOW(), INTERVAL ? DAY)
where '2000-01-01 00:00:00' is server.NeverTimestamp.
Because 1970-01-02 != 2000-01-01, NULLIF returns the literal 1970-01-02, the COALESCE chain treats the host as "last seen on 1970-01-02," and the comparison 1970-01-02 < NOW - any_window_DAYS is always true. The host is deleted on the next cron tick.
Why first-time Android enrollment is normally safe: the very first Google AMAPI ENROLLMENT pubsub event triggers addNewHost (server/mdm/android/service/pubsub.go) which calls NewAndroidHost β setTimesToNonZero (server/datastore/mysql/android.go). That sets host.DetailUpdatedAt = 2000-01-01, and the subsequent UPDATE (the orbit-enrolled-host-already-exists branch in NewAndroidHost) writes 2000-01-01 over the 1970-01-02 value the orbit-enroll path had just inserted. After that update, the cleanup SQL's NULLIF(..., '2000-01-01 00:00:00') recognizes the sentinel correctly, the COALESCE falls through to h.created_at, and the host survives.
Why re-enrollment after host record deletion is NOT safe: when the Fleet host record is deleted (any cause), Google AMAPI does not get notified and does not send a new ENROLLMENT pubsub event for the same device. From Google's perspective the device is still enrolled. The agent's next periodic check-in hits the orbit-enroll endpoint, EnrollOrbit inserts a fresh hosts row with detail_updated_at = 1970-01-02, and nothing overwrites that value. The cleanup cron deletes it again at the next hourly tick. The next orbit check-in creates another fresh row with 1970-01-02. The cycle repeats every hour indefinitely.
Why osquery / fleetd desktop hosts mostly don't hit this even on re-enrollment: their host_seen_times.seen_time is populated on the first osquery check-in (within seconds). The COALESCE reads hst.seen_time first, so detail_updated_at = 1970-01-02 is never consulted.
Why Android hosts hit it specifically: Android devices do not insert into host_seen_times (that table is osquery-only, per the comment at hosts.go). Android hosts rely entirely on detail_updated_at being refreshed by Google AMAPI pubsub events. On re-enrollment, no such event is generated.
π οΈ Expected behavior
CleanupExpiredHosts should treat both sentinels as "no real timestamp." Two equally valid fixes:
- Update the cleanup SQL to nullify both sentinels (smallest, safest change, also tolerates any legacy rows that may exist in production):
COALESCE(
GREATEST(COALESCE(hst.seen_time, ne.last_seen_at), COALESCE(ne.last_seen_at, hst.seen_time)),
NULLIF(NULLIF(h.detail_updated_at, '2000-01-01 00:00:00'), '1970-01-02 00:00:00'),
h.created_at
) < DATE_SUB(NOW(), INTERVAL ? DAY)
- Make
EnrollOrbit use server.NeverTimestamp ('2000-01-01 00:00:00') at line 2436 of hosts.go. This also requires a data migration to update existing rows from 1970-01-02 to 2000-01-01.
Option 1 is sufficient and lower risk. Both together is ideal.
π§βπ» Steps to reproduce
These steps:
π―οΈ More info (optional)
π₯ Actual behavior
When
host_expiry_settings.host_expiry_enabled = true(any value ofhost_expiry_window), Android hosts that have re-enrolled β after their previous host record was deleted by an admin, by host-expiry, or by any other path β get immediately auto-deleted again at the nextcleanups_then_aggregationcron tick. This loops every hour for as long as host expiry is enabled and the device keeps trying to talk to Fleet.Sentinel mismatch:
EnrollOrbit(server/datastore/mysql/hosts.go) initializes a new host'sdetail_updated_at,label_updated_at,policy_updated_at, andlast_enrolled_atto:CleanupExpiredHosts(server/datastore/mysql/hosts.go) nullifies the other sentinel:'2000-01-01 00:00:00'isserver.NeverTimestamp.Because
1970-01-02 != 2000-01-01,NULLIFreturns the literal1970-01-02, the COALESCE chain treats the host as "last seen on 1970-01-02," and the comparison1970-01-02 < NOW - any_window_DAYSis always true. The host is deleted on the next cron tick.Why first-time Android enrollment is normally safe: the very first Google AMAPI ENROLLMENT pubsub event triggers
addNewHost(server/mdm/android/service/pubsub.go) which callsNewAndroidHostβsetTimesToNonZero(server/datastore/mysql/android.go). That setshost.DetailUpdatedAt = 2000-01-01, and the subsequent UPDATE (the orbit-enrolled-host-already-exists branch inNewAndroidHost) writes2000-01-01over the1970-01-02value the orbit-enroll path had just inserted. After that update, the cleanup SQL'sNULLIF(..., '2000-01-01 00:00:00')recognizes the sentinel correctly, the COALESCE falls through toh.created_at, and the host survives.Why re-enrollment after host record deletion is NOT safe: when the Fleet host record is deleted (any cause), Google AMAPI does not get notified and does not send a new ENROLLMENT pubsub event for the same device. From Google's perspective the device is still enrolled. The agent's next periodic check-in hits the orbit-enroll endpoint,
EnrollOrbitinserts a fresh hosts row withdetail_updated_at = 1970-01-02, and nothing overwrites that value. The cleanup cron deletes it again at the next hourly tick. The next orbit check-in creates another fresh row with1970-01-02. The cycle repeats every hour indefinitely.Why osquery / fleetd desktop hosts mostly don't hit this even on re-enrollment: their
host_seen_times.seen_timeis populated on the first osquery check-in (within seconds). The COALESCE readshst.seen_timefirst, sodetail_updated_at = 1970-01-02is never consulted.Why Android hosts hit it specifically: Android devices do not insert into
host_seen_times(that table is osquery-only, per the comment athosts.go). Android hosts rely entirely ondetail_updated_atbeing refreshed by Google AMAPI pubsub events. On re-enrollment, no such event is generated.π οΈ Expected behavior
CleanupExpiredHostsshould treat both sentinels as "no real timestamp." Two equally valid fixes:EnrollOrbituseserver.NeverTimestamp('2000-01-01 00:00:00') at line 2436 ofhosts.go. This also requires a data migration to update existing rows from1970-01-02to2000-01-01.Option 1 is sufficient and lower risk. Both together is ideal.
π§βπ» Steps to reproduce
These steps:
Have been confirmed to consistently lead to reproduction in multiple Fleet instances. Three consecutive auto-deletion / re-enrollment cycles observed at the same hourly cadence on the dev server.
π―οΈ More info (optional)