Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions app/Models/Notification.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
* @property-read User $actor
* @property-read Commit|null $commit
* @property-read string $group_id
* @property-read CarbonInterface $group_started_at
* @property-read int $event_count
* @property-read int $actors_count
* @property-read int $unread_count
Expand All @@ -44,6 +45,7 @@
#[Hidden([
'key',
'group_rank',
'group_started_at',
'actor_position',
'second_actor_id',
'third_actor_id',
Expand Down Expand Up @@ -73,6 +75,7 @@ public function casts(): array
'level' => 'integer',
'read_at' => 'datetime',
'created_at' => 'datetime',
'group_started_at' => 'datetime',
'updated_at' => 'datetime',
'actors_count' => 'integer',
'unread_count' => 'integer',
Expand Down
209 changes: 183 additions & 26 deletions app/Queries/NotificationGroupsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator as Paginator;
use Illuminate\Support\Facades\DB;

final readonly class NotificationGroupsQuery
Expand All @@ -22,28 +24,22 @@

private const int STACKED_ACTORS = 3;

private const int WINDOW_ROWS_PER_GROUP = 32;

private const int WINDOW_ATTEMPTS = 4;

private const int MIN_WINDOW_GROWTH = 2;

private const int MAX_WINDOW_GROWTH = 8;

/** @return CursorPaginator<int, Notification> */
public function paginate(User $user, string $type = 'all', ?CarbonImmutable $through = null): CursorPaginator
{
$order = 'PARTITION BY group_id ORDER BY CASE WHEN actor_position = 1 THEN 0 ELSE 1 END, created_at DESC, id DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING';
$events = $this->events($user, $type)->select('notifications.*')
->selectRaw("NTH_VALUE(actor_id, 2) OVER ({$order}) AS second_actor_id")
->selectRaw("NTH_VALUE(actor_id, 3) OVER ({$order}) AS third_actor_id");

$query = Notification::query()->fromSub($events, 'notifications')
->where('group_rank', 1)
->with([
'actor' => fn (BelongsTo $query): BelongsTo => $query
->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]),
'commit',
])
->latest()->latest('id');
$perPage = $through instanceof CarbonImmutable
? max(self::PER_PAGE, $this->groupsSince($user, $through, $type)->where('group_rank', 1)->count())
: self::PER_PAGE;

$groups = $query->cursorPaginate($perPage)->appends(request()->except('through'));
$groups = $this->groups($user, $type, $perPage, Paginator::resolveCurrentCursor())->appends(request()->except('through'));

$this->loadStackedActors($groups->items());

Expand Down Expand Up @@ -109,6 +105,160 @@ public function events(User $user, string $type = 'all'): NotificationBuilder
return $this->group($this->visible($user), $type);
}

/**
* Grouping the whole history costs time in proportion to its size for a page of groups, so the page is grouped
* from a window of recent notifications, widened until it provably holds the same groups as the whole history would.
*
* @return CursorPaginator<int, Notification>
*/
private function groups(User $user, string $type, int $perPage, ?Cursor $cursor): CursorPaginator
{
$until = $this->windowEnd($user, $cursor);
$rows = $perPage * self::WINDOW_ROWS_PER_GROUP;

$sparse = $type !== 'all' && $this->below($user, $cursor)->whereIn('type', $this->kinds($type))->offset($rows - 1)->doesntExist();

for ($round = 0; ! $sparse && $round < self::WINDOW_ATTEMPTS; $round++) {
$since = $this->windowStart($user, $cursor, $rows);

if (! $since instanceof CarbonInterface) {
break;
}

$groups = $this->page($this->window($user, $since, $until), $user, $type, $perPage);

if ($this->windowSuffices($groups, $since)) {
return $groups;
}

$rows = $this->widened($rows, $perPage, count($groups->items()));
}

return $this->page($this->window($user, null, $until), $user, $type, $perPage);
}

/** @return CursorPaginator<int, Notification> */
private function page(NotificationBuilder $visible, User $user, string $type, int $perPage): CursorPaginator
{
$order = 'PARTITION BY group_id ORDER BY CASE WHEN actor_position = 1 THEN 0 ELSE 1 END, created_at DESC, id DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING';
$events = $this->group($visible, $type)->select('notifications.*')
->selectRaw("NTH_VALUE(actor_id, 2) OVER ({$order}) AS second_actor_id")
->selectRaw("NTH_VALUE(actor_id, 3) OVER ({$order}) AS third_actor_id");

return Notification::query()->fromSub($events, 'notifications')
->where('group_rank', 1)
->with([
'actor' => fn (BelongsTo $query): BelongsTo => $query
->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]),
'commit',
])
->latest()->latest('id')
->cursorPaginate($perPage);
}

/**
* The rows for the next window, scaled by how many groups this one found so it would hold twice the page and the
* one group past it, but never less than double nor more than eight times as many, so one sparse window cannot
* blow the next up to the whole history.
*/
private function widened(int $rows, int $perPage, int $found): int
{
return (int) ceil($rows * min(self::MAX_WINDOW_GROWTH, max(self::MIN_WINDOW_GROWTH, 2 * ($perPage + 1) / max(1, $found))));
}

/**
* Whether fewer than twice the window's rows lie below the cursor, so the window would take at least half of them
* and grouping them all costs little more.
*/
private function windowCoversHalf(NotificationBuilder $below, int $rows): bool
{
return $below->clone()->offset(2 * $rows - 1)->doesntExist();
}

/**
* Where a window of the given rows below the cursor begins: the oldest notification the inbox shows from the time
* of the row that many back. Null when the window would not be worth it, or no notification it shows lies there,
* and the page is grouped from the whole history instead.
*/
private function windowStart(User $user, ?Cursor $cursor, int $rows): ?CarbonInterface
{
$below = $this->below($user, $cursor)->latest()->latest('id');

if ($this->windowCoversHalf($below, $rows)) {
return null;
}

return $this->below($user, $cursor)->relevant()
->where('created_at', '>=', $below->offset($rows - 1)->value('created_at'))
->oldest()->oldest('id')->first()?->created_at;
}

private function below(User $user, ?Cursor $cursor): NotificationBuilder
{
return Notification::query()->where('recipient_id', $user->id)
->when($cursor, fn (NotificationBuilder $query, Cursor $cursor): NotificationBuilder => $query
->whereRowValues(['created_at', 'id'], '<', [$cursor->parameter('created_at'), $cursor->parameter('id')]));
}

/**
* A page past a cursor needs the notifications above it only to tell the groups reaching back from above the cursor
* apart from those ending below it. Such a group skips the hour past the first notification at or above the cursor
* only by continuing a session over a gap longer than an hour, which it cannot, as that notification came between.
* So the window ends an hour after that notification, and a page before a cursor keeps all that lies above it.
*/
private function windowEnd(User $user, ?Cursor $cursor): ?CarbonInterface
{
if (! $cursor instanceof Cursor || ! $cursor->pointsToNextItems()) {
return null;
}

return $this->visible($user)
->whereRowValues(['created_at', 'id'], '>=', [$cursor->parameter('created_at'), $cursor->parameter('id')])
->oldest()->oldest('id')->first()?->created_at->addHour();
}

/**
* Grouping the window splits groups like the whole history does, apart from cutting off the oldest group of each
* topic (see groupsSince) and the groups going on past the window's end. Either bound may be left off: a window
* without a start takes the whole history below its end. Stars never split, so a star group spans a
* commit's whole history: taking along every star of each commit starred within the window keeps those groups
* whole, and the extra stars cannot change how anything else groups, as a notification only looks back past its
* own topic's previous one.
*/
private function window(User $user, ?CarbonInterface $since, ?CarbonInterface $until): NotificationBuilder
{
if (! $since instanceof CarbonInterface && ! $until instanceof CarbonInterface) {
return $this->visible($user);
}

$within = fn (NotificationBuilder $query): NotificationBuilder => $query
->when($since, fn (NotificationBuilder $query, CarbonInterface $since): NotificationBuilder => $query->where('created_at', '>=', $since))
->when($until, fn (NotificationBuilder $query, CarbonInterface $until): NotificationBuilder => $query->where('created_at', '<=', $until));
$starredWithin = $within($this->visible($user)->select('commit_id')->where('type', 'starred'));

return $within($this->visible($user))->unionAll(
$this->visible($user)->where('type', 'starred')->whereNot($within)->whereIn('commit_id', $starredWithin)
);
}

/**
* A page from the window matches one from the whole history when the window holds more groups than the page and no
* group on it was cut off. A session continues past its topic's previous notification only when that one is at most
* an hour older, so a group that starts over an hour after the window began has nothing before the window: the row
* just before its first member would have been another member. Groups starting within that first hour may go on
* below, and the window widens until none is on the page.
*
* @param CursorPaginator<int, Notification> $groups
*/
private function windowSuffices(CursorPaginator $groups, CarbonInterface $since): bool
{
$horizon = $since->toImmutable()->addHour();

return $groups->hasMorePages() && array_all($groups->items(), fn (Notification $group): bool => $group->type === NotificationType::Starred
|| $group->group_started_at->isAfter($horizon));
}

/**
* Grouping only the notifications from some point onwards puts every one of them in the same group as grouping all
* of them does, apart from the oldest group of each topic missing what came before that point. So it finds just as
Expand All @@ -124,23 +274,29 @@ private function visible(User $user): NotificationBuilder
return Notification::query()->where('recipient_id', $user->id)->relevant()->select('notifications.*');
}

/** @return list<string> */
private function kinds(string $type): array
{
return array_merge(...array_map(fn (string $category): array => match ($category) {
'replies' => ['replied'],
'quotes' => ['quoted'],
'stars' => ['starred'],
'mentions' => ['mentioned'],
'follows' => ['followed'],
'moderation' => ['approved', 'rejected'],
default => [],
}, explode(',', $type)));
}

private function group(NotificationBuilder $visible, string $type = 'all', ?Notification $member = null): NotificationBuilder
{
$filtered = Notification::query()->from('visible')->select('visible.*', 'identified.group_id')
$filtered = Notification::query()->from('visible')->select('visible.*', 'identified.group_id', 'identified.group_started_at')
->join('identified', 'identified.id', '=', 'visible.id')
->when($member, fn (NotificationBuilder $query, Notification $member): NotificationBuilder => $query->where(
'identified.group_id',
fn (QueryBuilder $query): QueryBuilder => $query->from('identified')->where('id', $member->id)->select('group_id'),
))
->when($type !== 'all', fn (NotificationBuilder $query): NotificationBuilder => $query->whereIn('type', array_merge(...array_map(fn (string $category): array => match ($category) {
'replies' => ['replied'],
'quotes' => ['quoted'],
'stars' => ['starred'],
'mentions' => ['mentioned'],
'follows' => ['followed'],
'moderation' => ['approved', 'rejected'],
default => [],
}, explode(',', $type)))));
->when($type !== 'all', fn (NotificationBuilder $query): NotificationBuilder => $query->whereIn('type', $this->kinds($type)));

$gap = match (DB::connection($visible->getModel()->getConnectionName())->getDriverName()) {
'mysql', 'mariadb' => 'TIMESTAMPDIFF(SECOND, previous_match_at, created_at)',
Expand Down Expand Up @@ -197,7 +353,8 @@ private function group(NotificationBuilder $visible, string $type = 'all', ?Noti
),
identified AS (
SELECT id,
FIRST_VALUE(id) OVER (PARTITION BY topic_kind, topic_id, topic_day, session_number ORDER BY created_at, id) AS group_id
FIRST_VALUE(id) OVER (PARTITION BY topic_kind, topic_id, topic_day, session_number ORDER BY created_at, id) AS group_id,
FIRST_VALUE(created_at) OVER (PARTITION BY topic_kind, topic_id, topic_day, session_number ORDER BY created_at, id) AS group_started_at
FROM sessions
),
filtered AS (
Expand Down
Loading