• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 27958

21 Jul 2026 08:52AM UTC coverage: 72.493% (-0.006%) from 72.499%
27958

push

circleci

invalid-email-address
fix(matched): slice the backfill so it doesn't blow up the DB connection

The first --backfill drove buildNotifications on all ~24k open posts at once:
its long apiv2 phase idled the DB connection out and loadOpenMessages then hit
'2006 MySQL server has gone away' on a giant WHERE id IN(...). Walk the open-age
window in bounded time slices instead (backfill_slice_minutes, default 360):
freshPosts now takes an optional upper bound and buildNotifications an explicit
(since, until), so each slice drives on a small, bounded set — short apiv2
phase, small queries, and each slice's sends persist before the next (partial
progress survives a mid-run failure). Extracted dispatchNotifications() so the
scheduled run and each backfill slice share one delivery path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

13216 of 17415 branches covered (75.89%)

Branch coverage included in aggregate %.

55 of 76 new or added lines in 2 files covered. (72.37%)

10 existing lines in 4 files now uncovered.

139014 of 192578 relevant lines covered (72.19%)

37.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.81
/iznik-batch/app/Console/Commands/Message/NotifyMatchedPostsCommand.php
1
<?php
2

3
namespace App\Console\Commands\Message;
4

5
use App\Mail\Matched\MatchedPosts;
6
use App\Models\User;
7
use App\Services\EmailSpoolerService;
8
use App\Services\MatchedPostsService;
9
use App\Services\MessageSpatialService;
10
use App\Services\PushNotificationService;
11
use App\Traits\GracefulShutdown;
12
use App\Traits\LogsBatchJob;
13
use Carbon\Carbon;
14
use Illuminate\Console\Command;
15
use Illuminate\Support\Facades\DB;
16
use Illuminate\Support\Facades\Log;
17
use Illuminate\Support\Str;
18

19
/**
20
 * Emails members the opposite-type posts near them that match their own open
21
 * Offer/Wanted — the resurrected "Any of these take your fancy?" mail, rebuilt on
22
 * vector matching (V1: cron/relevant.php). Runs every 10 minutes; the service
23
 * already applies every dedup/eligibility guard, so this command just renders,
24
 * spools, and records what was sent so nothing is mailed twice.
25
 */
26
class NotifyMatchedPostsCommand extends Command
27
{
28
    use GracefulShutdown, LogsBatchJob;
29

30
    protected $signature = 'matches:notify
31
                            {--dry-run : Build and report notifications without sending or recording anything}
32
                            {--backfill : One-off: drive on ALL currently-open posts, not just recent arrivals}';
33

34
    protected $description = 'Notify members (in-app + push, and email) of opposite-type posts that match their open offers/wanteds (vector matched-posts)';
35

36
    public function handle(MatchedPostsService $service, EmailSpoolerService $spooler, PushNotificationService $push): int
4✔
37
    {
38
        $this->registerShutdownHandlers();
4✔
39

40
        $dryRun = (bool) $this->option('dry-run');
4✔
41
        if ($dryRun) {
4✔
42
            $this->warn('DRY RUN MODE — nothing sent, no ledger written.');
1✔
43
        }
44

45
        $backfill = (bool) $this->option('backfill');
4✔
46

47
        return $this->runWithLogging(function () use ($service, $spooler, $push, $dryRun, $backfill) {
4✔
48
            $now = Carbon::now();
4✔
49
            $totals = ['notified' => 0, 'emails' => 0, 'matches' => 0];
4✔
50

51
            if ($backfill) {
4✔
52
                // Catch up the already-open backlog (the scheduled run only drives
53
                // on the last fresh_window_minutes of arrivals). Walk the whole
54
                // messages_spatial open-age window (RECENT_DAYS) in bounded time
55
                // slices: a single buildNotifications over tens of thousands of
56
                // drivers overflows the IN(...) queries and idles the DB
57
                // connection out during its long apiv2 phase ("server has gone
58
                // away"). Each slice inherits the spatial open-age via freshPosts.
NEW
59
                $slice = (int) config('freegle.matched.backfill_slice_minutes', 360);
×
NEW
60
                $start = $now->copy()->subDays(MessageSpatialService::RECENT_DAYS);
×
NEW
61
                $this->warn(sprintf(
×
NEW
62
                    'BACKFILL: %d-day open-age window in %d-minute slices.',
×
NEW
63
                    MessageSpatialService::RECENT_DAYS,
×
NEW
64
                    $slice
×
NEW
65
                ));
×
66

NEW
67
                for ($since = $start; $since->lt($now); $since = $since->copy()->addMinutes($slice)) {
×
NEW
68
                    if ($this->shouldStop()) {
×
NEW
69
                        break;
×
70
                    }
NEW
71
                    $until = $since->copy()->addMinutes($slice);
×
NEW
72
                    if ($until->gt($now)) {
×
NEW
73
                        $until = $now->copy();
×
74
                    }
NEW
75
                    $batch = $service->buildNotifications($now, $since, $until);
×
NEW
76
                    $c = $this->dispatchNotifications($batch, $now, $dryRun, $spooler, $push);
×
NEW
77
                    $totals['notified'] += $c['notified'];
×
NEW
78
                    $totals['emails'] += $c['emails'];
×
NEW
79
                    $totals['matches'] += $c['matches'];
×
80
                }
81
            } else {
82
                $totals = $this->dispatchNotifications(
4✔
83
                    $service->buildNotifications($now),
4✔
84
                    $now,
4✔
85
                    $dryRun,
4✔
86
                    $spooler,
4✔
87
                    $push
4✔
88
                );
4✔
89
            }
90

91
            $prefix = $dryRun ? 'Would ' : '';
4✔
92
            $this->newLine();
4✔
93
            $this->table(
4✔
94
                ['Operation', 'Count'],
4✔
95
                [
4✔
96
                    [$prefix . 'Members notified (in-app + push)', number_format($totals['notified'])],
4✔
97
                    [$prefix . 'Members also emailed', number_format($totals['emails'])],
4✔
98
                    [$prefix . 'Matched posts included', number_format($totals['matches'])],
4✔
99
                ]
4✔
100
            );
4✔
101

102
            Log::info('Matched-posts notify completed', [
4✔
103
                'notified' => $totals['notified'],
4✔
104
                'emails' => $totals['emails'],
4✔
105
                'matches' => $totals['matches'],
4✔
106
                'dry_run' => $dryRun,
4✔
107
                'backfill' => $backfill,
4✔
108
            ]);
4✔
109

110
            return Command::SUCCESS;
4✔
111
        });
4✔
112
    }
113

114
    /**
115
     * Deliver one batch of notifications (in-app + push + email) and record the
116
     * ledger/cooldown. Returns per-batch counts so the backfill can accumulate
117
     * across slices. In dry-run it only counts.
118
     *
119
     * @param  array<int, array{user: User, items: array}>  $notifications
120
     * @return array{notified:int, emails:int, matches:int}
121
     */
122
    private function dispatchNotifications(array $notifications, Carbon $now, bool $dryRun, EmailSpoolerService $spooler, PushNotificationService $push): array
4✔
123
    {
124
        $notified = 0;
4✔
125
        $emails = 0;
4✔
126
        $matches = 0;
4✔
127

128
        foreach ($notifications as $n) {
4✔
129
            if ($this->shouldStop()) {
4✔
NEW
130
                break;
×
131
            }
132

133
            $user = $n['user'];
4✔
134
            $items = $n['items'];
4✔
135
            $email = $user->email_preferred ?? $user->email ?? null;
4✔
136

137
            if ($dryRun) {
4✔
138
                $notified++;
1✔
139
                $emails += $email ? 1 : 0;
1✔
140
                $matches += count($items);
1✔
141

142
                continue;
1✔
143
            }
144

145
            // In-app (bell) notification + device push — the primary channel,
146
            // delivered to every eligible recipient regardless of email.
147
            $this->notifyUserOfMatches($user, $items, $now, $push);
3✔
148
            $notified++;
3✔
149

150
            // Email too, when we have an address.
151
            if ($email) {
3✔
152
                $spooler->spool(new MatchedPosts($user, $email, $items));
3✔
153
                $emails++;
3✔
154
            }
155

156
            // Record every matched post as notified to this user (never re-send,
157
            // across both channels) and bump the per-user cooldown.
158
            $rows = array_map(fn ($i) => [
3✔
159
                'msgid' => (int) $i['message']->id,
3✔
160
                'userid' => (int) $user->id,
3✔
161
                'mailed_at' => $now->toDateTimeString(),
3✔
162
            ], $items);
3✔
163
            DB::table('messages_matched_notified')->insertOrIgnore($rows);
3✔
164
            DB::table('users')->where('id', $user->id)->update(['lastrelevantcheck' => $now->toDateTimeString()]);
3✔
165

166
            $matches += count($items);
3✔
167
        }
168

169
        return ['notified' => $notified, 'emails' => $emails, 'matches' => $matches];
4✔
170
    }
171

172
    /**
173
     * Create the in-app (bell) notification row and fire a device push. The push
174
     * is a no-op when the user has no registered Freegle-app device. One row per
175
     * run (the per-user cooldown already bounds frequency), pointing at the top
176
     * match; the subject mirrors the email.
177
     *
178
     * @param  array<int, array{message: \App\Models\Message, reason: \App\Models\Message, score: float}>  $items
179
     */
180
    private function notifyUserOfMatches(User $user, array $items, Carbon $now, PushNotificationService $push): void
3✔
181
    {
182
        $top = $items[0]['message'];
3✔
183
        $count = count($items);
3✔
184

185
        if ($count === 1) {
3✔
186
            $verb = $top->type === 'Offer' ? 'Someone is offering' : 'Someone wants';
3✔
187
            $title = $verb . ': ' . MatchedPostsService::itemName($top);
3✔
188
            $text = null;
3✔
189
        } else {
UNCOV
190
            $title = 'Freegle matches for you';
×
UNCOV
191
            $text = $count . " posts near you match what you've offered or asked for";
×
192
        }
193

194
        DB::table('users_notifications')->insert([
3✔
195
            'touser' => (int) $user->id,
3✔
196
            'fromuser' => null,
3✔
197
            'type' => 'MatchedPost',
3✔
198
            'url' => '/message/' . $top->id,
3✔
199
            'title' => Str::limit($title, 79, ''),
3✔
200
            'text' => $text,
3✔
201
            'timestamp' => $now->toDateTimeString(),
3✔
202
            'seen' => 0,
3✔
203
            'mailed' => 0,
3✔
204
        ]);
3✔
205

206
        $push->notifyUser((int) $user->id);
3✔
207
    }
208
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc