Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2bc1e3b
fix: grant sponsor group before eager Sponsor_Users creation in user …
smarcet Aug 10, 2026
a44f9cd
fix: propagate removeSponsorUser failures to MQ job retry machinery
smarcet Aug 10, 2026
c2c0e35
feat: register member on demand from IDP in sponsor user sync
smarcet Aug 10, 2026
a2e9227
fix: resolve member outside the sponsor group sync transaction
smarcet Aug 10, 2026
67fce36
fix: scope removeSponsorUser membership loop to the event summit
smarcet Aug 10, 2026
9b49d4a
fix: do not provision members from the IDP on revocation events
smarcet Aug 10, 2026
8dbc1d2
fix: refresh stale member groups from the IDP and make MQ retries real
smarcet Aug 10, 2026
0330462
test: clean up the on-demand member in a finally
smarcet Aug 10, 2026
690f895
fix: correct removeSponsorUser log message
smarcet Aug 10, 2026
9e6fc43
fix: make MQ retries actually redeliver by dead-lettering through the…
smarcet Aug 10, 2026
76d8d83
fix: treat removal events for an already-deleted sponsor as a no-op
smarcet Aug 10, 2026
a4b0b4e
fix: refresh stale sponsor groups additively instead of via full IDP …
smarcet Aug 10, 2026
6176956
fix: restrict sponsor group sync to Sponsor::AllowedMemberGroups
smarcet Aug 10, 2026
7f4d0a5
fix: publish retries directly to the delay queue instead of via laterRaw
smarcet Aug 10, 2026
c43ce97
fix: validate sponsor/summit ownership on group events
smarcet Aug 10, 2026
4a0f4a6
fix(tests): stop fixture teardown from leaking rows after an EM reset
smarcet Aug 10, 2026
a48e6e8
fix: overwrite any smuggled x_event_type with the resolved event type…
smarcet Aug 10, 2026
30d02ea
fix(tests): refresh cleanup repositories unconditionally
smarcet Aug 10, 2026
491a885
fix(tests): rethrow fixture cleanup failures instead of swallowing them
smarcet Aug 10, 2026
2cbc1da
fix(tests): detach the unit of work before fixture cleanup flushes
smarcet Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/Jobs/SponsorServices/RemoveSponsorMemberMQJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ public function __construct(ISponsorUserSyncService $service)
public function handle(SponsorServicesMQJob $job): void
{
try {
$event_type = $job->getRabbitMQMessage()->getRoutingKey();
// getEventType, not the raw routing key: a redelivered (released)
// message carries the queue name as routing key.
$event_type = $job->getEventType();
$payload = $job->payload();
$json = json_encode($payload);
Log::debug("RemoveSponsorMemberMQJob::handle payload {$json}");
Expand Down
132 changes: 130 additions & 2 deletions app/Jobs/SponsorServices/SponsorServicesMQJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,74 @@
**/

use Illuminate\Support\Facades\Log;
use PhpAmqpLib\Message\AMQPMessage;
use PhpAmqpLib\Wire\AMQPTable;
use VladimirYuldashev\LaravelQueueRabbitMQ\Queue\Jobs\RabbitMQJob as BaseJob;

class SponsorServicesMQJob extends BaseJob
{
public int $tries = 3;

/**
* Seconds to wait before the 2nd and 3rd attempt, as the comma-separated
* string Worker::calculateBackoff() explodes.
*
* These events race the IDP's own user-updated event, which is what refreshes
* a member's groups locally. Retrying immediately loses that race every time;
* for auth_user_added_to_sponsor_and_summit that is terminal, because no
* companion group event follows to repair the state.
*/
const RetryBackoff = '30,120';

/**
* Body key that carries the original event type across a release(): a
* released message is dead-lettered back through the default exchange, so
* it is redelivered with the QUEUE NAME as its routing key and the event
* type would otherwise be lost (see release()).
*/
const EventTypeKey = 'x_event_type';

private const KnownEventTypes = [
EventTypes::AUTH_USER_ADDED_TO_GROUP,
EventTypes::AUTH_USER_REMOVED_FROM_GROUP,
EventTypes::AUTH_USER_ADDED_TO_SPONSOR_AND_SUMMIT,
EventTypes::AUTH_USER_REMOVED_FROM_SPONSOR_AND_SUMMIT,
EventTypes::AUTH_USER_REMOVED_FROM_SUMMIT,
];

/**
* The event type driving handler selection. On a first delivery it is the
* message's routing key; on a redelivery after release() the routing key is
* the queue name and the original event type travels in the body. Handlers
* that branch on the event type must use this, never the raw routing key.
*
* @return string
*/
public function getEventType(): string
{
$routing_key = $this->getRabbitMQMessage()->getRoutingKey();
if (in_array($routing_key, self::KnownEventTypes, true)) {
return $routing_key;
}

$body = json_decode($this->getRawBody(), true);
return $body[self::EventTypeKey] ?? $routing_key;
}

/**
* Get the decoded body of the job.
*
* Note the maxTries/backoff keys: Job::maxTries() and Job::backoff() read the
* PAYLOAD, not the properties of the handler class this payload names. Without
* them the worker falls back to the command's own options, and the entry point
* runs `doctrine:queue:work sponsor_users_sync_consumer` with no flags - so the
* defaults of --tries=1 and --backoff=0 apply and a single failure is final.
*
* @return array
*/
public function payload(): array
{
$routing_key = $this->getRabbitMQMessage()->getRoutingKey();
$routing_key = $this->getEventType();

switch ($routing_key) {
case EventTypes::AUTH_USER_ADDED_TO_GROUP:
Expand All @@ -47,7 +101,81 @@ public function payload(): array
}
return [
'job' => $job,
'data' => json_decode($this->getRawBody(), true)
'data' => json_decode($this->getRawBody(), true),
'maxTries' => $this->tries,
'backoff' => self::RetryBackoff,
];
}

/**
* Release the job back into the queue for a retry.
*
* The library implementation publishes into a delay queue whose dead-letter
* exchange is the consumer exchange (sponsor-users-api-message-broker) and
* whose dead-letter routing key is this queue's NAME. That exchange is
* direct and only binds the five auth_user_* routing keys, so the expired
* retry is unroutable and RabbitMQ silently drops it - the retry policy
* would lose every failed event instead of retrying it.
*
* Dead-letter through the DEFAULT exchange instead: it routes by queue name
* with no binding required. Redelivery rewrites the routing key to the queue
* name, so the original event type is preserved in the body (EventTypeKey)
* for getEventType() to recover.
*
* @param int $delay
*/
public function release($delay = 0): void
{
$this->released = true;

$ttl = $this->secondsUntil($delay) * 1000;
if ($ttl <= 0) {
// Never skip the delay queue: publishing straight to the consumer
// exchange with the queue name as routing key is unroutable (above).
$ttl = 1000;
}
$delay_queue = $this->queue . '.delay.' . $ttl;

// Declare + publish DIRECTLY on the channel, bypassing laterRaw():
// - laterRaw() re-declares the delay queue with its own dead-letter
// arguments, which the broker rejects (PRECONDITION_FAILED,
// inequivalent args) once the queue exists with ours;
// - suppressing that re-declare by priming the declared-names cache
// would mean the queue is declared only once per worker process,
// while x-expires DELETES it when idle - every later release would
// then publish into a deleted queue and be dropped silently.
// An unconditional queue_declare per release re-creates the queue when
// it expired and is a no-op (equivalent args) when it did not.
$channel = $this->rabbitmq->getChannel();

$channel->queue_declare($delay_queue, false, true, false, false, false, new AMQPTable([
'x-dead-letter-exchange' => '',
'x-dead-letter-routing-key' => $this->queue,
'x-message-ttl' => $ttl,
'x-expires' => $ttl * 2,
]));

// Preserve the original event type across the redelivery. ALWAYS write
// the resolved value: on a first delivery the routing key is
// authoritative, so this overwrites any forged/buggy x_event_type the
// producer body may have carried; on a redelivery getEventType() already
// resolves from the body, so rewriting it is a no-op (idempotent).
$body = json_decode($this->getRawBody(), true) ?? [];
$body[self::EventTypeKey] = $this->getEventType();

$channel->basic_publish(
new AMQPMessage(json_encode($body), [
'content_type' => 'application/json',
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
'correlation_id' => uniqid('', true),
// attempts() reads this header on the next delivery.
'application_headers' => new AMQPTable(['laravel' => ['attempts' => $this->attempts()]]),
]),
'', // default exchange: routes by queue name, no binding required
$delay_queue
);

// The retry was republished as a new message; ack the current one.
$this->rabbitmq->ack($this);
}
}
4 changes: 3 additions & 1 deletion app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ public function __construct(ISponsorUserSyncService $service)
public function handle(SponsorServicesMQJob $job): void
{
try {
$event_type = $job->getRabbitMQMessage()->getRoutingKey();
// getEventType, not the raw routing key: a redelivered (released)
// message carries the queue name as routing key.
$event_type = $job->getEventType();
$payload = $job->payload();
$json = json_encode($payload);
Log::debug("UpdateSponsorMemberGroupsMQJob::handle payload {$json}");
Expand Down
Loading
Loading