fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
28221549d1
commit
e09965f918
|
|
@ -7,11 +7,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
|
||||
class MaintenanceNotification extends Model
|
||||
{
|
||||
protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at'];
|
||||
protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at', 'claimed_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['sent_at' => 'datetime'];
|
||||
return ['sent_at' => 'datetime', 'claimed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function window(): BelongsTo
|
||||
|
|
|
|||
|
|
@ -49,16 +49,20 @@ class AppServiceProvider extends ServiceProvider
|
|||
public function boot(): void
|
||||
{
|
||||
// Send-time guard for maintenance mail (X-CP-Notification carries the
|
||||
// ledger id). Read-only — it never marks the row sent (that happens on
|
||||
// real delivery in MessageSent, so a transport failure stays retryable).
|
||||
// It aborts a send when the window was cancelled meanwhile, or when a
|
||||
// sibling copy already delivered (sent_at set) — the common backlog dedup.
|
||||
// ledger id). Uses a distinct in-flight claim (claimed_at) so exactly one
|
||||
// copy ships without ever pre-marking delivery:
|
||||
// • already delivered (sent_at set) or window cancelled → suppress;
|
||||
// • otherwise atomically claim claimed_at (null or older than the TTL) —
|
||||
// the winner sends; a concurrent duplicate loses the claim and aborts.
|
||||
// sent_at is set only on real delivery (MessageSent), so a transport
|
||||
// failure leaves the claim to expire and the mail stays retryable.
|
||||
Event::listen(MessageSending::class, function (MessageSending $event) {
|
||||
$header = $event->message->getHeaders()->get('X-CP-Notification');
|
||||
if ($header === null) {
|
||||
return null;
|
||||
}
|
||||
$notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
|
||||
$id = (int) $header->getBodyAsString();
|
||||
$notification = MaintenanceNotification::query()->with('window')->find($id);
|
||||
if ($notification === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -69,7 +73,13 @@ class AppServiceProvider extends ServiceProvider
|
|||
return false; // window cancelled meanwhile — do not deliver
|
||||
}
|
||||
|
||||
return null;
|
||||
$claimed = MaintenanceNotification::query()
|
||||
->whereKey($id)
|
||||
->whereNull('sent_at')
|
||||
->where(fn ($q) => $q->whereNull('claimed_at')->orWhere('claimed_at', '<=', now()->subMinutes(15)))
|
||||
->update(['claimed_at' => now()]);
|
||||
|
||||
return $claimed === 0 ? false : null; // lost the claim → a sibling is delivering
|
||||
});
|
||||
|
||||
// Stamp a maintenance-notification ledger row as delivered only once the
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ return new class extends Migration
|
|||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Role::query()->whereIn('name', ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'])->delete();
|
||||
Permission::query()->delete();
|
||||
// Only the capabilities this migration introduced — never the whole catalogue.
|
||||
Permission::query()->whereIn('name', [
|
||||
'console.view', 'hosts.manage', 'datacenters.manage', 'customers.manage',
|
||||
'customers.impersonate', 'provisioning.retry', 'provisioning.cancel',
|
||||
'billing.manage', 'maintenance.manage', 'staff.manage',
|
||||
])->delete();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* A distinct in-flight claim, separate from sent_at. claimed_at is set at
|
||||
* send-time by exactly one job (atomic conditional update) to dedup concurrent
|
||||
* copies; sent_at is set only on real delivery so a transport failure stays
|
||||
* retryable once the claim goes stale.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('maintenance_notifications', function (Blueprint $table) {
|
||||
$table->timestamp('claimed_at')->nullable()->after('sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('maintenance_notifications', function (Blueprint $table) {
|
||||
$table->dropColumn('claimed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue