Follow-up to #2550 which added a created_at index and increased cleanup
frequency. The delete itself was still unbatched and vulnerable whenever
a backlog accumulates (first run after deploy, clock skew, etc).
Delete in small batches with a pause between each to let other
transactions through, following the SolidQueue pattern.
When concurrent NotifyRecipientsJobs process an Event and a Mention for
the same user+card, Rails' dirty tracking can skip writing source_type
in the UPDATE if it hasn't changed from the stale in-memory value,
leaving source_type and source_id mismatched (e.g. source_type='Event'
with a Mention's source_id, resulting in a nil source).
Force source_type to always be included in the UPDATE via
source_type_will_change! to prevent this.
Here's a sample timeline of this race condition happening in the real
world (with simplified IDs):
Two `NotifyRecipientsJob` involving notification `03fklpu`, same card,
same user, same comment — enqueued within 50ms of each other:
1. Both jobs load notification `03fklpu` — it has source_type='Mention'
(from a previous job)
2. `EventNotifier` writes at 21:22:36.051: ```sql SET
source_type='Event', source_id=<event_id>, unread_count=1 ```
3. `source_type` included because it changed ('Mention' → 'Event')
4. `MentionNotifier` writes at 21:22:36.057 (~6ms later): ```sql SET
source_id=<mention_id>, unread_count=1 ```
5. No `source_type`! It was `'Mention'` when loaded and `'Mention'` is
what it's setting → not dirty → skipped
6. Final DB state: `source_type='Event'` (from step 2, untouched),
`source_id=<mention_id>` (from step 3)
`Notification.source` now does `Event.find(<mention_id>) → nil.`
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The convert_gids_to_sgids method resolved GIDs globally and minted
valid SGIDs without verifying the record belongs to the importing
account. This adds the same account_id check that the export path
already has, plus RecordNotFound handling for invalid GIDs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
We had client-side notification stacking in the tray since launch, but now we want to stack notifications in the notifications page, in API responses and in email bundles.
ActionTextRichTextRecordSet now calls check_associations_dont_exist to
ensure imported ActionText records only reference records within the
same import, matching the behavior of the parent RecordSet class.
ZipKit creates a placeholder record when add_file is called, this record is then filled with the content that's passxed to write. But if the blob's file doesn't exist then an error is rased and the placeholder is never filled. This results in a ZIP that has a place in its catalog for the blob, but no contant for it, which makes it invalid.
Without this exporting, deleting and then re-imporing an account would reuse the old cache entries since the IDs are the same, but those cache entries will likely have the old account ID and would break
The old implementation loaded files into memory to provide an IO interface. This has the obvious downside of loading any file included in the import, e.g. a 10GB video file, into memory. ZipKit has no native IO object for reading but it provides all the necessary methods to implement one.