-
Notifications
You must be signed in to change notification settings - Fork 2
fix(speakers): retry a killed bulk-send chunk once and resume without re-emailing #598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a269e55
c7cf75e
cab6991
01d8093
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| <?php namespace App\Jobs\Emails\Traits; | ||
| /** | ||
| * Copyright 2026 OpenStack Foundation | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| **/ | ||
| use Illuminate\Support\Facades\Log; | ||
|
|
||
| /** | ||
| * Trait ResumableChunkJob | ||
| * | ||
| * Shared retry/resume mechanics for a bulk-email chunk job dispatched by a *Service::sendEmails | ||
| * chunk loop (ProcessSpeakersEmailRequestJob / SpeakerService::triggerSendEmails is the first | ||
| * caller). Bounds the job's own runtime strictly below every queue connection's retry_after so a | ||
| * retried attempt can never run concurrently with a still-live earlier attempt - timeout = 0 | ||
| * would NOT mean "no job-level bound, use the worker's": Laravel writes the job's $timeout | ||
| * property into the queue payload, Worker::timeoutForJob prefers it over the worker option | ||
| * because 0 is not null, and registerTimeoutHandler then calls pcntl_alarm(0), which cancels the | ||
| * alarm outright. Without an explicit bound below retry_after, a hung (not killed, just slow) | ||
| * chunk could outlive retry_after and be re-served to a second worker while the first is still | ||
| * running it - true concurrent execution of the same chunk, which the resume check below cannot | ||
| * protect against. Retries once with a backoff so a deterministic failure does not retry | ||
| * instantly, and on that retry exposes resume_since = dispatched_at in the payload so the | ||
| * receiving *Service::sendEmails can skip only the recipients this run already reached. Every | ||
| * other payload key - notably should_resend - is left exactly as it arrived: the resume check and | ||
| * should_resend answer different questions (did THIS run already reach this recipient, vs. does | ||
| * the operator want to re-send to anyone with a proof from any past campaign) and stack rather | ||
| * than replace one another. | ||
| * | ||
| * Composing job must: | ||
| * - implement Illuminate\Contracts\Queue\ShouldQueue and use | ||
| * Illuminate\Queue\InteractsWithQueue (this trait's attempts() call comes from there); | ||
| * - hold the dispatch payload in a $payload array property, stamped with a dispatched_at key | ||
| * (UTC epoch, once per run) by the *Service::triggerSendEmails chunk loop that dispatches it; | ||
| * - call activateResumeIfRetrying() at the top of handle(), before handing $this->payload to the | ||
| * receiving service. | ||
| * | ||
| * A chunk with no dispatched_at (queued by a pod running a version of the composing job from | ||
| * before this trait existed) is left untouched on a retry - it retries as a full re-run rather | ||
| * than an incorrect resume computed from a missing timestamp. That is a bounded, accepted risk | ||
| * (one duplicate chunk) limited to a single rolling-deploy window. | ||
| */ | ||
| trait ResumableChunkJob | ||
| { | ||
| // 1200s: strictly below every queue connection's retry_after (1800 on both redis and | ||
| // database, config/queue.php) and the database-fallback worker's --timeout=1400 | ||
| // (fn-docker/summit-api/php-entry-point.sh). Composing jobs sharing this trait must not | ||
| // override this to a value at or above the lowest retry_after / worker --timeout they run | ||
| // under, or the concurrency guarantee above no longer holds. | ||
| public $timeout = 1200; | ||
|
|
||
| public $tries = 2; | ||
|
|
||
| public $backoff = 300; | ||
|
|
||
| /** | ||
| * Mutates $this->payload in place: on a retry (attempts() > 1) with a dispatched_at already | ||
| * stamped, sets resume_since = dispatched_at and logs a warning. First attempt, or a chunk | ||
| * with no dispatched_at, leaves the payload untouched. | ||
| */ | ||
| private function activateResumeIfRetrying(): void | ||
| { | ||
| if ($this->attempts() <= 1 || !isset($this->payload['dispatched_at'])) { | ||
| return; | ||
| } | ||
|
|
||
| $this->payload['resume_since'] = $this->payload['dispatched_at']; | ||
|
|
||
| Log::warning | ||
| ( | ||
| sprintf | ||
| ( | ||
| "%s::handle attempt %s: resuming chunk from dispatched_at %s", | ||
| static::class, | ||
| $this->attempts(), | ||
| date('c', $this->payload['dispatched_at']) | ||
| ) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1288,11 +1288,19 @@ public function triggerSendEmails(Summit $summit, array $payload, $filter = null | |
| // deduplicated). withDbFallback() fails over to the database queue (and runs sync on a | ||
| // double failure) so the loop completes. Same pattern as | ||
| // PresentationSubmissionReopenService::notify's per-recipient dispatch loop. | ||
| // Stamped once per run (not per chunk) so every chunk's resume check, if the chunk is | ||
| // ever retried, compares against the same instant this run started. | ||
| $dispatched_at = time(); | ||
| $chunk_nbr = 1; | ||
| foreach (array_chunk($ids, $process_jon_chunk_size) as $chunk) { | ||
| $chunkPayload = $payload; | ||
| $chunkPayload['speaker_ids'] = $chunk; | ||
| $chunkPayload['dispatched_at'] = $dispatched_at; | ||
| unset($chunkPayload['excluded_speaker_ids']); | ||
| // resume_since is set only by ProcessSpeakersEmailRequestJob::handle() on a retry - | ||
| // getJsonPayload() returns the raw request body and no validation rule declares this | ||
| // key, so a caller-supplied value must never reach a first-attempt chunk. | ||
| unset($chunkPayload['resume_since']); | ||
|
|
||
| Log::debug | ||
| ( | ||
|
|
@@ -1387,6 +1395,36 @@ function | |
| throw new EntityNotFoundException('Speaker not found'); | ||
| } | ||
|
|
||
| // Resume, not resend: set only by ProcessSpeakersEmailRequestJob::handle() | ||
| // on a retry (attempts() > 1). A speaker whose proof for this email type | ||
| // was written at or after resume_since was already reached by this run | ||
| // before the kill - skip before any side effect (promo code, assistance) | ||
| // so a retry never leaves an orphan promo code for a speaker it must not | ||
| // re-email. should_resend is independent and evaluated later, unchanged, | ||
| // inside SpeakerActionsEmailStrategy::process(). | ||
| $resume_since = $payload['resume_since'] ?? null; | ||
| if (!is_null($resume_since)) { | ||
| $announcement_type = $email_strategy->getAnnouncementType(); | ||
| if | ||
| ( | ||
| !is_null($announcement_type) && | ||
| $speaker->hasAnnouncementEmailTypeSentSince($summit, $announcement_type, new \DateTime('@' . $resume_since)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Dispatch selection emails after the proof transaction commits
🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed: Moving to an afterCommit/outbox pattern would touch the same dispatch path shared by all ten services on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
An ✏️ Learnings added
🧠 Learnings usedYou are interacting with an AI system. |
||
| ) { | ||
| if (!is_null($onDispatchInfo)) { | ||
| $onDispatchInfo | ||
| ( | ||
| sprintf | ||
| ( | ||
| "Speaker %s (%s) already processed by this run before the retry, skipped.", | ||
| $speaker->getEmail(), | ||
| $speaker->getId() | ||
| ) | ||
| ); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // try to get or auto-build a promo code | ||
|
|
||
| $promo_code_strategy = $this->promo_code_strategy_factory->createStrategy($summit, $payload); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.