feat: index and sync view_count for fof/discussion-views sort support

- Rename SavingJob to UpdateSearchJob
- Add ViewsSearchJob for lightweight partial view_count updates
- Add probabilistic sync throttle in DiscussionSeeder::viewingOn()
  to avoid an ES update on every page view
- Add view_count to mapping, DiscussionSeeder::toDocument(), and
  translateSort
- Log a warning and skip instead of 500ing on unknown sort fields
This commit is contained in:
Bart van Bragt 2026-04-09 12:05:28 +02:00
parent ff2a252e99
commit 80d66c98c4
6 changed files with 106 additions and 7 deletions

View File

@ -48,6 +48,7 @@ class SearchController extends ListDiscussionsController
'lastPostedAt' => 'updated_at', 'lastPostedAt' => 'updated_at',
'createdAt' => 'created_at', 'createdAt' => 'created_at',
'commentCount' => 'comment_count', 'commentCount' => 'comment_count',
'view_count' => 'view_count',
]; ];
protected Collection $searchers; protected Collection $searchers;
@ -107,9 +108,19 @@ class SearchController extends ListDiscussionsController
$this->addFilters($filterQuery, $actor, $filters) $this->addFilters($filterQuery, $actor, $filters)
); );
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
foreach ($this->extractSort($request) as $field => $direction) { foreach ($this->extractSort($request) as $field => $direction) {
$field = $this->translateSort[$field] ?? $field; $translated = $this->translateSort[$field] ?? $field;
$builder->addSort(new Sort($field, $direction));
if (!in_array($translated, $knownSortFields)) {
resolve(\Psr\Log\LoggerInterface::class)->warning(
"blomstra/search: unknown sort field \"{$field}\", ignoring."
);
continue;
}
$builder->addSort(new Sort($translated, $direction));
} }
$response = $builder->search(); $response = $builder->search();

View File

@ -13,7 +13,7 @@
namespace Blomstra\Search\Commands; namespace Blomstra\Search\Commands;
use Blomstra\Search\Jobs\Job; use Blomstra\Search\Jobs\Job;
use Blomstra\Search\Jobs\SavingJob; use Blomstra\Search\Jobs\UpdateSearchJob;
use Blomstra\Search\Seeders\Seeder; use Blomstra\Search\Seeders\Seeder;
use Elasticsearch\Client; use Elasticsearch\Client;
use Flarum\Settings\SettingsRepositoryInterface; use Flarum\Settings\SettingsRepositoryInterface;
@ -71,6 +71,7 @@ class BuildCommand extends Command
'recipient_groups' => ['type' => 'integer'], 'recipient_groups' => ['type' => 'integer'],
'recipient_users' => ['type' => 'integer'], 'recipient_users' => ['type' => 'integer'],
'comment_count' => ['type' => 'integer'], 'comment_count' => ['type' => 'integer'],
'view_count' => ['type' => 'integer'],
], ],
]; ];
@ -181,7 +182,7 @@ class BuildCommand extends Command
} }
if ($collection->isNotEmpty()) { if ($collection->isNotEmpty()) {
$queue->pushOn(Job::$onQueue, new SavingJob($collection, $seeder)); $queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder));
} }
$this->info("IDs {$rangeFrom}{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}."); $this->info("IDs {$rangeFrom}{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}.");

View File

@ -17,7 +17,7 @@ use Elasticsearch\Client;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class SavingJob extends Job class UpdateSearchJob extends Job
{ {
public function handle(Client $client) public function handle(Client $client)
{ {

View File

@ -0,0 +1,53 @@
<?php
/*
* This file is part of blomstra/search.
*
* Copyright (c) 2022 Blomstra Ltd.
*
* For the full copyright and license information, please view the LICENSE.md
* file that was distributed with this source code.
*
*/
namespace Blomstra\Search\Jobs;
use Elasticsearch\Client;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Queue\AbstractJob;
class ViewsSearchJob extends AbstractJob
{
protected string $index;
public function __construct(protected int $discussionId)
{
$this->index = resolve('blomstra.search.elastic_index');
if (Job::$onQueue) {
$this->onQueue(Job::$onQueue);
}
}
public function handle(Client $client): void
{
$discussion = Discussion::find($this->discussionId);
if (!$discussion) {
return;
}
$type = resolve(DiscussionSerializer::class)->getType(new Discussion());
$client->update([
'index' => $this->index,
'id' => "$type:{$this->discussionId}",
'retry_on_conflict' => 3,
'ignore' => [404],
'body' => [
'doc' => ['view_count' => (int) $discussion->view_count],
],
]);
}
}

View File

@ -14,7 +14,8 @@ namespace Blomstra\Search;
use Blomstra\Search\Jobs\DeletingJob; use Blomstra\Search\Jobs\DeletingJob;
use Blomstra\Search\Jobs\Job; use Blomstra\Search\Jobs\Job;
use Blomstra\Search\Jobs\SavingJob; use Blomstra\Search\Jobs\UpdateSearchJob;
use Blomstra\Search\Jobs\ViewsSearchJob;
use Elasticsearch\Client as Elastic; use Elasticsearch\Client as Elastic;
use Elasticsearch\ClientBuilder; use Elasticsearch\ClientBuilder;
use Flarum\Api\Client; use Flarum\Api\Client;
@ -108,12 +109,18 @@ class Provider extends AbstractServiceProvider
/** @var string|Seeders\Seeder $seeder */ /** @var string|Seeders\Seeder $seeder */
foreach ($seeders as $seeder) { foreach ($seeders as $seeder) {
$seeder::savingOn($events, function ($model) use ($queue, $seeder) { $seeder::savingOn($events, function ($model) use ($queue, $seeder) {
$queue->pushOn(Job::$onQueue, new SavingJob(Collection::make([$model]), $seeder)); $queue->pushOn(Job::$onQueue, new UpdateSearchJob(Collection::make([$model]), $seeder));
}); });
$seeder::deletingOn($events, function ($model) use ($queue, $seeder) { $seeder::deletingOn($events, function ($model) use ($queue, $seeder) {
$queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $seeder)); $queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $seeder));
}); });
if (method_exists($seeder, 'viewingOn')) {
$seeder::viewingOn($events, function (int $discussionId) use ($queue) {
$queue->pushOn(Job::$onQueue, new ViewsSearchJob($discussionId));
});
}
} }
} }
} }

View File

@ -16,7 +16,9 @@ use Blomstra\Search\Save\Document;
use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event as Core; use Flarum\Discussion\Event as Core;
use Flarum\Extension\ExtensionManager;
use FoF\Byobu\Events as Byobu; use FoF\Byobu\Events as Byobu;
use FoF\DiscussionViews\Events\DiscussionWasViewed;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -58,6 +60,27 @@ class DiscussionSeeder extends Seeder
}); });
} }
public static function viewingOn(Dispatcher $events, callable $callable): void
{
if (!resolve(ExtensionManager::class)->isEnabled('fof-discussion-views')) {
return;
}
$events->listen(DiscussionWasViewed::class, function (DiscussionWasViewed $event) use ($callable) {
$viewCount = $event->discussion->view_count;
$shouldSync = match (true) {
$viewCount < 15 => true,
$viewCount < 100 => rand(1, 3) === 1,
default => rand(1, 19) === 1,
};
if ($shouldSync) {
$callable($event->discussion->id);
}
});
}
public static function deletingOn(Dispatcher $events, callable $callable) public static function deletingOn(Dispatcher $events, callable $callable)
{ {
$events->listen([ $events->listen([
@ -107,6 +130,10 @@ class DiscussionSeeder extends Seeder
$document['is_sticky'] = (bool) $model->is_sticky; $document['is_sticky'] = (bool) $model->is_sticky;
} }
if ($this->extensionEnabled('fof-discussion-views')) {
$document['view_count'] = (int) ($model->view_count ?? 0);
}
return $document; return $document;
} }
} }