Skip to content

Commit 1b42f28

Browse files
authored
Merge pull request #16644 from craftcms/feature/cms-109-search-index-queue
Search index queue
2 parents 227b6d1 + f76a651 commit 1b42f28

File tree

9 files changed

+247
-12
lines changed

9 files changed

+247
-12
lines changed

CHANGELOG-WIP.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77
- Global nav items and breadcrumbs can now have `aria-label` attributes via an `ariaLabel` property.
88
- Added `craft\base\ElementInterface::getSerializedFieldValuesForDb()`.
99
- Added `craft\base\FieldInterface::serializeValueForDb()`.
10+
- Added `craft\db\Table::SEARCHINDEXQUEUE_FIELDS`.
11+
- Added `craft\db\Table::SEARCHINDEXQUEUE`.
12+
- Added `craft\services\Search::indexElementIfQueued()`.
13+
- Added `craft\services\Search::queueIndexElement()`.
1014

1115
### System
1216
- The `changedattributes` and `changedfields` tables are now cleaned up during garbage collection. ([#16531](https://github.com/craftcms/cms/pull/16531))
1317
- The `resourcepaths` table is now truncated when clearing control panel resources, via the Caches utility or the `clear-caches/cp-resources` command. ([#16514](https://github.com/craftcms/cms/issues/16514))
1418
- Date values for custom fields are now represented as ISO-8601 date strings (with time zones) within element exports. ([#16629](https://github.com/craftcms/cms/pull/16629))
19+
- “Updating search indexes” queue jobs no longer do anything if search indexes were already updated for the element since the job was created. ([#16644](https://github.com/craftcms/cms/pull/16644))

src/config/app.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
'id' => 'CraftCMS',
55
'name' => 'Craft CMS',
66
'version' => '4.14.4',
7-
'schemaVersion' => '4.5.3.0',
7+
'schemaVersion' => '4.15.0.0',
88
'minVersionRequired' => '3.7.11',
99
'basePath' => dirname(__DIR__), // Defines the @app alias
1010
'runtimePath' => '@storage/runtime', // Defines the @runtime alias

src/db/Table.php

+4
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,8 @@ abstract class Table
9797
public const VOLUMES = '{{%volumes}}';
9898
public const WIDGETS = '{{%widgets}}';
9999
public const SEARCHINDEX = '{{%searchindex}}';
100+
/** @since 4.15.0 */
101+
public const SEARCHINDEXQUEUE = '{{%searchindexqueue}}';
102+
/** @since 4.15.0 */
103+
public const SEARCHINDEXQUEUE_FIELDS = '{{%searchindexqueue_fields}}';
100104
}

src/fields/BaseRelationField.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,7 @@ public function getContentGqlMutationArgumentType(): Type|array
939939
*/
940940
public function afterSave(bool $isNew): void
941941
{
942-
// If the propagation method just changed, resave all the Matrix blocks
942+
// If the propagation method just changed, resave all the elements
943943
if (isset($this->oldSettings)) {
944944
$oldLocalizeRelations = (bool)($this->oldSettings['localizeRelations'] ?? false);
945945
if ($this->localizeRelations !== $oldLocalizeRelations) {

src/migrations/Install.php

+15
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,17 @@ public function createTables(): void
574574
'dateUpdated' => $this->dateTime()->notNull(),
575575
'uid' => $this->uid(),
576576
]);
577+
$this->createTable(Table::SEARCHINDEXQUEUE, [
578+
'id' => $this->primaryKey(),
579+
'elementId' => $this->integer()->notNull(),
580+
'siteId' => $this->integer()->notNull(),
581+
'reserved' => $this->boolean()->notNull()->defaultValue(false),
582+
]);
583+
$this->createTable(Table::SEARCHINDEXQUEUE_FIELDS, [
584+
'jobId' => $this->integer()->notNull(),
585+
'fieldHandle' => $this->string()->notNull(),
586+
'PRIMARY KEY([[jobId]], [[fieldHandle]])',
587+
]);
577588
$this->createTable(Table::SECTIONS, [
578589
'id' => $this->primaryKey(),
579590
'structureId' => $this->integer(),
@@ -896,6 +907,8 @@ public function createIndexes(): void
896907
$this->createIndex(null, Table::RELATIONS, ['targetId'], false);
897908
$this->createIndex(null, Table::RELATIONS, ['sourceSiteId'], false);
898909
$this->createIndex(null, Table::REVISIONS, ['canonicalId', 'num'], true);
910+
$this->createIndex(null, Table::SEARCHINDEXQUEUE, ['elementId', 'siteId', 'reserved'], false);
911+
$this->createIndex(null, Table::SEARCHINDEXQUEUE_FIELDS, ['jobId', 'fieldHandle'], true);
899912
$this->createIndex(null, Table::SECTIONS, ['handle'], false);
900913
$this->createIndex(null, Table::SECTIONS, ['name'], false);
901914
$this->createIndex(null, Table::SECTIONS, ['structureId'], false);
@@ -1064,6 +1077,8 @@ public function addForeignKeys(): void
10641077
$this->addForeignKey(null, Table::RELATIONS, ['sourceSiteId'], Table::SITES, ['id'], 'CASCADE', 'CASCADE');
10651078
$this->addForeignKey(null, Table::REVISIONS, ['creatorId'], Table::USERS, ['id'], 'SET NULL', null);
10661079
$this->addForeignKey(null, Table::REVISIONS, ['canonicalId'], Table::ELEMENTS, ['id'], 'CASCADE', null);
1080+
$this->addForeignKey(null, Table::SEARCHINDEXQUEUE, 'elementId', Table::ELEMENTS, 'id', 'CASCADE', null);
1081+
$this->addForeignKey(null, Table::SEARCHINDEXQUEUE_FIELDS, 'jobId', Table::SEARCHINDEXQUEUE, 'id', 'CASCADE', null);
10671082
$this->addForeignKey(null, Table::SECTIONS, ['structureId'], Table::STRUCTURES, ['id'], 'SET NULL', null);
10681083
$this->addForeignKey(null, Table::SECTIONS_SITES, ['siteId'], Table::SITES, ['id'], 'CASCADE', 'CASCADE');
10691084
$this->addForeignKey(null, Table::SECTIONS_SITES, ['sectionId'], Table::SECTIONS, ['id'], 'CASCADE', null);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace craft\migrations;
4+
5+
use craft\db\Migration;
6+
use craft\db\Table;
7+
8+
/**
9+
* m250206_135036_search_index_queue migration.
10+
*/
11+
class m250206_135036_search_index_queue extends Migration
12+
{
13+
/**
14+
* @inheritdoc
15+
*/
16+
public function safeUp(): bool
17+
{
18+
$this->safeDown();
19+
$this->createTable(Table::SEARCHINDEXQUEUE, [
20+
'id' => $this->primaryKey(),
21+
'elementId' => $this->integer()->notNull(),
22+
'siteId' => $this->integer()->notNull(),
23+
'reserved' => $this->boolean()->notNull()->defaultValue(false),
24+
]);
25+
$this->createTable(Table::SEARCHINDEXQUEUE_FIELDS, [
26+
'jobId' => $this->integer()->notNull(),
27+
'fieldHandle' => $this->string()->notNull(),
28+
'PRIMARY KEY([[jobId]], [[fieldHandle]])',
29+
]);
30+
$this->createIndex(null, Table::SEARCHINDEXQUEUE, ['elementId', 'siteId', 'reserved'], false);
31+
$this->createIndex(null, Table::SEARCHINDEXQUEUE_FIELDS, ['jobId', 'fieldHandle'], true);
32+
$this->addForeignKey(null, Table::SEARCHINDEXQUEUE, 'elementId', Table::ELEMENTS, 'id', 'CASCADE', null);
33+
$this->addForeignKey(null, Table::SEARCHINDEXQUEUE_FIELDS, 'jobId', Table::SEARCHINDEXQUEUE, 'id', 'CASCADE', null);
34+
return true;
35+
}
36+
37+
/**
38+
* @inheritdoc
39+
*/
40+
public function safeDown(): bool
41+
{
42+
$this->dropTableIfExists(Table::SEARCHINDEXQUEUE_FIELDS);
43+
$this->dropTableIfExists(Table::SEARCHINDEXQUEUE);
44+
return true;
45+
}
46+
}

src/queue/jobs/UpdateSearchIndex.php

+17-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use craft\base\ElementInterface;
1212
use craft\i18n\Translation;
1313
use craft\queue\BaseJob;
14+
use yii\base\InvalidConfigException;
1415

1516
/**
1617
* UpdateSearchIndex job
@@ -41,11 +42,27 @@ class UpdateSearchIndex extends BaseJob
4142
*/
4243
public ?array $fieldHandles = null;
4344

45+
/**
46+
* @var bool Whether to check if the element’s search indexes are queued to be updated before proceeding.
47+
* @since 4.15.0
48+
*/
49+
public bool $queued = false;
50+
4451
/**
4552
* @inheritdoc
4653
*/
4754
public function execute($queue): void
4855
{
56+
$searchService = Craft::$app->getSearch();
57+
58+
if ($this->queued) {
59+
if (!is_int($this->elementId) || !is_int($this->siteId)) {
60+
throw new InvalidConfigException('`elementId` and `siteId` must be an integer when `queued` is true.');
61+
}
62+
$searchService->indexElementIfQueued($this->elementId, $this->siteId, $this->elementType);
63+
return;
64+
}
65+
4966
$elements = $this->elementType::find()
5067
->drafts(null)
5168
->provisionalDrafts(null)
@@ -54,7 +71,6 @@ public function execute($queue): void
5471
->status(null)
5572
->all();
5673
$total = count($elements);
57-
$searchService = Craft::$app->getSearch();
5874

5975
foreach ($elements as $i => $element) {
6076
$this->setProgress($queue, ($i + 1) / $total);

src/services/Elements.php

+1-7
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
use craft\models\ElementActivity;
5858
use craft\queue\jobs\FindAndReplace;
5959
use craft\queue\jobs\UpdateElementSlugsAndUris;
60-
use craft\queue\jobs\UpdateSearchIndex;
6160
use craft\records\Element as ElementRecord;
6261
use craft\records\Element_SiteSettings as Element_SiteSettingsRecord;
6362
use craft\records\StructureElement as StructureElementRecord;
@@ -3564,12 +3563,7 @@ private function _saveElementInternal(
35643563
if (Craft::$app->getRequest()->getIsConsoleRequest()) {
35653564
Craft::$app->getSearch()->indexElementAttributes($element, $searchableDirtyFields);
35663565
} else {
3567-
Queue::push(new UpdateSearchIndex([
3568-
'elementType' => get_class($element),
3569-
'elementId' => $element->id,
3570-
'siteId' => $element->siteId,
3571-
'fieldHandles' => $searchableDirtyFields,
3572-
]), 2048);
3566+
Craft::$app->getSearch()->queueIndexElement($element, $searchableDirtyFields);
35733567
}
35743568
}
35753569
}

src/services/Search.php

+157-2
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,20 @@
1818
use craft\events\IndexKeywordsEvent;
1919
use craft\events\SearchEvent;
2020
use craft\helpers\ArrayHelper;
21+
use craft\helpers\Component as ComponentHelper;
2122
use craft\helpers\Db;
2223
use craft\helpers\ElementHelper;
24+
use craft\helpers\Queue;
2325
use craft\helpers\Search as SearchHelper;
2426
use craft\helpers\StringHelper;
27+
use craft\queue\jobs\UpdateSearchIndex;
2528
use craft\search\SearchQuery;
2629
use craft\search\SearchQueryTerm;
2730
use craft\search\SearchQueryTermGroup;
2831
use Throwable;
2932
use yii\base\Component;
30-
use yii\db\Exception;
33+
use yii\base\Exception;
34+
use yii\db\Exception as DbException;
3135
use yii\db\Expression;
3236
use yii\db\Schema;
3337

@@ -197,6 +201,157 @@ public function indexElementAttributes(ElementInterface $element, ?array $fieldH
197201
return true;
198202
}
199203

204+
/**
205+
* Queues up an element to be indexed.
206+
*
207+
* @param ElementInterface $element
208+
* @param string[] $fieldHandles
209+
* @since 4.15.0
210+
*/
211+
public function queueIndexElement(ElementInterface $element, array $fieldHandles): void
212+
{
213+
$this->createOrUpdateIndexJob($element, $fieldHandles);
214+
215+
Queue::push(new UpdateSearchIndex([
216+
'elementType' => get_class($element),
217+
'elementId' => $element->id,
218+
'siteId' => $element->siteId,
219+
'queued' => true,
220+
]), 2048);
221+
}
222+
223+
private function createOrUpdateIndexJob(ElementInterface $element, array $fieldHandles): void
224+
{
225+
$jobId = $this->pendingIndexJobId($element->id, $element->siteId);
226+
227+
if ($jobId) {
228+
if (empty($fieldHandles)) {
229+
// nothing more to do
230+
return;
231+
}
232+
233+
// get a lock on the job and make sure it still exists && is pending
234+
$mutex = Craft::$app->getMutex();
235+
$lockName = "searchqueue:$jobId";
236+
237+
if ($mutex->acquire($lockName)) {
238+
try {
239+
if ($this->isIndexJobPending($jobId)) {
240+
foreach ($fieldHandles as $fieldHandle) {
241+
Db::upsert(Table::SEARCHINDEXQUEUE_FIELDS, [
242+
'jobId' => $jobId,
243+
'fieldHandle' => $fieldHandle,
244+
]);
245+
}
246+
return;
247+
}
248+
} finally {
249+
$mutex->release($lockName);
250+
}
251+
}
252+
}
253+
254+
$db = Craft::$app->getDb();
255+
$db->transaction(function() use ($element, $fieldHandles, $db) {
256+
Db::insert(Table::SEARCHINDEXQUEUE, [
257+
'elementId' => $element->id,
258+
'siteId' => $element->siteId,
259+
], $db);
260+
$jobId = $db->getLastInsertID(Table::SEARCHINDEXQUEUE);
261+
$fieldData = array_map(fn(string $fieldHandle) => [$jobId, $fieldHandle], $fieldHandles);
262+
Db::batchInsert(Table::SEARCHINDEXQUEUE_FIELDS, ['jobId', 'fieldHandle'], $fieldData, $db);
263+
});
264+
}
265+
266+
/**
267+
* Indexes the attributes of a given element, only if it’s queued.
268+
*
269+
* @param int $elementId
270+
* @param int $siteId
271+
* @param class-string<ElementInterface>|null $elementType
272+
* @since 4.15.0
273+
*/
274+
public function indexElementIfQueued(int $elementId, int $siteId, ?string $elementType = null): void
275+
{
276+
$jobId = $this->pendingIndexJobId($elementId, $siteId);
277+
278+
if (!$jobId) {
279+
// no pending jobs
280+
return;
281+
}
282+
283+
// get a lock on the job and then mark it as reserved,
284+
// so there's no chance another process reserves it/modifies it, overlapping with this one
285+
$mutex = Craft::$app->getMutex();
286+
$lockName = "searchqueue:$jobId";
287+
if (!$mutex->acquire($lockName, 5)) {
288+
throw new Exception("Unable to acquire a mutex lock for search queue job $jobId");
289+
}
290+
291+
try {
292+
if (!Db::update(Table::SEARCHINDEXQUEUE, ['reserved' => true], ['id' => $jobId])) {
293+
// another process must be handling the same job
294+
return;
295+
}
296+
} finally {
297+
$mutex->release($lockName);
298+
}
299+
300+
try {
301+
// Figure out which fields need to be updated for the element
302+
$fieldHandles = (new Query())
303+
->select(['fieldHandle'])
304+
->from(Table::SEARCHINDEXQUEUE_FIELDS)
305+
->where(['jobId' => $jobId])
306+
->column();
307+
308+
$elementType ??= Craft::$app->getElements()->getElementTypeById($elementId);
309+
310+
if ($elementType && ComponentHelper::validateComponentClass($elementType, ElementInterface::class)) {
311+
$element = $elementType::find()
312+
->drafts(null)
313+
->provisionalDrafts(null)
314+
->id($elementId)
315+
->siteId($siteId)
316+
->status(null)
317+
->one();
318+
319+
if ($elementId) {
320+
$this->indexElementAttributes($element, $fieldHandles);
321+
}
322+
}
323+
324+
Db::delete(Table::SEARCHINDEXQUEUE, ['id' => $jobId]);
325+
} catch (Throwable $e) {
326+
Db::update(Table::SEARCHINDEXQUEUE, ['reserved' => false], ['id' => $jobId]);
327+
throw $e;
328+
}
329+
}
330+
331+
private function pendingIndexJobId(int $elementId, int $siteId): ?int
332+
{
333+
return (new Query())
334+
->select('id')
335+
->from(Table::SEARCHINDEXQUEUE)
336+
->where([
337+
'elementId' => $elementId,
338+
'siteId' => $siteId,
339+
'reserved' => false,
340+
])
341+
->scalar();
342+
}
343+
344+
private function isIndexJobPending(int $jobId): bool
345+
{
346+
$job = (new Query())
347+
->select('reserved')
348+
->from(Table::SEARCHINDEXQUEUE)
349+
->where(['id' => $jobId])
350+
->one();
351+
352+
return $job && !$job['reserved'];
353+
}
354+
200355
/**
201356
* Returns whether we should search for the resulting elements up front via [[searchElements()]],
202357
* rather than supply a subquery which should be applied to the main element query via [[createDbQuery()]].
@@ -488,7 +643,7 @@ private function _indexKeywords(ElementInterface $element, string $keywords, ?st
488643
try {
489644
Db::insert(Table::SEARCHINDEX, $columns);
490645
return;
491-
} catch (Exception $e) {
646+
} catch (DbException $e) {
492647
if (str_contains($e->getPrevious()?->getMessage(), 'deadlock')) {
493648
// A gap lock was probably hit. Try again in one second
494649
// https://github.com/craftcms/cms/issues/15221

0 commit comments

Comments
 (0)