diff --git a/.prettierignore b/.prettierignore
index 6feed212..bd8c8fc6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,3 +5,6 @@ vendor
vendor-prefixed
package-lock.json
composer.lock
+# Stylelint formats styles; its WordPress rules contradict Prettier's (blank lines, wrapped values).
+*.css
+*.scss
diff --git a/assets/src/components/SiteIndexableEntities.tsx b/assets/src/components/SiteIndexableEntities.tsx
deleted file mode 100644
index 5d2083ad..00000000
--- a/assets/src/components/SiteIndexableEntities.tsx
+++ /dev/null
@@ -1,453 +0,0 @@
-/**
- * WordPress dependencies
- */
-import {
- Card,
- CardHeader,
- CardBody,
- Button,
- __experimentalText as Text,
- Modal,
-} from '@wordpress/components';
-
-/**
- * External dependencies
- */
-import { useState, useEffect, useCallback } from 'react';
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import MultiSelectChips from './MultiSelectChips';
-import { API_NAMESPACE, NONCE, withTrailingSlash } from '@/js/utils';
-import type { NoticeType } from '@/admin/settings/page';
-import type { OneSearchSharedSite } from '@/types/global';
-import type { PostTypeOption } from './SiteSearchSettings';
-
-interface EntitiesMap {
- [ siteUrl: string ]: string[];
-}
-
-interface SiteIndexableEntitiesProps {
- sites: OneSearchSharedSite[];
- allPostTypes: Record< string, PostTypeOption[] >;
- currentSiteUrl: string;
- setNotice: ( notice: NoticeType | null ) => void;
- onEntitiesSaved?: () => void;
- saving: boolean;
- setSaving: ( saving: boolean ) => void;
-}
-
-interface IndexableEntitiesResponse {
- indexableEntities: {
- entities: EntitiesMap;
- };
-}
-
-interface SaveEntitiesResponse {
- success: boolean;
- message?: string;
- data?: {
- status?: number;
- };
-}
-
-interface ReIndexResponse {
- success: boolean;
- message?: string;
- data?: {
- status?: number;
- };
-}
-
-const SiteIndexableEntities = ( {
- sites,
- allPostTypes,
- currentSiteUrl,
- setNotice,
- onEntitiesSaved,
- saving,
- setSaving,
-}: SiteIndexableEntitiesProps ) => {
- const [ selectedEntities, setSelectedEntities ] = useState< EntitiesMap >(
- {}
- );
- const [ savedEntities, setSavedEntities ] = useState< EntitiesMap >( {} );
- const [ reindexing, setReindexing ] = useState( false );
- const [ showReindexingModal, setShowReindexingModal ] = useState( false );
-
- const controlsDisabled = saving || reindexing;
-
- const normalizeEntities = ( map: EntitiesMap = {} ): EntitiesMap => {
- const results: EntitiesMap = {};
- Object.keys( map || {} )
- .sort()
- .forEach( ( site ) => {
- const arr = Array.isArray( map[ site ] ) ? map[ site ] : [];
- const clean = Array.from( new Set( arr.map( String ) ) ).sort();
- results[ site ] = clean;
- } );
-
- return results;
- };
-
- const isEmptySavedEntities = (): boolean => {
- if ( ! savedEntities || typeof savedEntities !== 'object' ) {
- return true;
- }
-
- const keys = Object.keys( savedEntities );
- if ( keys.length === 0 ) {
- return true;
- }
-
- return keys.every( ( key ) => {
- const value = savedEntities[ key ];
- return ! Array.isArray( value ) || value.length === 0;
- } );
- };
-
- const getIndexableEntities = useCallback( async () => {
- try {
- const response = await fetch(
- `${ API_NAMESPACE }/indexable-entities`,
- {
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': NONCE,
- },
- }
- );
-
- const data: IndexableEntitiesResponse = await response.json();
- const incoming: EntitiesMap =
- data.indexableEntities?.entities || {};
- setSelectedEntities( incoming );
- setSavedEntities( normalizeEntities( incoming ) );
- } catch {
- setNotice( {
- type: 'error',
- message: __(
- 'Error fetching indexable entities.',
- 'onesearch'
- ),
- } );
- }
- }, [ setNotice ] );
-
- useEffect( () => {
- getIndexableEntities();
- }, [ getIndexableEntities ] );
-
- const handleSelectedEntitiesChange = (
- selected: string[],
- url: string
- ) => {
- if ( controlsDisabled ) {
- return;
- }
- setSelectedEntities( ( prev: EntitiesMap ) => ( {
- ...prev,
- [ url ]: selected,
- } ) );
- };
-
- const handleSelectedEntitiesSave = async (
- entities: EntitiesMap
- ): Promise< boolean > => {
- try {
- setSaving( true );
- const response = await fetch(
- `${ API_NAMESPACE }/indexable-entities`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': NONCE,
- },
- body: JSON.stringify( { entities } ),
- }
- );
-
- if ( ! response.ok ) {
- throw new Error(
- __( 'Network response was not ok.', 'onesearch' )
- );
- }
-
- const data: SaveEntitiesResponse = await response.json();
-
- if ( data.success ) {
- setSavedEntities( normalizeEntities( entities ) );
- onEntitiesSaved?.();
- // Re-index selected entities.
- await handleReIndex();
- return true;
- } else if ( data.data?.status === 500 ) {
- setNotice( {
- message: __( 'Internal server error.', 'onesearch' ),
- type: 'error',
- } );
- } else {
- setNotice( {
- message:
- data.message || __( 'Unknown error.', 'onesearch' ),
- type: 'error',
- } );
- }
- } catch ( error: unknown ) {
- const message =
- error instanceof Error ? error.message : String( error );
- setNotice( {
- message,
- type: 'error',
- } );
- } finally {
- setSaving( false );
- }
- return false;
- };
-
- const handleReIndex = async (): Promise< boolean > => {
- try {
- setReindexing( true );
- // @todo use @wordpress/api-fetch everywhere internal.
- const response = await fetch( `${ API_NAMESPACE }/re-index`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': NONCE,
- },
- } );
-
- const data: ReIndexResponse = await response.json();
- if ( data.success ) {
- setNotice( {
- message:
- data.message ||
- __( 'Re-indexing complete.', 'onesearch' ),
- type: 'success',
- } );
- return true;
- } else if ( data.data?.status === 500 ) {
- setNotice( {
- message: __( 'Internal server error.', 'onesearch' ),
- type: 'error',
- } );
- } else {
- setNotice( {
- message:
- data.message || __( 'Unknown error.', 'onesearch' ),
- type: 'error',
- } );
- }
- } catch ( error: unknown ) {
- const message =
- error instanceof Error ? error.message : String( error );
- setNotice( {
- message,
- type: 'error',
- } );
- } finally {
- setReindexing( false );
- setShowReindexingModal( false );
- }
- return false;
- };
-
- const isDirty =
- JSON.stringify( normalizeEntities( selectedEntities ) ) !==
- JSON.stringify( savedEntities );
-
- // Convert PostTypeOption to the format expected by MultiSelectChips
- const toMultiSelectOptions = (
- options: PostTypeOption[]
- ): Array< { slug: string; label: string; restBase: string } > => {
- return options.map( ( opt ) => ( {
- slug: opt.slug,
- label: opt.label ?? opt.slug,
- restBase: opt.restBase ?? opt.slug,
- } ) );
- };
-
- return (
- <>
-
-
-
- { __( 'Select Entities to Index', 'onesearch' ) }
-
-
-
- setShowReindexingModal( true ) }
- isBusy={ reindexing }
- disabled={
- reindexing || isEmptySavedEntities()
- }
- className="onesearch-btn-reindex"
- >
- { reindexing
- ? __( 'Re-indexing…', 'onesearch' )
- : __( 'Re-index', 'onesearch' ) }
-
-
- handleSelectedEntitiesSave(
- selectedEntities
- )
- }
- disabled={ ! isDirty || saving }
- isBusy={ saving }
- className="onesearch-btn-save-entities"
- >
- { saving
- ? __( 'Saving…', 'onesearch' )
- : __( 'Save Changes', 'onesearch' ) }
-
-
-
- { __(
- 'Saving changes will automatically re-index the data.',
- 'onesearch'
- ) }
-
-
-
-
-
- { /* Governing Site */ }
-
-
-
- { __( 'Governing Site', 'onesearch' ) }
-
-
- { currentSiteUrl }
-
-
-
-
- handleSelectedEntitiesChange(
- next,
- currentSiteUrl
- )
- }
- valueField="slug"
- labelField="label"
- disabled={ controlsDisabled }
- />
-
-
-
- { /* Brand Sites */ }
- { sites?.map( ( site: OneSearchSharedSite ) => (
-
-
-
- { site.name }
-
-
- { site.url }
-
-
- { ! allPostTypes?.[ site?.url ] ? (
-
- { __(
- 'No entities to select. Please check site configuration',
- 'onesearch'
- ) }
-
- ) : (
-
-
- handleSelectedEntitiesChange(
- next,
- site?.url
- )
- }
- valueField="slug"
- labelField="label"
- disabled={ controlsDisabled }
- />
-
- ) }
-
- ) ) }
-
-
- { showReindexingModal && (
- setShowReindexingModal( false ) }
- shouldCloseOnClickOutside={ false }
- size="medium"
- >
-
- { __(
- 'Re-indexing will only index the entities you have previously saved. To re-index modified entities, please make sure you have saved them.',
- 'onesearch'
- ) }
-
-
- setShowReindexingModal( false ) }
- disabled={ reindexing }
- style={ { marginRight: '8px' } }
- >
- { __( 'Cancel', 'onesearch' ) }
-
- handleReIndex() }
- isBusy={ reindexing }
- disabled={ reindexing }
- >
- { reindexing
- ? __( 'Re-indexing…', 'onesearch' )
- : __( 'Re-index', 'onesearch' ) }
-
-
-
- ) }
- >
- );
-};
-
-export default SiteIndexableEntities;
diff --git a/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx b/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx
new file mode 100644
index 00000000..579cf1a4
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx
@@ -0,0 +1,64 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { __experimentalText as Text } from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import MultiSelectChips from '../MultiSelectChips';
+import type { PostTypeOption } from '../SiteSearchSettings';
+import { toMultiSelectOptions } from './utils';
+
+interface EntitySiteCardProps {
+ siteName: string;
+ siteUrl: string;
+ options: PostTypeOption[] | undefined;
+ selectedValues: string[];
+ disabled: boolean;
+ isBrand?: boolean;
+ onChange: ( values: string[] ) => void;
+}
+
+const EntitySiteCard = ( {
+ siteName,
+ siteUrl,
+ options,
+ selectedValues,
+ disabled,
+ isBrand = false,
+ onChange,
+}: EntitySiteCardProps ) => (
+
+
+
{ siteName }
+
{ siteUrl }
+
+ { ! options ? (
+
+ { __(
+ 'No entities to select. Please check site configuration',
+ 'onesearch'
+ ) }
+
+ ) : (
+
+
+
+ ) }
+
+);
+
+export default EntitySiteCard;
diff --git a/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx
new file mode 100644
index 00000000..d3df944f
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx
@@ -0,0 +1,100 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button } from '@wordpress/components';
+
+interface HistoryPaginationProps {
+ historyPage: number;
+ historyTotalPages: number;
+ onPageChange: ( page: number ) => void;
+}
+
+const HistoryPagination = ( {
+ historyPage,
+ historyTotalPages,
+ onPageChange,
+}: HistoryPaginationProps ) => {
+ if ( historyTotalPages <= 1 ) {
+ return null;
+ }
+
+ const pages: ( number | string )[] = [];
+ const delta = 2;
+ const last = historyTotalPages;
+
+ pages.push( 1 );
+
+ const rangeStart = Math.max( 2, historyPage - delta );
+ const rangeEnd = Math.min( last - 1, historyPage + delta );
+
+ if ( rangeStart > 2 ) {
+ pages.push( '…' );
+ }
+ for ( let i = rangeStart; i <= rangeEnd; i++ ) {
+ pages.push( i );
+ }
+ if ( rangeEnd < last - 1 ) {
+ pages.push( '…' );
+ }
+ pages.push( last );
+
+ const handlePageClick = ( page: number ) => {
+ if ( page === historyPage || page < 1 || page > last ) {
+ return;
+ }
+ onPageChange( page );
+ };
+
+ return (
+
+ handlePageClick( historyPage - 1 ) }
+ aria-label={ __( 'Previous page', 'onesearch' ) }
+ >
+ ‹
+
+ { pages.map( ( p, idx ) => {
+ if ( typeof p === 'string' ) {
+ return (
+
+ …
+
+ );
+ }
+ return (
+ handlePageClick( p ) }
+ className={
+ p === historyPage
+ ? 'onesearch-history-pagination-current'
+ : ''
+ }
+ >
+ { p }
+
+ );
+ } ) }
+ = last }
+ onClick={ () => handlePageClick( historyPage + 1 ) }
+ aria-label={ __( 'Next page', 'onesearch' ) }
+ >
+ ›
+
+
+ );
+};
+
+export default HistoryPagination;
diff --git a/assets/src/components/SiteIndexableEntities/HistoryTable.tsx b/assets/src/components/SiteIndexableEntities/HistoryTable.tsx
new file mode 100644
index 00000000..e67aa415
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/HistoryTable.tsx
@@ -0,0 +1,148 @@
+/**
+ * WordPress dependencies
+ */
+import { Button, __experimentalText as Text } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import JobStatusBadge from './JobStatusBadge';
+import type { JobStatus } from './types';
+import { formatDuration, formatTimestamp } from './utils';
+
+interface HistoryTableProps {
+ history: JobStatus[];
+ retryingJobId: string | null;
+ onRetry: ( job: JobStatus ) => void;
+}
+
+/**
+ * A job is worth retrying when it failed outright, or when it finished with
+ * some batches still in a failed state. A cancelled job never is: cancelling
+ * was the user's decision, not a failure.
+ *
+ * @param {JobStatus} job The job to check.
+ * @return {boolean} Whether a retry should be offered.
+ */
+const isRetryable = ( job: JobStatus ): boolean =>
+ job.status !== 'cancelled' &&
+ ( job.status === 'failed' || ( job.children_failed ?? 0 ) > 0 );
+
+/**
+ * Formats a job's batch progress as "done/total".
+ *
+ * A top-level sync job (e.g. from the Watcher) has no child batches because it
+ * is itself the one batch, and its progress counters track posts, not batches.
+ *
+ * @param {JobStatus} job The job to format.
+ * @return {string} The batch progress, or an em dash when the job has no batches.
+ */
+const formatBatches = ( job: JobStatus ): string => {
+ if ( job.type === 'sync' ) {
+ return `${ job.status === 'completed' ? 1 : 0 }/1`;
+ }
+
+ const totalBatches =
+ ( job.data?.[ 'total_batches' ] as number ) ||
+ job.children_total ||
+ job.progress_total;
+ const completedBatches = job.children_completed ?? job.progress;
+
+ return totalBatches > 0 ? `${ completedBatches }/${ totalBatches }` : '—';
+};
+
+const HistoryTable = ( {
+ history,
+ retryingJobId,
+ onRetry,
+}: HistoryTableProps ) => {
+ if ( history.length === 0 ) {
+ return (
+ { __( 'No past jobs.', 'onesearch' ) }
+ );
+ }
+
+ return (
+
+
+
+ { __( 'ID', 'onesearch' ) }
+ { __( 'Type', 'onesearch' ) }
+ { __( 'Created at', 'onesearch' ) }
+ { __( 'Duration', 'onesearch' ) }
+ { __( 'Status', 'onesearch' ) }
+ { __( 'Batches', 'onesearch' ) }
+
+
+ { __( 'Actions', 'onesearch' ) }
+
+
+
+
+
+ { history.map( ( job ) => {
+ const duration =
+ job.finished_at && job.created_at
+ ? job.finished_at - job.created_at
+ : null;
+
+ const retrying = retryingJobId === job.id;
+
+ return (
+
+
+
+ { job.id.substring( 0, 16 ) }…
+
+
+
+
+ { job.group || 'reindex' }
+
+
+
+
+ { formatTimestamp( job.created_at ) }
+
+
+
+
+ { duration !== null
+ ? formatDuration( duration )
+ : '—' }
+
+
+
+
+
+
+
+ { formatBatches( job ) }
+
+
+
+ { isRetryable( job ) && (
+ onRetry( job ) }
+ >
+ { retrying
+ ? __( 'Retrying…', 'onesearch' )
+ : __( 'Retry', 'onesearch' ) }
+
+ ) }
+
+
+ );
+ } ) }
+
+
+ );
+};
+
+export default HistoryTable;
diff --git a/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx
new file mode 100644
index 00000000..2a2ff5b3
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx
@@ -0,0 +1,23 @@
+/**
+ * Internal dependencies
+ */
+import { STATUS_LABELS } from './constants';
+
+interface JobStatusBadgeProps {
+ status: string;
+ size?: 'normal' | 'small';
+}
+
+const JobStatusBadge = ( { status, size = 'normal' }: JobStatusBadgeProps ) => {
+ return (
+
+ { STATUS_LABELS[ status ] || status }
+
+ );
+};
+
+export default JobStatusBadge;
diff --git a/assets/src/components/SiteIndexableEntities/ReindexModal.tsx b/assets/src/components/SiteIndexableEntities/ReindexModal.tsx
new file mode 100644
index 00000000..06244a74
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/ReindexModal.tsx
@@ -0,0 +1,67 @@
+/**
+ * WordPress dependencies
+ */
+import { Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import type { JobStatus, SiteJobState } from './types';
+import ReindexModalContent from './ReindexModalContent';
+
+interface ReindexModalProps {
+ reindexing: boolean;
+ siteStates: SiteJobState[];
+ cancelling: boolean;
+ history: JobStatus[];
+ historyPage: number;
+ historyTotalPages: number;
+ retryingJobId: string | null;
+ onClose: () => void;
+ onReIndex: () => void;
+ onCancelJob: () => void;
+ onRetryHistoryJob: ( job: JobStatus ) => void;
+ onPageChange: ( page: number ) => void;
+}
+
+const ReindexModal = ( {
+ reindexing,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ retryingJobId,
+ onClose,
+ onReIndex,
+ onCancelJob,
+ onRetryHistoryJob,
+ onPageChange,
+}: ReindexModalProps ) => (
+
+
+
+);
+
+export default ReindexModal;
diff --git a/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx
new file mode 100644
index 00000000..e9425fd8
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx
@@ -0,0 +1,157 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Button } from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import type { JobStatus, SiteJobState } from './types';
+import HistoryPagination from './HistoryPagination';
+import HistoryTable from './HistoryTable';
+import SiteRow from './SiteRow';
+
+interface ReindexModalContentProps {
+ reindexing: boolean;
+ siteStates: SiteJobState[];
+ cancelling: boolean;
+ history: JobStatus[];
+ historyPage: number;
+ historyTotalPages: number;
+ onReIndex: () => void;
+ onCancelJob: () => void;
+ retryingJobId: string | null;
+ onRetryHistoryJob: ( job: JobStatus ) => void;
+ onPageChange: ( page: number ) => void;
+}
+
+const ReindexModalContent = ( {
+ reindexing,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ onReIndex,
+ onCancelJob,
+ retryingJobId,
+ onRetryHistoryJob,
+ onPageChange,
+}: ReindexModalContentProps ) => (
+ <>
+ { ! reindexing && siteStates.length === 0 && (
+
+
+ { __(
+ 'Re-indexing will only index the entities you have previously saved. To re-index modified entities, please make sure you have saved them.',
+ 'onesearch'
+ ) }
+
+
+
+ { __( 'Re-index', 'onesearch' ) }
+
+
+
+ ) }
+
+ { reindexing && siteStates.length === 0 && (
+
+
+
+ { __(
+ 'Preparing entities for re-indexing. This may take a moment.',
+ 'onesearch'
+ ) }
+
+
+ ) }
+
+ { siteStates.length > 0 && (
+
+
+
+ { __( 'Index Job', 'onesearch' ) }{ ' ' }
+
+ { siteStates[ 0 ]?.reindexJob?.id?.substring(
+ 0,
+ 20
+ ) ||
+ siteStates[ 0 ]?.site?.job_id?.substring(
+ 0,
+ 20
+ ) ||
+ '…' }
+ …
+
+
+
+ { ( () => {
+ const totalBatches = siteStates.reduce(
+ ( sum, s ) => sum + s.children.length,
+ 0
+ );
+ const totalDone = siteStates.reduce(
+ ( sum, s ) =>
+ sum +
+ s.children.filter( ( c ) =>
+ [ 'completed', 'failed' ].includes(
+ c.status
+ )
+ ).length,
+ 0
+ );
+ if ( totalBatches > 0 ) {
+ return sprintf(
+ /* translators: 1: done batches, 2: total batches */
+ __( '%1$d / %2$d batches', 'onesearch' ),
+ totalDone,
+ totalBatches
+ );
+ }
+ return '';
+ } )() }
+
+
+ { cancelling
+ ? __( 'Cancelling…', 'onesearch' )
+ : __( 'Cancel Job', 'onesearch' ) }
+
+
+
+
+ { siteStates.map( ( s ) => (
+
+ ) ) }
+
+
+ ) }
+
+
+
+ { __( 'Sync Job History', 'onesearch' ) }
+
+
+
+
+
+
+ >
+);
+
+export default ReindexModalContent;
diff --git a/assets/src/components/SiteIndexableEntities/SiteRow.tsx b/assets/src/components/SiteIndexableEntities/SiteRow.tsx
new file mode 100644
index 00000000..e6fa82e0
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/SiteRow.tsx
@@ -0,0 +1,55 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { __experimentalText as Text } from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import type { SiteJobState } from './types';
+import JobStatusBadge from './JobStatusBadge';
+
+interface SiteProgressProps {
+ state: SiteJobState;
+}
+
+const SiteProgress = ( { state }: SiteProgressProps ) => {
+ const j = state.reindexJob;
+ if ( ! j ) {
+ return … ;
+ }
+ const childTotal = state.children.length;
+ if ( childTotal > 0 ) {
+ const done = state.children.filter( ( c ) =>
+ [ 'completed', 'failed' ].includes( c.status )
+ ).length;
+ return (
+
+ { done } / { childTotal } { __( 'batches', 'onesearch' ) }
+
+ );
+ }
+ return ;
+};
+
+interface SiteRowProps {
+ state: SiteJobState;
+}
+
+const SiteRow = ( { state }: SiteRowProps ) => (
+
+
+
+ { state.site.site_name }
+
+
+ { state.site.site_url }
+
+
+
+
+
+
+);
+
+export default SiteRow;
diff --git a/assets/src/components/SiteIndexableEntities/constants.ts b/assets/src/components/SiteIndexableEntities/constants.ts
new file mode 100644
index 00000000..67e7761b
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/constants.ts
@@ -0,0 +1,9 @@
+export const STATUS_LABELS: Record< string, string > = {
+ pending: 'Pending',
+ running: 'Running',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+};
+
+export const TERMINAL_JOB_STATUSES = [ 'completed', 'failed', 'cancelled' ];
diff --git a/assets/src/components/SiteIndexableEntities/index.tsx b/assets/src/components/SiteIndexableEntities/index.tsx
new file mode 100644
index 00000000..0a7deac7
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/index.tsx
@@ -0,0 +1,275 @@
+/**
+ * WordPress dependencies
+ */
+import { Button, Card, CardBody, CardHeader } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useState } from 'react';
+import type { NoticeType } from '@/admin/settings/page';
+import { API_NAMESPACE, NONCE, withTrailingSlash } from '@/js/utils';
+import type { OneSearchSharedSite } from '@/types/global';
+/**
+ * Internal dependencies
+ */
+import type { PostTypeOption } from '../SiteSearchSettings';
+import EntitySiteCard from './EntitySiteCard';
+import ReindexModal from './ReindexModal';
+import type {
+ EntitiesMap,
+ IndexableEntitiesResponse,
+ SaveEntitiesResponse,
+} from './types';
+import { normalizeEntities } from './utils';
+import { useReindexJob } from './useReindexJob';
+
+interface SiteIndexableEntitiesProps {
+ sites: OneSearchSharedSite[];
+ allPostTypes: Record< string, PostTypeOption[] >;
+ currentSiteUrl: string;
+ setNotice: ( notice: NoticeType | null ) => void;
+ onEntitiesSaved?: () => void;
+ saving: boolean;
+ setSaving: ( saving: boolean ) => void;
+}
+
+const SiteIndexableEntities = ( {
+ sites,
+ allPostTypes,
+ currentSiteUrl,
+ setNotice,
+ onEntitiesSaved,
+ saving,
+ setSaving,
+}: SiteIndexableEntitiesProps ) => {
+ const [ selectedEntities, setSelectedEntities ] = useState< EntitiesMap >(
+ {}
+ );
+ const [ savedEntities, setSavedEntities ] = useState< EntitiesMap >( {} );
+
+ const {
+ reindexing,
+ showReindexingModal,
+ setShowReindexingModal,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ retryingJobId,
+ handleReIndex,
+ handleCancelJob,
+ handleModalClose,
+ handleRetryHistoryJob,
+ fetchHistory,
+ } = useReindexJob( { currentSiteUrl, setNotice } );
+
+ const entitySelectorsDisabled = saving || reindexing;
+
+ const getIndexableEntities = useCallback( async () => {
+ try {
+ const response = await fetch(
+ `${ API_NAMESPACE }/indexable-entities`,
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': NONCE,
+ },
+ }
+ );
+ const data: IndexableEntitiesResponse = await response.json();
+ const incoming: EntitiesMap =
+ data.indexableEntities?.entities || {};
+ setSelectedEntities( incoming );
+ setSavedEntities( normalizeEntities( incoming ) );
+ } catch {
+ setNotice( {
+ type: 'error',
+ message: __(
+ 'Error fetching indexable entities.',
+ 'onesearch'
+ ),
+ } );
+ }
+ }, [ setNotice ] );
+
+ useEffect( () => {
+ getIndexableEntities();
+ }, [ getIndexableEntities ] );
+
+ const handleSelectedEntitiesChange = (
+ selected: string[],
+ url: string
+ ) => {
+ if ( entitySelectorsDisabled ) {
+ return;
+ }
+ setSelectedEntities( ( prev: EntitiesMap ) => ( {
+ ...prev,
+ [ url ]: selected,
+ } ) );
+ };
+
+ const handleSelectedEntitiesSave = async (
+ entities: EntitiesMap
+ ): Promise< boolean > => {
+ try {
+ setSaving( true );
+ const response = await fetch(
+ `${ API_NAMESPACE }/indexable-entities`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': NONCE,
+ },
+ body: JSON.stringify( { entities } ),
+ }
+ );
+
+ if ( ! response.ok ) {
+ throw new Error(
+ __( 'Network response was not ok.', 'onesearch' )
+ );
+ }
+
+ const data: SaveEntitiesResponse = await response.json();
+
+ if ( data.success ) {
+ setSavedEntities( normalizeEntities( entities ) );
+ onEntitiesSaved?.();
+ await handleReIndex();
+ return true;
+ } else if ( data.data?.status === 500 ) {
+ setNotice( {
+ message: __( 'Internal server error.', 'onesearch' ),
+ type: 'error',
+ } );
+ } else {
+ setNotice( {
+ message:
+ data.message || __( 'Unknown error.', 'onesearch' ),
+ type: 'error',
+ } );
+ }
+ } catch ( error: unknown ) {
+ const message =
+ error instanceof Error ? error.message : String( error );
+ setNotice( { message, type: 'error' } );
+ } finally {
+ setSaving( false );
+ }
+ return false;
+ };
+
+ const isDirty =
+ JSON.stringify( normalizeEntities( selectedEntities ) ) !==
+ JSON.stringify( savedEntities );
+
+ return (
+ <>
+
+
+
+ { __( 'Select Entities to Index', 'onesearch' ) }
+
+
+
+ {
+ setShowReindexingModal( ( prev ) => {
+ if ( ! prev ) {
+ fetchHistory( 1 );
+ }
+ return ! prev;
+ } );
+ } }
+ className="onesearch-btn-reindex"
+ >
+ { reindexing && (
+
+ ) }
+ { __( 'Re-index', 'onesearch' ) }
+
+
+ handleSelectedEntitiesSave(
+ selectedEntities
+ )
+ }
+ disabled={ ! isDirty || saving }
+ isBusy={ saving }
+ className="onesearch-btn-save-entities"
+ >
+ { saving
+ ? __( 'Saving…', 'onesearch' )
+ : __( 'Save Changes', 'onesearch' ) }
+
+
+
+ { __(
+ 'Saving changes will automatically re-index the data.',
+ 'onesearch'
+ ) }
+
+
+
+
+
+
+ handleSelectedEntitiesChange( next, currentSiteUrl )
+ }
+ />
+ { sites?.map( ( site: OneSearchSharedSite ) => (
+
+ handleSelectedEntitiesChange( next, site?.url )
+ }
+ />
+ ) ) }
+
+
+
+ { showReindexingModal && (
+ void handleReIndex() }
+ onCancelJob={ () => void handleCancelJob() }
+ onRetryHistoryJob={ ( job ) =>
+ void handleRetryHistoryJob( job )
+ }
+ onPageChange={ fetchHistory }
+ />
+ ) }
+ >
+ );
+};
+
+export default SiteIndexableEntities;
diff --git a/assets/src/components/SiteIndexableEntities/types.ts b/assets/src/components/SiteIndexableEntities/types.ts
new file mode 100644
index 00000000..72cc567c
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/types.ts
@@ -0,0 +1,73 @@
+export interface EntitiesMap {
+ [ siteUrl: string ]: string[];
+}
+
+export interface IndexableEntitiesResponse {
+ indexableEntities: {
+ entities: EntitiesMap;
+ };
+}
+
+export interface SaveEntitiesResponse {
+ success: boolean;
+ message?: string;
+ data?: {
+ status?: number;
+ };
+}
+
+export interface SiteJob {
+ site_name: string;
+ site_url: string;
+ job_id: string;
+}
+
+export interface ReIndexResponse {
+ success: boolean;
+ message?: string;
+ job_id?: string;
+ batch_count?: number;
+ jobs?: SiteJob[];
+ data?: {
+ status?: number;
+ };
+}
+
+export interface HistoryResponse {
+ success: boolean;
+ jobs: JobStatus[];
+ total: number;
+ page: number;
+ per_page: number;
+ total_pages: number;
+}
+
+export interface JobStatus {
+ id: string;
+ type?: string;
+ status: string;
+ progress: number;
+ progress_total: number;
+ progress_percent: number;
+ error?: string;
+ child_ids?: string[];
+ children_completed?: number;
+ children_failed?: number;
+ children_total?: number;
+ data?: {
+ total_batches?: number;
+ sites?: SiteJob[];
+ [ key: string ]: unknown;
+ };
+ group?: string;
+ created_at?: number;
+ updated_at?: number;
+ finished_at?: number;
+ cancelled_at?: number;
+}
+
+export interface SiteJobState {
+ site: SiteJob;
+ reindexJob: JobStatus | null;
+ children: JobStatus[];
+}
diff --git a/assets/src/components/SiteIndexableEntities/useReindexJob.ts b/assets/src/components/SiteIndexableEntities/useReindexJob.ts
new file mode 100644
index 00000000..d3b799f4
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/useReindexJob.ts
@@ -0,0 +1,537 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useRef, useState } from 'react';
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { NoticeType } from '@/admin/settings/page';
+import { API_NAMESPACE, NONCE, withTrailingSlash } from '@/js/utils';
+/**
+ * Internal dependencies
+ */
+import { TERMINAL_JOB_STATUSES } from './constants';
+import type {
+ HistoryResponse,
+ JobStatus,
+ ReIndexResponse,
+ SiteJob,
+ SiteJobState,
+} from './types';
+import { getHistorySites } from './utils';
+
+interface UseReindexJobParams {
+ currentSiteUrl: string;
+ setNotice: ( notice: NoticeType | null ) => void;
+}
+
+interface UseReindexJobReturn {
+ reindexing: boolean;
+ showReindexingModal: boolean;
+ setShowReindexingModal: React.Dispatch< React.SetStateAction< boolean > >;
+ siteStates: SiteJobState[];
+ cancelling: boolean;
+ history: JobStatus[];
+ historyPage: number;
+ historyTotalPages: number;
+ retryingJobId: string | null;
+ handleReIndex: () => Promise< boolean >;
+ handleCancelJob: () => Promise< void >;
+ handleModalClose: () => void;
+ handleRetryHistoryJob: ( job: JobStatus ) => Promise< void >;
+ fetchHistory: ( page: number ) => Promise< void >;
+}
+
+export const useReindexJob = ( {
+ currentSiteUrl,
+ setNotice,
+}: UseReindexJobParams ): UseReindexJobReturn => {
+ const [ reindexing, setReindexing ] = useState( false );
+ const [ showReindexingModal, setShowReindexingModal ] = useState( false );
+ const [ siteStates, setSiteStates ] = useState< SiteJobState[] >( [] );
+ const [ history, setHistory ] = useState< JobStatus[] >( [] );
+ const [ historyPage, setHistoryPage ] = useState( 1 );
+ const [ historyTotalPages, setHistoryTotalPages ] = useState( 0 );
+ const [ retryingJobId, setRetryingJobId ] = useState< string | null >(
+ null
+ );
+ const [ cancelling, setCancelling ] = useState( false );
+
+ const intervalRef = useRef< ReturnType< typeof setInterval > | null >(
+ null
+ );
+ const historyRetryIntervalRef = useRef< ReturnType<
+ typeof setInterval
+ > | null >( null );
+ const siteStatesRef = useRef< SiteJobState[] >( [] );
+
+ const stopPolling = useCallback( () => {
+ if ( intervalRef.current ) {
+ clearInterval( intervalRef.current );
+ intervalRef.current = null;
+ }
+ }, [] );
+
+ const stopHistoryRetryPolling = useCallback( () => {
+ if ( historyRetryIntervalRef.current ) {
+ clearInterval( historyRetryIntervalRef.current );
+ historyRetryIntervalRef.current = null;
+ }
+ }, [] );
+
+ const isCurrentSite = useCallback(
+ ( siteUrl: string ) => withTrailingSlash( siteUrl ) === currentSiteUrl,
+ [ currentSiteUrl ]
+ );
+
+ const fetchJobWithChildren = useCallback(
+ async (
+ jobId: string
+ ): Promise< {
+ reindexJob: JobStatus | null;
+ children: JobStatus[];
+ } | null > => {
+ try {
+ const [ jobRes, childRes ] = await Promise.all( [
+ fetch(
+ `${ API_NAMESPACE }/jobs/${ encodeURIComponent(
+ jobId
+ ) }`,
+ { headers: { 'X-WP-Nonce': NONCE } }
+ ),
+ fetch(
+ `${ API_NAMESPACE }/jobs/${ encodeURIComponent(
+ jobId
+ ) }/children`,
+ { headers: { 'X-WP-Nonce': NONCE } }
+ ),
+ ] );
+ const jobData = await jobRes.json();
+ const childData = await childRes.json();
+ if ( ! jobRes.ok || ! childRes.ok || ! jobData.job ) {
+ return null;
+ }
+ return {
+ reindexJob: jobData.job,
+ children: childData.children || [],
+ };
+ } catch {
+ return null;
+ }
+ },
+ []
+ );
+
+ const fetchRemoteJobStatus = useCallback(
+ async (
+ siteUrl: string,
+ jobId: string
+ ): Promise< {
+ reindexJob: JobStatus | null;
+ children: JobStatus[];
+ } | null > => {
+ try {
+ const params = new URLSearchParams( {
+ site_url: siteUrl,
+ job_id: jobId,
+ } );
+ const res = await fetch(
+ `${ API_NAMESPACE }/jobs/remote-status?${ params.toString() }`,
+ { headers: { 'X-WP-Nonce': NONCE } }
+ );
+ const data = await res.json();
+ if ( ! res.ok || ! data.job ) {
+ return null;
+ }
+ return {
+ reindexJob: data.job,
+ children: data.children || [],
+ };
+ } catch {
+ return null;
+ }
+ },
+ []
+ );
+
+ const fetchHistory = useCallback( async ( page: number ) => {
+ try {
+ const url = new URL(
+ `${ API_NAMESPACE }/jobs/history`,
+ window.location.origin
+ );
+ url.searchParams.set( 'page', String( page ) );
+ url.searchParams.set( 'per_page', '5' );
+ const res = await fetch( url.toString(), {
+ headers: { 'X-WP-Nonce': NONCE },
+ } );
+ const data: HistoryResponse = await res.json();
+ setHistory( data.jobs || [] );
+ setHistoryPage( data.page || 1 );
+ setHistoryTotalPages( data.total_pages || 0 );
+ } catch {
+ // History is secondary to the progress view, so a failed load keeps the table as it was.
+ }
+ }, [] );
+
+ const pollAllSites = useCallback( async () => {
+ const current = siteStatesRef.current;
+ if ( current.length === 0 ) {
+ return;
+ }
+
+ const updated = await Promise.all(
+ current.map( async ( s ) => {
+ if ( ! s.site.job_id ) {
+ return s;
+ }
+ if ( isCurrentSite( s.site.site_url ) ) {
+ const result = await fetchJobWithChildren( s.site.job_id );
+ if ( result ) {
+ return { ...s, ...result };
+ }
+ return s;
+ }
+ const result = await fetchRemoteJobStatus(
+ s.site.site_url,
+ s.site.job_id
+ );
+ if ( result ) {
+ return { ...s, ...result };
+ }
+ return s;
+ } )
+ );
+
+ setSiteStates( updated );
+
+ const allTerminal = updated.every( ( s ) => {
+ // A job that hasn't been read yet, or whose read failed, may still be running.
+ if ( ! s.reindexJob ) {
+ return ! s.site.job_id;
+ }
+ if ( TERMINAL_JOB_STATUSES.includes( s.reindexJob.status ) ) {
+ return true;
+ }
+ if ( s.children.length > 0 ) {
+ return s.children.every( ( c ) =>
+ TERMINAL_JOB_STATUSES.includes( c.status )
+ );
+ }
+ return false;
+ } );
+
+ if ( allTerminal ) {
+ stopPolling();
+ setReindexing( false );
+ setSiteStates( [] );
+ fetchHistory( 1 );
+ }
+ }, [
+ isCurrentSite,
+ fetchJobWithChildren,
+ fetchRemoteJobStatus,
+ stopPolling,
+ fetchHistory,
+ ] );
+
+ useEffect( () => {
+ siteStatesRef.current = siteStates;
+ }, [ siteStates ] );
+
+ useEffect( () => {
+ return () => {
+ stopPolling();
+ stopHistoryRetryPolling();
+ };
+ }, [] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // On mount, resume any reindex that was running before a page refresh.
+ useEffect( () => {
+ const restoreReindexState = async () => {
+ try {
+ const res = await fetch( `${ API_NAMESPACE }/re-index/status`, {
+ headers: { 'X-WP-Nonce': NONCE },
+ } );
+ const data = await res.json();
+ if ( data.success && data.active && data.jobs?.length ) {
+ const initial: SiteJobState[] = data.jobs.map(
+ ( site: SiteJob ) => ( {
+ site,
+ reindexJob: null,
+ children: [],
+ } )
+ );
+ setReindexing( true );
+ setShowReindexingModal( true );
+ setSiteStates( initial );
+ siteStatesRef.current = initial;
+ const interval = setInterval( () => pollAllSites(), 2000 );
+ intervalRef.current = interval;
+ pollAllSites();
+ fetchHistory( 1 );
+ }
+ } catch {
+ // Silently ignore — the status endpoint is best-effort.
+ }
+ };
+ restoreReindexState();
+ }, [] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const retryJob = async ( jobId: string, siteUrl: string ) => {
+ try {
+ const isLocalRetry = isCurrentSite( siteUrl );
+ const res = isLocalRetry
+ ? await fetch(
+ `${ API_NAMESPACE }/jobs/${ encodeURIComponent(
+ jobId
+ ) }/retry`,
+ {
+ method: 'POST',
+ headers: { 'X-WP-Nonce': NONCE },
+ }
+ )
+ : await fetch( `${ API_NAMESPACE }/jobs/remote-retry`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': NONCE,
+ },
+ body: JSON.stringify( {
+ site_url: siteUrl,
+ job_id: jobId,
+ } ),
+ } );
+ return await res.json();
+ } catch {
+ return {
+ success: false,
+ message: __( 'Retry request failed.', 'onesearch' ),
+ };
+ }
+ };
+
+ const fetchHistoryDetailsForJob = async (
+ job: JobStatus
+ ): Promise< SiteJobState[] > =>
+ Promise.all(
+ getHistorySites( job, currentSiteUrl ).map( async ( site ) => {
+ if ( ! site.job_id ) {
+ return {
+ site,
+ reindexJob: null,
+ children: [],
+ };
+ }
+
+ const result = isCurrentSite( site.site_url )
+ ? await fetchJobWithChildren( site.job_id )
+ : await fetchRemoteJobStatus( site.site_url, site.job_id );
+
+ return {
+ site,
+ reindexJob:
+ result?.reindexJob ||
+ ( site.job_id === job.id ? job : null ),
+ children: result?.children || [],
+ };
+ } )
+ );
+
+ const refreshHistoryRetryDetails = async ( job: JobStatus ) => {
+ const siteDetails = await fetchHistoryDetailsForJob( job );
+
+ const allTerminal = siteDetails.every( ( state ) => {
+ if ( state.children.length > 0 ) {
+ return state.children.every( ( child ) =>
+ TERMINAL_JOB_STATUSES.includes( child.status )
+ );
+ }
+ return state.reindexJob
+ ? TERMINAL_JOB_STATUSES.includes( state.reindexJob.status )
+ : ! state.site.job_id;
+ } );
+
+ if ( allTerminal ) {
+ stopHistoryRetryPolling();
+ setRetryingJobId( null );
+ fetchHistory( historyPage );
+ }
+ };
+
+ const handleRetryHistoryJob = async ( job: JobStatus ) => {
+ if ( retryingJobId ) {
+ return;
+ }
+
+ setRetryingJobId( job.id );
+
+ // History rows carry only the top-level job, so the per-site jobs are fetched to find which have failures.
+ const siteDetails = await fetchHistoryDetailsForJob( job );
+ const jobsToRetry = siteDetails.filter(
+ ( state ) =>
+ state.site.job_id &&
+ ( state.reindexJob?.status === 'failed' ||
+ state.children.some(
+ ( child ) => child.status === 'failed'
+ ) )
+ );
+
+ if ( jobsToRetry.length === 0 ) {
+ setRetryingJobId( null );
+ setNotice( {
+ type: 'error',
+ message: __( 'No failed batches to retry.', 'onesearch' ),
+ } );
+ return;
+ }
+
+ const results = await Promise.all(
+ jobsToRetry.map( ( state ) =>
+ retryJob( state.site.job_id, state.site.site_url )
+ )
+ );
+
+ const failedResult = results.find( ( result ) => ! result.success );
+ if ( failedResult ) {
+ setRetryingJobId( null );
+ setNotice( {
+ type: 'error',
+ message:
+ failedResult.message || __( 'Retry failed.', 'onesearch' ),
+ } );
+ return;
+ }
+
+ setNotice( {
+ type: 'success',
+ message: __( 'Failed batches retry scheduled.', 'onesearch' ),
+ } );
+
+ stopHistoryRetryPolling();
+ historyRetryIntervalRef.current = setInterval( () => {
+ void refreshHistoryRetryDetails( job );
+ }, 2000 );
+ void refreshHistoryRetryDetails( job );
+ };
+
+ const handleReIndex = async (): Promise< boolean > => {
+ try {
+ setReindexing( true );
+ setSiteStates( [] );
+ stopPolling();
+ fetchHistory( 1 );
+
+ const response = await fetch( `${ API_NAMESPACE }/re-index`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': NONCE,
+ },
+ } );
+
+ const data: ReIndexResponse = await response.json();
+ // A site that failed to start doesn't stop the others, so the runs that did start are still followed.
+ if ( data.jobs && data.jobs.length > 0 ) {
+ if ( ! data.success ) {
+ setNotice( {
+ message:
+ data.message || __( 'Unknown error.', 'onesearch' ),
+ type: 'error',
+ } );
+ }
+ const initial: SiteJobState[] = data.jobs.map( ( site ) => ( {
+ site,
+ reindexJob: null,
+ children: [],
+ } ) );
+ setSiteStates( initial );
+ siteStatesRef.current = initial;
+ const interval = setInterval( () => pollAllSites(), 2000 );
+ intervalRef.current = interval;
+ pollAllSites();
+ return true;
+ }
+
+ const errorMsg =
+ data.message || __( 'Unknown error.', 'onesearch' );
+ setReindexing( false );
+ setShowReindexingModal( false );
+ setNotice( { message: errorMsg, type: 'error' } );
+ } catch ( error: unknown ) {
+ const message =
+ error instanceof Error ? error.message : String( error );
+ setReindexing( false );
+ setShowReindexingModal( false );
+ setNotice( { message, type: 'error' } );
+ }
+ return false;
+ };
+
+ const handleModalClose = () => {
+ setShowReindexingModal( false );
+ };
+
+ const handleCancelJob = async () => {
+ setCancelling( true );
+ const activeJobs = siteStates.filter(
+ ( s ) =>
+ s.site.job_id &&
+ ( ! s.reindexJob ||
+ ! [ 'completed', 'failed', 'cancelled' ].includes(
+ s.reindexJob.status
+ ) )
+ );
+
+ await Promise.all(
+ activeJobs.map( ( s ) => {
+ if ( isCurrentSite( s.site.site_url ) ) {
+ return fetch(
+ `${ API_NAMESPACE }/jobs/${ encodeURIComponent(
+ s.site.job_id
+ ) }`,
+ {
+ method: 'DELETE',
+ headers: { 'X-WP-Nonce': NONCE },
+ }
+ ).catch( () => {} );
+ }
+ return fetch( `${ API_NAMESPACE }/jobs/remote-cancel`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': NONCE,
+ },
+ body: JSON.stringify( {
+ site_url: s.site.site_url,
+ job_id: s.site.job_id,
+ } ),
+ } ).catch( () => {} );
+ } )
+ );
+
+ stopPolling();
+ setReindexing( false );
+ setSiteStates( [] );
+ setCancelling( false );
+ fetchHistory( 1 );
+ };
+
+ return {
+ reindexing,
+ showReindexingModal,
+ setShowReindexingModal,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ retryingJobId,
+ handleReIndex,
+ handleCancelJob,
+ handleModalClose,
+ handleRetryHistoryJob,
+ fetchHistory,
+ };
+};
diff --git a/assets/src/components/SiteIndexableEntities/utils.ts b/assets/src/components/SiteIndexableEntities/utils.ts
new file mode 100644
index 00000000..ae752cdb
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/utils.ts
@@ -0,0 +1,85 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import type { PostTypeOption } from '../SiteSearchSettings';
+import type { EntitiesMap, JobStatus, SiteJob } from './types';
+
+export const formatDuration = ( seconds: number ): string => {
+ if ( seconds < 1 ) {
+ return sprintf(
+ /* translators: %d: milliseconds */
+ __( '%dms', 'onesearch' ),
+ Math.round( seconds * 1000 )
+ );
+ }
+ if ( seconds < 60 ) {
+ return sprintf(
+ /* translators: %d: seconds */
+ __( '%ds', 'onesearch' ),
+ Math.floor( seconds )
+ );
+ }
+ const mins = Math.floor( seconds / 60 );
+ const secs = seconds % 60;
+ if ( mins < 60 ) {
+ return sprintf(
+ /* translators: 1: minutes, 2: seconds */
+ __( '%1$dm %2$ds', 'onesearch' ),
+ mins,
+ secs
+ );
+ }
+ const hrs = Math.floor( mins / 60 );
+ const remainingMins = mins % 60;
+ return sprintf(
+ /* translators: 1: hours, 2: minutes */
+ __( '%1$dh %2$dm', 'onesearch' ),
+ hrs,
+ remainingMins
+ );
+};
+
+export const formatTimestamp = ( ts?: number ): string =>
+ ts ? new Date( ts * 1000 ).toLocaleString() : '—';
+
+export const normalizeEntities = ( map: EntitiesMap = {} ): EntitiesMap => {
+ const results: EntitiesMap = {};
+ Object.keys( map || {} )
+ .sort()
+ .forEach( ( site ) => {
+ const arr = Array.isArray( map[ site ] ) ? map[ site ] : [];
+ const clean = Array.from( new Set( arr.map( String ) ) ).sort();
+ results[ site ] = clean;
+ } );
+ return results;
+};
+
+export const toMultiSelectOptions = (
+ options: PostTypeOption[]
+): Array< { slug: string; label: string; restBase: string } > =>
+ options.map( ( opt ) => ( {
+ slug: opt.slug,
+ label: opt.label ?? opt.slug,
+ restBase: opt.restBase ?? opt.slug,
+ } ) );
+
+export const getHistorySites = (
+ job: JobStatus,
+ currentSiteUrl: string
+): SiteJob[] => {
+ const sitesFromJob = job.data?.sites;
+ if ( Array.isArray( sitesFromJob ) && sitesFromJob.length > 0 ) {
+ return sitesFromJob;
+ }
+ return [
+ {
+ site_name: __( 'Governing Site', 'onesearch' ),
+ site_url: currentSiteUrl,
+ job_id: job.id,
+ },
+ ];
+};
diff --git a/assets/src/css/admin.scss b/assets/src/css/admin.scss
index 9a12f9a3..15eda686 100644
--- a/assets/src/css/admin.scss
+++ b/assets/src/css/admin.scss
@@ -336,6 +336,26 @@ body {
.onesearch-entities-controls {
display: flex;
gap: 8px;
+ align-items: center;
+}
+
+.onesearch-btn-spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(0, 0, 0, 0.2);
+ border-top-color: currentcolor;
+ border-radius: 50%;
+ animation: onesearch-spin 0.6s linear infinite;
+ margin-right: 4px;
+ vertical-align: middle;
+}
+
+@keyframes onesearch-spin {
+
+ to {
+ transform: rotate(360deg);
+ }
}
/* Individual sites */
@@ -441,3 +461,288 @@ body:has(.onesearch-setup-overlay) .onesearch-search-content {
.onesearch-button-group {
margin-right: 8px;
}
+
+.onesearch-job-status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 8px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ line-height: 1.4;
+}
+
+.onesearch-job-status--pending {
+ background: #f0b849;
+ color: #1e1e1e;
+}
+
+.onesearch-job-status--running {
+ background: #007cba;
+ color: #fff;
+}
+
+.onesearch-job-status--completed {
+ background: #16a32d;
+ color: #fff;
+}
+
+.onesearch-job-status--failed {
+ background: #e11d1d;
+ color: #fff;
+}
+
+.onesearch-job-status--cancelled {
+ background: #9ca3af;
+ color: #fff;
+}
+
+.onesearch-job-progress-text {
+ font-size: 13px;
+ color: #666;
+}
+
+/* ─── Hierarchical Job Panel ─── */
+
+.onesearch-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 24px;
+}
+
+// The text wraps beside the action button instead of pushing it below.
+.onesearch-reindex-intro {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.onesearch-reindex-intro-text {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+}
+
+.onesearch-reindex-intro .onesearch-modal-actions {
+ flex-shrink: 0;
+ margin-top: 0;
+}
+
+.onesearch-job-panel-header .onesearch-btn-cancel {
+ background: #d63638 !important;
+ border-color: #d63638 !important;
+ color: #fff !important;
+ box-shadow: none !important;
+}
+
+.onesearch-job-panel-header .onesearch-btn-cancel:hover {
+ background: #a02a2c !important;
+ border-color: #a02a2c !important;
+ color: #fff !important;
+}
+
+// Re-index loading screen inside the modal.
+.onesearch-reindex-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ gap: 16px;
+}
+
+.onesearch-reindex-spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid #e0e0e0;
+ border-top-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ border-radius: 50%;
+ animation: onesearch-spin 0.7s linear infinite;
+}
+
+.onesearch-reindex-loading-text {
+ margin: 0;
+ font-size: 13px;
+ color: #757575;
+}
+
+.onesearch-job-panel {
+ margin: 0 -24px;
+}
+
+.onesearch-job-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 24px;
+ border-bottom: 1px solid #e0e0e0;
+ background: #f8f9fa;
+}
+
+.onesearch-job-panel-header h3 {
+ margin: 0;
+ font-size: 15px;
+}
+
+.onesearch-job-panel-header code {
+ font-size: 12px;
+ color: #666;
+}
+
+.onesearch-job-panel-total {
+ font-size: 13px;
+ color: #555;
+ font-weight: 500;
+ margin-left: auto;
+ margin-right: 12px;
+}
+
+.onesearch-job-panel-body {
+ padding: 0;
+}
+
+/* Site row */
+.onesearch-job-site-row {
+ border-bottom: 1px solid #eee;
+}
+
+.onesearch-job-site-row:last-child {
+ border-bottom: none;
+}
+
+.onesearch-job-site-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 24px;
+}
+
+.onesearch-job-site-name {
+ font-weight: 600;
+ font-size: 14px;
+ min-width: 140px;
+}
+
+.onesearch-job-site-url {
+ flex: 1;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.onesearch-job-site-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Small status badges */
+.onesearch-job-status--small {
+ font-size: 10px;
+ padding: 1px 6px;
+}
+
+/* History section */
+.onesearch-job-history {
+ margin-top: 16px;
+ border-top: 1px solid #e0e0e0;
+ padding-top: 12px;
+}
+
+.onesearch-job-history-header {
+ font-size: 14px;
+ font-weight: 600;
+ padding: 4px 0;
+ color: #1d2327;
+}
+
+.onesearch-history-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+
+ thead th {
+ text-align: left;
+ padding: 8px 12px;
+ font-weight: 600;
+ color: #1d2327;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ }
+
+ tbody td {
+ padding: 10px 12px;
+ vertical-align: middle;
+ white-space: nowrap;
+
+ code {
+ font-size: 11px;
+ }
+ }
+
+ tbody tr + tr td {
+ border-top: 1px solid #f0f0f0;
+ }
+
+ td:first-child,
+ th:first-child {
+ padding-left: 0;
+ }
+
+ td:last-child,
+ th:last-child {
+ padding-right: 0;
+ }
+}
+
+.onesearch-history-actions-col {
+ width: 1%;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.onesearch-job-history .onesearch-job-panel-body {
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 0 4px;
+}
+
+// History pagination.
+.onesearch-history-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 12px 0 4px;
+
+ .components-button {
+ min-width: 32px;
+ height: 32px;
+ padding: 0 8px;
+ justify-content: center;
+ font-size: 13px;
+ border-radius: 4px;
+ }
+
+ & &-current.components-button {
+ background: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ color: #fff;
+ pointer-events: none;
+ }
+
+ & &-ellipsis {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ font-size: 14px;
+ color: #757575;
+ user-select: none;
+ }
+}
diff --git a/composer.json b/composer.json
index 65da01c1..df717332 100644
--- a/composer.json
+++ b/composer.json
@@ -57,7 +57,8 @@
],
"require": {
"php": "^8.2",
- "algolia/algoliasearch-client-php": "^4.0"
+ "algolia/algoliasearch-client-php": "^4.0",
+ "woocommerce/action-scheduler": "^3.9"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.2",
diff --git a/composer.lock b/composer.lock
index 0078b7c2..f443297a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e80b4afd596a0fe2d41a9bb43ec17f06",
+ "content-hash": "9d753220b311e2b5a40eab7a84ee2b52",
"packages": [
{
"name": "algolia/algoliasearch-client-php",
@@ -565,6 +565,49 @@
}
],
"time": "2026-05-26T12:45:58+00:00"
+ },
+ {
+ "name": "woocommerce/action-scheduler",
+ "version": "3.9.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/woocommerce/action-scheduler.git",
+ "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c",
+ "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5",
+ "woocommerce/woocommerce-sniffs": "0.1.0",
+ "wp-cli/wp-cli": "~2.5.0",
+ "yoast/phpunit-polyfills": "^2.0"
+ },
+ "type": "wordpress-plugin",
+ "extra": {
+ "scripts-description": {
+ "test": "Run unit tests",
+ "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
+ "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-3.0-or-later"
+ ],
+ "description": "Action Scheduler for WordPress and WooCommerce",
+ "homepage": "https://actionscheduler.org/",
+ "support": {
+ "issues": "https://github.com/woocommerce/action-scheduler/issues",
+ "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3"
+ },
+ "time": "2025-07-15T09:32:30+00:00"
}
],
"packages-dev": [
diff --git a/inc/Main.php b/inc/Main.php
index b43f59b1..83b23d95 100644
--- a/inc/Main.php
+++ b/inc/Main.php
@@ -10,6 +10,7 @@
namespace OneSearch;
use OneSearch\Contracts\Traits\Singleton;
+use OneSearch\Modules\Schema\Job_Schema;
/**
* Class - Main
@@ -26,6 +27,7 @@ final class Main {
private const REGISTRABLE_CLASSES = [
Modules\Core\Assets::class,
Modules\Core\Rest::class,
+ Modules\Scheduler\Bootstrap::class,
Modules\Settings\Admin::class,
Modules\Settings\Settings::class,
Modules\Search\Admin::class,
@@ -34,6 +36,7 @@ final class Main {
Modules\Search\Watcher::class,
Modules\Rest\Basic_Options_Controller::class,
Modules\Rest\Governing_Data_Controller::class,
+ Modules\Scheduler\Job_REST_Controller::class,
Modules\Rest\Search_Controller::class,
];
@@ -103,6 +106,9 @@ static function () {
* Load the plugin classes.
*/
private function load(): void {
+ // Fires once after install or schema version bump; no-ops on every other request.
+ Job_Schema::maybe_upgrade();
+
// Loop through all the classes, instantiate them, and register any hooks.
$instances = [];
foreach ( self::REGISTRABLE_CLASSES as $class_name ) {
diff --git a/inc/Modules/Jobs/Abstract_Job.php b/inc/Modules/Jobs/Abstract_Job.php
new file mode 100644
index 00000000..e287de9e
--- /dev/null
+++ b/inc/Modules/Jobs/Abstract_Job.php
@@ -0,0 +1,647 @@
+
+ */
+ protected array $data = [];
+
+ /**
+ * Maximum number of retry attempts after the initial run.
+ * With max_retries=3, the job can run up to 4 times total.
+ *
+ * @var int
+ */
+ protected int $max_retries = 3;
+
+ /**
+ * Current retry attempt number (0 = first run, 1 = first retry, etc.).
+ *
+ * @var int
+ */
+ protected int $retry_count = 0;
+
+ /**
+ * Base delay in seconds between retries. Actual delay = retry_delay_seconds × 2^(retry_count - 1).
+ *
+ * @var int
+ */
+ protected int $retry_delay_seconds = 60;
+
+ /**
+ * Action Scheduler group name for this job. Used for filtering and cancellation.
+ * Prefixed with "onesearch_" when registering with Action Scheduler.
+ *
+ * @var string
+ */
+ protected string $group = 'default';
+
+ /**
+ * ID of the parent job, if this is a child job in a composite workflow.
+ * Set by Reindex_Job when creating Sync_Job children.
+ *
+ * @var string|null
+ */
+ protected ?string $parent_id = null;
+
+ /**
+ * IDs of all child jobs spawned by this parent job.
+ * Used by Reindex_Job to track its Sync_Job children.
+ *
+ * @var string[]
+ */
+ protected array $child_ids = [];
+
+ /**
+ * Number of child jobs that have completed (success or failure).
+ * Incremented by Job_Scheduler::notify_parent() when a child finishes.
+ *
+ * @var int
+ */
+ protected int $children_completed = 0;
+
+ /**
+ * Number of child jobs that have failed.
+ * Once all children finish, any failure fails the parent unless one was cancelled.
+ *
+ * @var int
+ */
+ protected int $children_failed = 0;
+
+ /**
+ * Number of child jobs that were cancelled.
+ * Once all children finish, any cancellation cancels the parent.
+ *
+ * @var int
+ */
+ protected int $children_cancelled = 0;
+
+ /**
+ * Unix timestamp when the job reached a terminal state, null while active.
+ *
+ * @var int|null
+ */
+ protected ?int $finished_at = null;
+
+ /**
+ * Unix timestamp when the job was created.
+ *
+ * @var int
+ */
+ protected int $created_at;
+
+ /**
+ * Unix timestamp when the job was last updated.
+ *
+ * @var int
+ */
+ protected int $updated_at;
+
+ /**
+ * Unix timestamp when the job was cancelled, null if not cancelled.
+ *
+ * @var int|null
+ */
+ protected ?int $cancelled_at = null;
+
+ /**
+ * Initialize a new job with a unique ID and current timestamps.
+ *
+ * Called when creating a job programmatically before scheduling.
+ * When reconstructing from storage, from_array() bypasses this
+ * via ReflectionClass::newInstanceWithoutConstructor().
+ */
+ public function __construct() {
+ $this->id = wp_generate_uuid4();
+ $this->created_at = time();
+ $this->updated_at = time();
+ }
+
+ /**
+ * Execute the job's concrete logic.
+ *
+ * Called by Job_Scheduler::execute_job() after the job has been
+ * loaded from storage and marked as RUNNING. Implementations
+ * should call update_progress() as they process units of work.
+ *
+ * Throwing an exception triggers the retry mechanism in execute_job().
+ *
+ * @throws \Throwable On any failure; caught by Job_Scheduler for retry logic.
+ */
+ abstract public function handle(): void;
+
+ /**
+ * Return the job type identifier (e.g. "sync", "reindex").
+ */
+ abstract public static function get_type(): string;
+
+ /**
+ * Get the unique job identifier.
+ *
+ * @return string UUID4 string (e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479").
+ */
+ public function get_id(): string {
+ return $this->id;
+ }
+
+ /**
+ * Get the current lifecycle status.
+ *
+ * @return string One of the STATUS_* constants.
+ */
+ public function get_status(): string {
+ return $this->status;
+ }
+
+ /**
+ * Get the number of completed work units.
+ *
+ * @return int E.g. 5 if 5 out of 10 posts have been synced.
+ */
+ public function get_progress(): int {
+ return $this->progress;
+ }
+
+ /**
+ * Get the total number of work units to complete.
+ *
+ * @return int E.g. 10 if there are 10 posts to sync.
+ */
+ public function get_progress_total(): int {
+ return $this->progress_total;
+ }
+
+ /**
+ * Get the progress as a percentage (0.0–100.0).
+ *
+ * @return float Rounded to 1 decimal place. Returns 0 if progress_total is <= 0.
+ */
+ public function get_progress_percent(): float {
+ if ( $this->progress_total <= 0 ) {
+ return 0;
+ }
+
+ return round( $this->progress / $this->progress_total * 100, 1 );
+ }
+
+ /**
+ * Get the error message if the job has failed.
+ *
+ * @return string|null The error message, or null if the job hasn't failed.
+ */
+ public function get_error(): ?string {
+ return $this->error;
+ }
+
+ /**
+ * Get the Action Scheduler group for this job.
+ *
+ * @return string The group name (e.g. "reindex", "reindex_job_cba321").
+ */
+ public function get_group(): string {
+ return $this->group;
+ }
+
+ /**
+ * Get the maximum number of retry attempts allowed.
+ *
+ * @return int Number of retries after the initial run (0 = no retries).
+ */
+ public function get_max_retries(): int {
+ return $this->max_retries;
+ }
+
+ /**
+ * Get the current retry attempt number.
+ *
+ * @return int 0 = first run, 1 = first retry, etc.
+ */
+ public function get_retry_count(): int {
+ return $this->retry_count;
+ }
+
+ /**
+ * Get the base delay in seconds between retry attempts.
+ *
+ * Actual delay is exponential: retry_delay_seconds × 2^(retry_count - 1).
+ *
+ * @return int Seconds.
+ */
+ public function get_retry_delay_seconds(): int {
+ return $this->retry_delay_seconds;
+ }
+
+ /**
+ * Get the parent job ID, if this is a child job.
+ *
+ * @return string|null The parent's job ID, or null for top-level jobs.
+ */
+ public function get_parent_id(): ?string {
+ return $this->parent_id;
+ }
+
+ /**
+ * Get all child job IDs spawned by this parent job.
+ *
+ * @return string[] Array of child job IDs.
+ */
+ public function get_child_ids(): array {
+ return $this->child_ids;
+ }
+
+ /**
+ * Get the job's data payload.
+ *
+ * @return array The data payload.
+ */
+ public function get_data(): array {
+ return $this->data;
+ }
+
+ /**
+ * Set the lifecycle status and touch the updated_at timestamp.
+ *
+ * Reopening a finished job clears its finished_at. Use mark_completed() and fail() to finish one, since they
+ * also record the progress, error and finish time.
+ *
+ * @param string $status One of the STATUS_* constants.
+ */
+ public function set_status( string $status ): void {
+ $was_terminal = in_array( $this->status, [ self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED ], true );
+ $is_terminal = in_array( $status, [ self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED ], true );
+
+ if ( $was_terminal && ! $is_terminal ) {
+ $this->finished_at = null;
+ }
+
+ $this->status = $status;
+ $this->updated_at = time();
+ }
+
+ /**
+ * Set the current progress value, clamped to [0, progress_total].
+ *
+ * @param int $progress Number of completed work units.
+ * @return $this
+ */
+ public function set_progress( int $progress ): self {
+ $this->progress = max( 0, min( $progress, $this->progress_total ) );
+ $this->updated_at = time();
+ return $this;
+ }
+
+ /**
+ * Set the total number of work units, minimum 1.
+ *
+ * @param int $total Total work units (must be >= 1).
+ * @return $this
+ */
+ public function set_progress_total( int $total ): self {
+ $this->progress_total = max( 1, $total );
+ $this->updated_at = time();
+ return $this;
+ }
+
+ /**
+ * Set the job's data payload with arbitrary configuration.
+ *
+ * @param array $data Key-value configuration for the job.
+ * @return $this
+ */
+ public function set_data( array $data ): self {
+ $this->data = $data;
+ return $this;
+ }
+
+ /**
+ * Set the maximum number of retry attempts (0 = no retries).
+ *
+ * @param int $max_retries Maximum retries after initial run.
+ * @return $this
+ */
+ public function set_max_retries( int $max_retries ): self {
+ $this->max_retries = max( 0, $max_retries );
+ return $this;
+ }
+
+ /**
+ * Set the current retry attempt number.
+ *
+ * @param int $retry_count Current retry attempt (0 = first run).
+ * @return $this
+ */
+ public function set_retry_count( int $retry_count ): self {
+ $this->retry_count = max( 0, $retry_count );
+ return $this;
+ }
+
+ /**
+ * Set the base delay in seconds between retry attempts.
+ *
+ * @param int $seconds Minimum 1 second.
+ * @return $this
+ */
+ public function set_retry_delay_seconds( int $seconds ): self {
+ $this->retry_delay_seconds = max( 1, $seconds );
+ return $this;
+ }
+
+ /**
+ * Set the Action Scheduler group name.
+ *
+ * @param string $group Group name (e.g. "reindex", "reindex_job_abc123").
+ * @return $this
+ */
+ public function set_group( string $group ): self {
+ $this->group = $group;
+ return $this;
+ }
+
+ /**
+ * Set the parent job ID to establish a parent-child relationship.
+ *
+ * @param string|null $parent_id The parent's job ID, or null for top-level.
+ * @return $this
+ */
+ public function set_parent_id( ?string $parent_id ): self {
+ $this->parent_id = $parent_id;
+ return $this;
+ }
+
+ /**
+ * Replace the entire list of child job IDs.
+ *
+ * @param string[] $child_ids Array of child job IDs.
+ * @return $this
+ */
+ public function set_child_ids( array $child_ids ): self {
+ $this->child_ids = $child_ids;
+ return $this;
+ }
+
+ /**
+ * Add a single child job ID, preventing duplicates.
+ *
+ * @param string $child_id The child job's ID.
+ * @return $this
+ */
+ public function add_child_id( string $child_id ): self {
+ if ( ! in_array( $child_id, $this->child_ids, true ) ) {
+ $this->child_ids[] = $child_id;
+ }
+ return $this;
+ }
+
+ /**
+ * Update progress to a specific value, clamped to [0, progress_total].
+ *
+ * @param int $progress Number of completed work units.
+ * @return $this
+ */
+ public function update_progress( int $progress ): self {
+ $this->progress = max( 0, min( $progress, $this->progress_total ) );
+ $this->updated_at = time();
+ return $this;
+ }
+
+ /**
+ * Describe an error for a job's error message, including the underlying cause when there is one.
+ *
+ * The indexer keeps Algolia's own message in the error data, which is what explains a failure.
+ *
+ * @param \WP_Error $error The error.
+ */
+ protected static function describe_error( \WP_Error $error ): string {
+ $data = $error->get_error_data();
+
+ return trim( sprintf( '%s %s', $error->get_error_message(), $data['message'] ?? '' ) );
+ }
+
+ /**
+ * Mark the job as FAILED with an error message.
+ *
+ * @param string $error Description of what went wrong.
+ */
+ public function fail( string $error ): void {
+ $this->status = self::STATUS_FAILED;
+ $this->error = $error;
+ $this->finished_at = time();
+ $this->updated_at = time();
+ }
+
+ /**
+ * Clear the error message, used when retrying a failed job.
+ */
+ public function clear_error(): void {
+ $this->error = null;
+ $this->updated_at = time();
+ }
+
+ /**
+ * Mark the job as RUNNING and touch updated_at.
+ */
+ public function mark_running(): void {
+ $this->status = self::STATUS_RUNNING;
+ $this->updated_at = time();
+ }
+
+ /**
+ * Mark the job as COMPLETED and set progress to 100%.
+ */
+ public function mark_completed(): void {
+ $this->status = self::STATUS_COMPLETED;
+ $this->progress = $this->progress_total;
+ $this->finished_at = time();
+ $this->updated_at = time();
+ }
+
+ /**
+ * Check if this parent job still has child jobs that haven't finished.
+ *
+ * @return bool True if at least one child hasn't completed yet.
+ */
+ public function has_pending_children(): bool {
+ return ! empty( $this->child_ids ) && $this->children_completed < count( $this->child_ids );
+ }
+
+ /**
+ * Check if the job is in a terminal state (no further execution will occur).
+ *
+ * @return bool True if status is COMPLETED, FAILED, or CANCELLED.
+ */
+ public function is_finished(): bool {
+ return in_array( $this->status, [ self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED ], true );
+ }
+
+ /**
+ * Determine whether the job should be retried after a failure.
+ *
+ * @return bool True if retry_count <= max_retries (more retries available).
+ */
+ public function should_retry(): bool {
+ return $this->retry_count <= $this->max_retries;
+ }
+
+ /**
+ * Serialize the job for storage.
+ *
+ * @return array Complete job state with snake_case keys.
+ */
+ public function to_array(): array {
+ return [
+ 'id' => $this->id,
+ 'type' => static::get_type(),
+ 'status' => $this->status,
+ 'progress' => $this->progress,
+ 'progress_total' => $this->progress_total,
+ 'progress_percent' => $this->get_progress_percent(),
+ 'error' => $this->error,
+ 'data' => $this->data,
+ 'max_retries' => $this->max_retries,
+ 'retry_count' => $this->retry_count,
+ 'retry_delay_seconds' => $this->retry_delay_seconds,
+ 'group' => $this->group,
+ 'parent_id' => $this->parent_id,
+ 'child_ids' => $this->child_ids,
+ 'children_completed' => $this->children_completed,
+ 'children_failed' => $this->children_failed,
+ 'children_cancelled' => $this->children_cancelled,
+ 'children_total' => count( $this->child_ids ),
+ 'cancelled_at' => $this->cancelled_at,
+ 'finished_at' => $this->finished_at,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+
+ /**
+ * Reconstruct a job instance from a stored associative array.
+ *
+ * Uses ReflectionClass to bypass the constructor (which generates
+ * a new ID) and restores all properties from the serialized state.
+ *
+ * @param array $data The array produced by to_array().
+ * @return static The reconstructed job instance of the concrete class.
+ */
+ public static function from_array( array $data ): static {
+ $ref = new \ReflectionClass( static::class );
+ $job = $ref->newInstanceWithoutConstructor();
+ $job->id = $data['id'] ?? '';
+ $job->status = $data['status'] ?? $job->status;
+ $job->progress = (int) ( $data['progress'] ?? $job->progress );
+ $job->progress_total = (int) ( $data['progress_total'] ?? $job->progress_total );
+ $job->error = $data['error'] ?? $job->error;
+ $job->data = $data['data'] ?? $job->data;
+ $job->max_retries = (int) ( $data['max_retries'] ?? $job->max_retries );
+ $job->retry_count = (int) ( $data['retry_count'] ?? $job->retry_count );
+ $job->retry_delay_seconds = (int) ( $data['retry_delay_seconds'] ?? $job->retry_delay_seconds );
+ $job->group = $data['group'] ?? $job->group;
+ $job->parent_id = $data['parent_id'] ?? $job->parent_id;
+ $job->child_ids = $data['child_ids'] ?? $job->child_ids;
+ $job->children_completed = (int) ( $data['children_completed'] ?? $job->children_completed );
+ $job->children_failed = (int) ( $data['children_failed'] ?? $job->children_failed );
+ $job->children_cancelled = (int) ( $data['children_cancelled'] ?? $job->children_cancelled );
+ $job->cancelled_at = isset( $data['cancelled_at'] ) ? (int) $data['cancelled_at'] : null;
+ $job->finished_at = isset( $data['finished_at'] ) ? (int) $data['finished_at'] : null;
+ $job->created_at = (int) ( $data['created_at'] ?? time() );
+ $job->updated_at = (int) ( $data['updated_at'] ?? time() );
+ return $job;
+ }
+}
diff --git a/inc/Modules/Jobs/Registry.php b/inc/Modules/Jobs/Registry.php
new file mode 100644
index 00000000..1633739d
--- /dev/null
+++ b/inc/Modules/Jobs/Registry.php
@@ -0,0 +1,128 @@
+register( 'sync', Sync_Job::class );
+ * Registry::instance()->register( 'reindex', Reindex_Job::class );
+ *
+ * $job = Registry::instance()->resolve( 'sync' );
+ * // $job is a fresh Sync_Job instance.
+ */
+final class Registry {
+ use Singleton;
+
+ /**
+ * Map of job type name → FQCN.
+ *
+ * @var array>
+ */
+ private array $jobs = [];
+
+ /**
+ * Register a job type by name and class.
+ *
+ * @param string $name The job type name (e.g. "sync", "reindex").
+ * @param string $class_name The fully-qualified class name that extends Abstract_Job.
+ * @throws \InvalidArgumentException If the class does not extend Abstract_Job.
+ * @throws \InvalidArgumentException If the class does not exist.
+ */
+ public function register( string $name, string $class_name ): void {
+ if ( ! class_exists( $class_name ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ /* translators: %s: class name */
+ esc_html__( 'Job class "%s" does not exist.', 'onesearch' ),
+ esc_html( $class_name )
+ )
+ );
+ }
+
+ if ( ! is_a( $class_name, Abstract_Job::class, true ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ /* translators: 1: class name, 2: abstract job class */
+ esc_html__( 'Job class "%1$s" must extend "%2$s".', 'onesearch' ),
+ esc_html( $class_name ),
+ Abstract_Job::class
+ )
+ );
+ }
+
+ $this->jobs[ $name ] = $class_name;
+ }
+
+ /**
+ * Create a fresh job instance by type name.
+ *
+ * @param string $name The registered job type name.
+ * @return \OneSearch\Modules\Jobs\Abstract_Job A new instance of the registered job class.
+ * @throws \InvalidArgumentException If the job type is not registered.
+ */
+ public function resolve( string $name ): Abstract_Job {
+ if ( ! $this->has( $name ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ /* translators: %s: job type name */
+ esc_html__( 'Job type "%s" is not registered.', 'onesearch' ),
+ esc_html( $name )
+ )
+ );
+ }
+
+ $class_name = $this->jobs[ $name ];
+
+ return new $class_name();
+ }
+
+ /**
+ * Check if a job type name is registered.
+ *
+ * @param string $name The job type name.
+ * @return bool True if the name is registered.
+ */
+ public function has( string $name ): bool {
+ return isset( $this->jobs[ $name ] );
+ }
+
+ /**
+ * Get all registered job type names and their class names.
+ *
+ * @return array> Map of name → FQCN.
+ */
+ public function all(): array {
+ return $this->jobs;
+ }
+
+ /**
+ * Get all registered job type names.
+ *
+ * @return string[] List of registered names.
+ */
+ public function names(): array {
+ return array_keys( $this->jobs );
+ }
+
+ /**
+ * Get the number of registered job types.
+ */
+ public function count(): int {
+ return count( $this->jobs );
+ }
+}
diff --git a/inc/Modules/Jobs/Reindex_Job.php b/inc/Modules/Jobs/Reindex_Job.php
new file mode 100644
index 00000000..bc0a315e
--- /dev/null
+++ b/inc/Modules/Jobs/Reindex_Job.php
@@ -0,0 +1,245 @@
+data['post_types'] ?? null;
+
+ if ( ! is_array( $post_types ) ) {
+ throw new \InvalidArgumentException( 'Reindex_Job requires post_types in data payload.' );
+ }
+
+ // Every run rebuilds all batches; cancelled ones left by a failed earlier attempt never report back and would stall completion.
+ $this->set_child_ids( [] );
+
+ // Clearing up front means a failed reindex leaves the site unsearchable until the next successful run.
+ $is_cleared = ( new Indexer() )->clear_site_records();
+ if ( is_wp_error( $is_cleared ) ) {
+ throw new \RuntimeException(
+ sprintf( 'Failed to clear the site\'s Algolia records: %s', esc_html( self::describe_error( $is_cleared ) ) )
+ );
+ }
+
+ // A site with nothing to index is done once its old records are gone.
+ if ( empty( $post_types ) ) {
+ $this->mark_completed();
+ return;
+ }
+
+ $batch_size = self::get_batch_size( $this->data['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
+ $scheduler = new Job_Scheduler();
+ $group = 'reindex_' . $this->get_id();
+ $page = 1;
+
+ try {
+ while ( true ) {
+ $query = new \WP_Query( self::get_batch_query_args( $post_types, $batch_size, $page ) );
+
+ $posts = $query->posts;
+ if ( ! is_array( $posts ) || empty( $posts ) ) {
+ break;
+ }
+
+ $post_ids = array_map( static fn ( $p ) => is_object( $p ) ? (int) $p->ID : (int) $p, $posts );
+
+ $child = new Sync_Job();
+ $child->set_data(
+ [
+ 'post_ids' => $post_ids,
+ 'post_types' => $post_types,
+ ]
+ );
+ $child->set_parent_id( $this->get_id() );
+ $child->set_group( $group );
+ $child->set_max_retries( 2 );
+ $child->set_retry_delay_seconds( 30 );
+
+ $scheduler->persist_job( $child );
+ $this->add_child_id( $child->get_id() );
+
+ if ( count( $posts ) < $batch_size ) {
+ break;
+ }
+
+ ++$page;
+ }
+
+ $batch_count = count( $this->get_child_ids() );
+ if ( 0 === $batch_count ) {
+ $this->mark_completed();
+ return;
+ }
+
+ $this->set_progress_total( $batch_count );
+ $this->update_progress( 0 );
+ $this->record_batch_count( $batch_count );
+
+ // The last write of this copy: once the batches are queued, they update the stored parent instead.
+ $scheduler->persist_job( $this );
+
+ foreach ( $this->get_child_ids() as $child_id ) {
+ $scheduler->enqueue( Sync_Job::class, $child_id, $group );
+ }
+ } catch ( \Throwable $e ) {
+ foreach ( $this->get_child_ids() as $child_id ) {
+ $scheduler->cancel( $child_id );
+ }
+ throw new \RuntimeException(
+ sprintf( 'Failed to schedule child Sync_Job: %s', esc_html( $e->getMessage() ) )
+ );
+ }
+
+ Job_Scheduler::dispatch_queue();
+ }
+
+ /**
+ * Record this site's batch count among the per-site totals a governing site keeps for the run.
+ *
+ * Done before the batches are queued, since the parent isn't written from here once they are.
+ *
+ * @param int $batch_count The batches this site queued.
+ */
+ private function record_batch_count( int $batch_count ): void {
+ if ( empty( $this->data['sites'] ) || ! is_array( $this->data['sites'] ) ) {
+ return;
+ }
+
+ $current_site = Utils::normalize_url( get_site_url() );
+ foreach ( $this->data['sites'] as $i => $site ) {
+ if ( Utils::normalize_url( $site['site_url'] ?? '' ) === $current_site ) {
+ $this->data['sites'][ $i ]['batch_count'] = $batch_count;
+ }
+ }
+
+ $this->data['total_batches'] = (int) array_sum( array_column( $this->data['sites'], 'batch_count' ) );
+ }
+
+ /**
+ * Estimate how many batches a reindex of the given post types will create.
+ *
+ * Counted with the same query the batches are paged with, so it matches unless posts change in between.
+ *
+ * @param string[] $post_types The post types to index.
+ * @param int $batch_size Posts per batch.
+ */
+ public static function count_batches( array $post_types, int $batch_size = self::DEFAULT_BATCH_SIZE ): int {
+ if ( empty( $post_types ) ) {
+ return 0;
+ }
+
+ $batch_size = self::get_batch_size( $batch_size );
+ $query = new \WP_Query(
+ array_merge(
+ self::get_batch_query_args( $post_types, 1, 1 ),
+ [ 'no_found_rows' => false ]
+ )
+ );
+
+ return (int) ceil( $query->found_posts / $batch_size );
+ }
+
+ /**
+ * Clamp a batch size to the allowed range.
+ *
+ * @param mixed $batch_size The requested batch size.
+ */
+ private static function get_batch_size( $batch_size ): int {
+ return max( 1, min( (int) $batch_size, self::MAX_BATCH_SIZE ) );
+ }
+
+ /**
+ * Get the query for one page of the posts to index.
+ *
+ * @param string[] $post_types The post types to index.
+ * @param int $batch_size Posts per page.
+ * @param int $page The page number.
+ * @return array
+ */
+ private static function get_batch_query_args( array $post_types, int $batch_size, int $page ): array {
+ return [
+ 'post_type' => $post_types,
+ 'post_status' => \OneSearch\Modules\Search\Post_Record::get_allowed_statuses( $post_types ),
+ 'posts_per_page' => $batch_size,
+ 'paged' => $page,
+ // Paging needs a unique order; posts sharing a date could otherwise repeat across batches or be skipped.
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ ];
+ }
+}
diff --git a/inc/Modules/Jobs/Sync_Job.php b/inc/Modules/Jobs/Sync_Job.php
new file mode 100644
index 00000000..65a11d07
--- /dev/null
+++ b/inc/Modules/Jobs/Sync_Job.php
@@ -0,0 +1,135 @@
+data['post_ids'] ?? [];
+
+ if ( empty( $post_ids ) ) {
+ throw new \InvalidArgumentException( 'Sync_Job requires post_ids in data payload.' );
+ }
+
+ $this->progress_total = count( $post_ids );
+ $this->update_progress( 0 );
+
+ $errors = [];
+ $save_posts = [];
+ $delete_ids = [];
+
+ foreach ( $post_ids as $i => $post_id ) {
+ $post = get_post( $post_id );
+
+ if ( ! $post ) {
+ $this->update_progress( $i + 1 );
+ continue;
+ }
+
+ $post_types = $this->data['post_types'] ?? [ $post->post_type ];
+ $should_index = in_array( $post->post_status, Post_Record::get_allowed_statuses( $post_types ), true );
+
+ if ( ! $should_index ) {
+ $delete_ids[] = (int) $post_id;
+ } else {
+ $save_posts[] = $post;
+ }
+
+ $this->update_progress( $i + 1 );
+ }
+
+ $indexer = new Indexer();
+
+ if ( ! empty( $delete_ids ) ) {
+ $result = $indexer->delete_posts( $delete_ids );
+ if ( is_wp_error( $result ) ) {
+ $errors[] = sprintf(
+ 'Failed to delete %d posts from Algolia: %s',
+ count( $delete_ids ),
+ esc_html( self::describe_error( $result ) )
+ );
+ }
+ }
+
+ if ( ! empty( $save_posts ) ) {
+ $result = $indexer->save_posts( $save_posts );
+ if ( is_wp_error( $result ) ) {
+ $errors[] = sprintf(
+ 'Failed to save %d posts to Algolia: %s',
+ count( $save_posts ),
+ esc_html( self::describe_error( $result ) )
+ );
+ }
+ }
+
+ if ( ! empty( $errors ) ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new \RuntimeException( implode( '; ', $errors ) );
+ }
+
+ $this->mark_completed();
+ }
+}
diff --git a/inc/Modules/Rest/Search_Controller.php b/inc/Modules/Rest/Search_Controller.php
index db3d8276..3f32097f 100644
--- a/inc/Modules/Rest/Search_Controller.php
+++ b/inc/Modules/Rest/Search_Controller.php
@@ -9,6 +9,9 @@
namespace OneSearch\Modules\Rest;
+use OneSearch\Modules\Jobs\Abstract_Job;
+use OneSearch\Modules\Jobs\Reindex_Job;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Search\Indexer;
use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Settings\Settings;
@@ -19,6 +22,30 @@
* Class Search_Controller
*/
class Search_Controller extends Abstract_REST_Controller {
+ /**
+ * Transient key for the active reindex state.
+ *
+ * Stores the jobs array so the frontend can restore progress UI
+ * after a page refresh. Cleared when the reindex completes or is
+ * cancelled.
+ */
+ public const REINDEX_STATE_TRANSIENT = 'onesearch_reindex_state';
+
+ /**
+ * TTL in seconds for the reindex state transient.
+ */
+ private const REINDEX_STATE_TTL = 3600;
+
+ /**
+ * Seconds of inactivity after which a non-terminal job is considered abandoned.
+ */
+ private const STALE_JOB_THRESHOLD = 900;
+
+ /**
+ * Seconds a re-index request may hold its lock before another request can take it over.
+ */
+ private const REINDEX_LOCK_TTL = 5 * MINUTE_IN_SECONDS;
+
/**
* {@inheritDoc}
*/
@@ -50,7 +77,7 @@ public function register_routes(): void {
],
],
'callback' => [ $this, 'update_algolia_credentials' ],
- 'permission_callback' => [ $this, 'check_api_permissions' ],
+ 'permission_callback' => [ $this, 'check_api_permissions_except_brands' ],
],
]
);
@@ -76,7 +103,10 @@ public function register_routes(): void {
'required' => true,
'type' => 'array',
'sanitize_callback' => static function ( $value ) {
- return is_array( $value );
+ if ( ! is_array( $value ) ) {
+ return [];
+ }
+ return $value;
},
],
],
@@ -92,11 +122,38 @@ public function register_routes(): void {
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'reindex' ],
+ 'permission_callback' => [ $this, 'check_api_permissions_except_brands' ],
+ ]
+ );
+
+ // Get active reindex state for UI persistence across page refreshes.
+ register_rest_route(
+ self::NAMESPACE,
+ '/re-index/status',
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_reindex_status' ],
'permission_callback' => [ $this, 'check_api_permissions' ],
]
);
}
+ /**
+ * Same as check_api_permissions(), except the governing site refuses brand tokens.
+ *
+ * Brands only call the governing site to read their config; letting their token
+ * through here would let any brand overwrite the Algolia keys or start a reindex.
+ *
+ * @param \WP_REST_Request $request Request.
+ */
+ public function check_api_permissions_except_brands( $request ): bool {
+ if ( Settings::is_governing_site() && ! empty( $request->get_header( 'X-OneSearch-Token' ) ) ) {
+ return false;
+ }
+
+ return $this->check_api_permissions( $request );
+ }
+
/**
* Get Algolia credentials from governing site.
*/
@@ -191,64 +248,343 @@ public function set_indexable_entities( \WP_REST_Request $request ) {
return new \WP_Error( 'invalid_data', __( 'Failed saving settings. Please try again', 'onesearch' ), [ 'status' => 400 ] );
}
- update_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $indexable_entities );
+ $sanitized = $this->sanitize_indexable_entities( $indexable_entities );
+
+ update_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES, $sanitized );
return rest_ensure_response(
[
'success' => true,
'message' => __( 'Data saved successfully.', 'onesearch' ),
- 'indexableEntities' => $indexable_entities,
+ 'indexableEntities' => $sanitized,
]
);
}
+ /**
+ * Recursively sanitize indexable entities data.
+ *
+ * @param mixed $data The data to sanitize.
+ * @return mixed The sanitized data.
+ */
+ private function sanitize_indexable_entities( $data ) {
+ if ( is_array( $data ) ) {
+ $sanitized = [];
+ foreach ( $data as $key => $value ) {
+ $sanitized_key = is_string( $key ) ? sanitize_text_field( $key ) : $key;
+ $sanitized[ $sanitized_key ] = $this->sanitize_indexable_entities( $value );
+ }
+ return $sanitized;
+ }
+
+ if ( is_string( $data ) ) {
+ return sanitize_text_field( $data );
+ }
+
+ return $data;
+ }
+
/**
* Reindex the current site.
*
* If the site is a governing site, trigger the reindex on children as well.
+ * The parent Reindex_Job runs in this request to resolve posts and enqueue
+ * child Sync_Jobs; the child Sync_Jobs then run asynchronously via Action Scheduler.
+ * When a governing site starts the reindex on a brand site, the brand's parent
+ * runs in the background too, since the governing site is waiting on the response.
+ *
+ * @param \WP_REST_Request> $request Request.
*/
- public function reindex(): \WP_REST_Response|\WP_Error {
+ public function reindex( $request ): \WP_REST_Response|\WP_Error {
+ $lock_key = self::REINDEX_STATE_TRANSIENT . '_lock';
+ if ( ! self::acquire_reindex_lock( $lock_key ) ) {
+ return new \WP_Error(
+ 'onesearch_reindex_active',
+ __( 'A re-index is already in progress. Cancel it or wait for it to complete before starting a new one.', 'onesearch' ),
+ [ 'status' => 409 ]
+ );
+ }
+
+ $active_state = $this->get_active_reindex_state();
+ if ( null !== $active_state ) {
+ $this->release_reindex_lock( $lock_key );
+ return new \WP_Error(
+ 'onesearch_reindex_active',
+ __( 'A re-index is already in progress. Cancel it or wait for it to complete before starting a new one.', 'onesearch' ),
+ [ 'status' => 409 ]
+ );
+ }
+
+ $jobs = [];
$errors = [];
// If Governing, trigger re-index on children as well.
if ( Settings::is_governing_site() ) {
- $child_errors = $this->reindex_child_sites();
+ $child_result = $this->reindex_child_sites();
+
+ if ( isset( $child_result['jobs'] ) ) {
+ $jobs = array_merge( $jobs, $child_result['jobs'] );
+ }
- if ( is_array( $child_errors ) ) {
- $errors = array_merge( $errors, $child_errors );
+ if ( isset( $child_result['errors'] ) ) {
+ $errors = array_merge( $errors, $child_result['errors'] );
}
}
- // Index the current site.
- $indexed = ( new Indexer() )->reindex();
+ $post_types = Indexer::get_indexable_post_types();
+
+ // Reported like any other failure, so a governing site can relay the brand's own message.
+ if ( is_wp_error( $post_types ) ) {
+ $this->release_reindex_lock( $lock_key );
+
+ $errors[] = [
+ 'site_url' => get_site_url(),
+ 'message' => $post_types->get_error_message(),
+ ];
+
+ return rest_ensure_response(
+ [
+ 'success' => false,
+ 'message' => implode( "\n", array_column( $errors, 'message' ) ),
+ 'errors' => $errors,
+ ]
+ );
+ }
+
+ $job = new Reindex_Job();
+ $job->set_data(
+ [
+ 'post_types' => $post_types,
+ 'batch_size' => Reindex_Job::DEFAULT_BATCH_SIZE,
+ ]
+ );
+ $job->set_max_retries( 2 );
+ $job->set_retry_delay_seconds( 60 );
+
+ $scheduler = new Job_Scheduler();
+ $job_id = $job->get_id();
+
+ $local_site = [
+ 'site_name' => Settings::is_governing_site() ? __( 'Governing Site', 'onesearch' ) : get_bloginfo( 'name' ),
+ 'site_url' => get_site_url(),
+ 'job_id' => $job_id,
+ 'batch_count' => 0,
+ ];
+
+ // Recorded up front, so a batch that finishes before this request does still waits for the brand sites.
+ if ( Settings::is_governing_site() ) {
+ $job->set_data( array_merge( $job->get_data(), [ 'sites' => array_merge( [ $local_site ], $jobs ) ] ) );
+ }
- if ( is_wp_error( $indexed ) ) {
+ // Only a governing site sends a brand site its token.
+ $is_from_governing_site = Settings::is_consumer_site() && ! empty( $request->get_header( 'X-OneSearch-Token' ) );
+
+ try {
+ if ( $is_from_governing_site ) {
+ // Clearing the old records can outlast the governing site's request timeout, which would lose track of this job.
+ $local_site['batch_count'] = Reindex_Job::count_batches( $post_types, Reindex_Job::DEFAULT_BATCH_SIZE );
+ $scheduler->schedule( $job );
+ Job_Scheduler::dispatch_queue();
+ } else {
+ $job->mark_running();
+ $scheduler->persist_job( $job );
+ $job->handle();
+
+ // Once children exist notify_parent() owns the parent lifecycle; re-persisting here would clobber a terminal status.
+ if ( ! $job->has_pending_children() ) {
+ if ( ! $job->is_finished() ) {
+ $job->mark_completed();
+ }
+ $scheduler->persist_job( $job );
+ }
+
+ $local_site['batch_count'] = count( $job->get_child_ids() );
+ }
+ } catch ( \Throwable $e ) {
+ $job->fail( $e->getMessage() );
+ $scheduler->persist_job( $job );
$errors[] = [
'site_url' => get_site_url(),
- 'message' => $indexed->get_error_message() ?: __( 'Re-index failed.', 'onesearch' ),
+ 'message' => $e->getMessage(),
];
}
+ array_unshift( $jobs, $local_site );
+
+ // A run with live batches already recorded these totals, and writing it here could undo the batches' updates.
+ if ( Settings::is_governing_site() && ( ! $job->get_child_ids() || $job->is_finished() ) ) {
+ $job->set_data(
+ array_merge(
+ $job->get_data(),
+ [
+ 'total_batches' => (int) array_sum( array_column( $jobs, 'batch_count' ) ),
+ 'sites' => $jobs,
+ ]
+ )
+ );
+ $scheduler->persist_job( $job );
+
+ // With nothing of its own to index, the run finished before its brand sites did, so it waits for them instead.
+ if ( Abstract_Job::STATUS_COMPLETED === $job->get_status() && ! $job->get_child_ids() && count( $jobs ) > 1 ) {
+ $scheduler->await_remote_sites( $job->to_array() );
+ }
+ }
+
+ // Persist the reindex state so the UI can survive page refreshes.
+ set_transient( self::REINDEX_STATE_TRANSIENT, $jobs, self::REINDEX_STATE_TTL );
+
+ // Release the reindex lock now that the state has been persisted.
+ $this->release_reindex_lock( $lock_key );
+
return rest_ensure_response(
[
- 'success' => empty( $errors ),
- 'message' => empty( $errors )
+ 'success' => empty( $errors ),
+ 'message' => empty( $errors )
? __( 'Re-indexing scheduled successfully.', 'onesearch' )
- : __( 'Re-indexing was unsuccessful. Please try again later.', 'onesearch' ),
- 'errors' => $errors,
+ : implode( "\n", array_column( $errors, 'message' ) ),
+ 'errors' => $errors,
+ 'job_id' => $job_id,
+ 'batch_count' => $local_site['batch_count'],
+ 'jobs' => $jobs,
]
);
}
+ /**
+ * Get the current reindex status for UI persistence.
+ *
+ * Used by the frontend on mount to detect if a reindex was in progress
+ * before a page refresh, so it can restore the progress UI.
+ */
+ public function get_reindex_status(): WP_REST_Response {
+ $state = $this->get_active_reindex_state();
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'active' => null !== $state,
+ 'jobs' => $state,
+ ]
+ );
+ }
+
+ /**
+ * Get the reindex state while any of its jobs is still live, clearing it once none is.
+ *
+ * A job stops counting once it's gone, finished, or idle past STALE_JOB_THRESHOLD.
+ *
+ * @return array|null
+ */
+ private function get_active_reindex_state(): ?array {
+ $state = get_transient( self::REINDEX_STATE_TRANSIENT );
+
+ if ( ! is_array( $state ) || empty( $state ) ) {
+ return null;
+ }
+
+ $scheduler = new Job_Scheduler();
+ $blocking = false;
+ $now = time();
+
+ foreach ( $state as $entry ) {
+ $job_id = $entry['job_id'] ?? '';
+ $job_status = $scheduler->get_status( $job_id );
+
+ // Live jobs are persisted before the lock is released, so a missing row means the job is gone, not blocking.
+ if ( ! $job_status ) {
+ continue;
+ }
+
+ if ( in_array( $job_status['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ continue;
+ }
+
+ // Settling a parent bumps updated_at, so 15 idle minutes means the job is wedged or its process died.
+ $updated_at = (int) ( $job_status['updated_at'] ?? 0 );
+ if ( $updated_at > 0 && ( $now - $updated_at ) > self::STALE_JOB_THRESHOLD ) {
+ continue;
+ }
+
+ $blocking = true;
+ }
+
+ if ( ! $blocking ) {
+ delete_transient( self::REINDEX_STATE_TRANSIENT );
+ return null;
+ }
+
+ return $state;
+ }
+
+ /**
+ * Clear the active reindex state — called when a reindex completes
+ * or is cancelled.
+ */
+ public static function clear_reindex_state(): void {
+ delete_transient( self::REINDEX_STATE_TRANSIENT );
+ delete_option( self::REINDEX_STATE_TRANSIENT . '_lock' );
+ }
+
+ /**
+ * Take the lock that keeps two re-index requests from running at once.
+ *
+ * The lock stores when it expires, so one left behind by a request that died is taken over once that time passes
+ * instead of blocking every re-index after it.
+ *
+ * @param string $lock_key The option name used as the mutex lock.
+ */
+ private static function acquire_reindex_lock( string $lock_key ): bool {
+ global $wpdb;
+
+ $expires_at = time() + self::REINDEX_LOCK_TTL;
+
+ // Not add_option(): its upsert lets a second request through when the values differ.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $acquired = (bool) $wpdb->query(
+ $wpdb->prepare(
+ "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %d, 'no')",
+ $lock_key,
+ $expires_at
+ )
+ );
+
+ if ( ! $acquired ) {
+ // Conditional on the old expiry, so only one of several waiting requests takes over a lapsed lock.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $acquired = (bool) $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
+ $expires_at,
+ $lock_key,
+ time()
+ )
+ );
+ }
+
+ wp_cache_delete( $lock_key, 'options' );
+
+ return $acquired;
+ }
+
+ /**
+ * Release the reindex lock acquired at the start of reindex().
+ *
+ * @param string $lock_key The option name used as the mutex lock.
+ */
+ private function release_reindex_lock( string $lock_key ): void {
+ delete_option( $lock_key );
+ }
+
/**
* Trigger batch reindexing of all child sites (for governing sites).
*
- * @return true|array{site_url: string, message: string}[] Array of errors encountered, true if successful.
+ * @return array{ jobs: array, errors: array }
*/
- private function reindex_child_sites() {
+ private function reindex_child_sites(): array {
$shared_sites = Settings::get_shared_sites();
- $errors = [];
+ $child_jobs = [];
+ $errors = [];
// Build the requests array for each site.
foreach ( $shared_sites as $site_data ) {
if ( empty( $site_data['url'] ) || empty( $site_data['api_key'] ) ) {
@@ -317,8 +653,20 @@ private function reindex_child_sites() {
'message' => $site_messages ? implode( ' ', $site_messages ) : __( 'The site failed to re-index.', 'onesearch' ),
];
}
+
+ if ( ! empty( $response_data['job_id'] ) ) {
+ $child_jobs[] = [
+ 'site_name' => $site_data['name'] ?? $site_data['url'],
+ 'site_url' => $site_data['url'],
+ 'job_id' => $response_data['job_id'],
+ 'batch_count' => (int) ( $response_data['batch_count'] ?? 0 ),
+ ];
+ }
}
- return $errors ?: true;
+ return [
+ 'jobs' => $child_jobs,
+ 'errors' => $errors,
+ ];
}
}
diff --git a/inc/Modules/Scheduler/Bootstrap.php b/inc/Modules/Scheduler/Bootstrap.php
new file mode 100644
index 00000000..7c7b45a8
--- /dev/null
+++ b/inc/Modules/Scheduler/Bootstrap.php
@@ -0,0 +1,53 @@
+register( 'sync', \OneSearch\Modules\Jobs\Sync_Job::class );
+ $registry->register( 'reindex', \OneSearch\Modules\Jobs\Reindex_Job::class );
+
+ /**
+ * Fires after built-in job types are registered.
+ *
+ * @param \OneSearch\Modules\Jobs\Registry $registry The job registry instance.
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The scheduler instance.
+ */
+ do_action( 'onesearch_register_jobs', $registry, $scheduler );
+ }
+}
diff --git a/inc/Modules/Scheduler/Job_REST_Controller.php b/inc/Modules/Scheduler/Job_REST_Controller.php
new file mode 100644
index 00000000..3a72b243
--- /dev/null
+++ b/inc/Modules/Scheduler/Job_REST_Controller.php
@@ -0,0 +1,1170 @@
+ WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_job_history' ],
+ 'permission_callback' => [ $this, 'check_job_read_permissions' ],
+ 'args' => [
+ 'page' => [
+ 'required' => false,
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ ],
+ 'per_page' => [
+ 'required' => false,
+ 'type' => 'integer',
+ 'default' => 5,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'sanitize_callback' => 'absint',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/remote-status',
+ [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_remote_job_status' ],
+ 'permission_callback' => static function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'site_url' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'esc_url_raw',
+ ],
+ 'job_id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/remote-retry',
+ [
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'retry_remote_job' ],
+ 'permission_callback' => static function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'site_url' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'esc_url_raw',
+ ],
+ 'job_id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/remote-cancel',
+ [
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'cancel_remote_job' ],
+ 'permission_callback' => static function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'site_url' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'esc_url_raw',
+ ],
+ 'job_id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/(?P[-a-zA-Z0-9_.]+)/children',
+ [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_job_children' ],
+ 'permission_callback' => [ $this, 'check_job_read_permissions' ],
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/(?P[-a-zA-Z0-9_.]+)/retry',
+ [
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'retry_job' ],
+ 'permission_callback' => [ $this, 'check_api_permissions' ],
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/(?P[-a-zA-Z0-9_.]+)',
+ [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_job' ],
+ 'permission_callback' => [ $this, 'check_job_read_permissions' ],
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/(?P[-a-zA-Z0-9_.]+)',
+ [
+ [
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => [ $this, 'cancel_job' ],
+ 'permission_callback' => [ $this, 'check_api_permissions' ],
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ],
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Get a single job's status.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_job( $request ) {
+ $job_id = $request->get_param( 'id' );
+ $scheduler = new Job_Scheduler();
+ $status = $scheduler->get_status( $job_id );
+
+ if ( ! $status ) {
+ return new \WP_Error(
+ 'onesearch_job_not_found',
+ __( 'Job not found.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ // Settled here too, so an open progress view sees a run that was waiting on its brand sites finish promptly.
+ if ( Abstract_Job::STATUS_RUNNING === ( $status['status'] ?? '' ) && ! empty( $status['data']['_needs_remote_finalize'] ) ) {
+ $scheduler->settle_parent( (string) $job_id );
+ $status = $scheduler->get_status( (string) $job_id ) ?: $status;
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'job' => $status,
+ ]
+ );
+ }
+
+ /**
+ * Cancel a job and its children.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function cancel_job( $request ) {
+ $job_id = $request->get_param( 'id' );
+ $scheduler = new Job_Scheduler();
+ $status = $scheduler->get_status( $job_id );
+
+ if ( ! $status ) {
+ return new \WP_Error(
+ 'onesearch_job_not_found',
+ __( 'Job not found.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ $is_parent_job = ! empty( $status['child_ids'] ?? [] );
+ if ( ! $is_parent_job && in_array( $status['status'] ?? '', [ 'completed', 'failed', 'cancelled' ], true ) ) {
+ return new \WP_Error(
+ 'onesearch_job_terminal',
+ __( 'Job is already in a terminal state.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $scheduler->cancel( $job_id );
+
+ // Clearing the reindex state is what tells the frontend it may start a new run.
+ if ( ! empty( $status['child_ids'] ?? [] ) ) {
+ Search_Controller::clear_reindex_state();
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Job cancelled.', 'onesearch' ),
+ ]
+ );
+ }
+
+ /**
+ * Get child jobs of a parent job.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_job_children( $request ) {
+ $job_id = $request->get_param( 'id' );
+ $scheduler = new Job_Scheduler();
+ $status = $scheduler->get_status( $job_id );
+
+ if ( ! $status ) {
+ return new \WP_Error(
+ 'onesearch_job_not_found',
+ __( 'Job not found.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ $child_ids = $status['child_ids'] ?? [];
+ $children = [];
+
+ foreach ( $child_ids as $child_id ) {
+ $child_status = $scheduler->get_status( $child_id );
+ if ( $child_status ) {
+ $children[] = $child_status;
+ }
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'children' => $children,
+ ]
+ );
+ }
+
+ /**
+ * Retry a failed job using the same job ID.
+ *
+ * Only failures are retried: a run the user cancelled, and the batches in it, stay as they are.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function retry_job( $request ) {
+ $job_id = $request->get_param( 'id' );
+ $scheduler = new Job_Scheduler();
+ $status = $scheduler->get_status( $job_id );
+
+ if ( ! $status ) {
+ return new \WP_Error(
+ 'onesearch_job_not_found',
+ __( 'Job not found.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ if ( 'failed' !== ( $status['status'] ?? '' ) ) {
+ return new \WP_Error(
+ 'onesearch_retry_not_failed',
+ __( 'Only failed jobs can be retried.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ if ( ! empty( $status['child_ids'] ?? [] ) ) {
+ return $this->retry_failed_child_jobs( $scheduler, $status );
+ }
+
+ $parent_id = (string) ( $status['parent_id'] ?? '' );
+ $parent = '' !== $parent_id ? $scheduler->get_status( $parent_id ) : null;
+
+ // Retrying a batch reopens its run, and a run the user cancelled stays cancelled.
+ if ( $parent && Abstract_Job::STATUS_CANCELLED === ( $parent['status'] ?? '' ) ) {
+ return new \WP_Error(
+ 'onesearch_retry_cancelled',
+ __( 'A cancelled run can\'t be retried.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $job = $this->requeue_job( $scheduler, $status );
+ if ( is_wp_error( $job ) ) {
+ return $job;
+ }
+
+ if ( $parent_id ) {
+ $this->reset_parent_for_retry( $scheduler, $parent_id );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Retry scheduled.', 'onesearch' ),
+ 'job_id' => $job->get_id(),
+ ]
+ );
+ }
+
+ /**
+ * Retry only failed child batches for a parent reindex job.
+ *
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param array $parent_data Stored parent job data.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ private function retry_failed_child_jobs( Job_Scheduler $scheduler, array $parent_data ) {
+ $parent_id = (string) ( $parent_data['id'] ?? '' );
+ $child_ids = $parent_data['child_ids'] ?? [];
+
+ if ( '' === $parent_id || ! is_array( $child_ids ) ) {
+ return new \WP_Error(
+ 'onesearch_retry_invalid_parent',
+ __( 'Invalid parent job.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $failed_children = [];
+ $completed_children = 0;
+ $cancelled_children = 0;
+ $terminal_failed_ids = [];
+
+ foreach ( $child_ids as $child_id ) {
+ if ( ! is_string( $child_id ) || '' === $child_id ) {
+ continue;
+ }
+
+ $child_status = $scheduler->get_status( $child_id );
+ if ( ! $child_status ) {
+ continue;
+ }
+
+ if ( Abstract_Job::STATUS_FAILED === ( $child_status['status'] ?? '' ) ) {
+ // Resolved before the parent's counters are reset below, so an unknown type can't leave it half-updated.
+ $child = $this->job_from_status( $child_status );
+ if ( is_wp_error( $child ) ) {
+ return $child;
+ }
+
+ $failed_children[] = $child;
+ $terminal_failed_ids[] = $child_id;
+ continue;
+ }
+
+ if ( Abstract_Job::STATUS_CANCELLED === ( $child_status['status'] ?? '' ) ) {
+ ++$cancelled_children;
+ } elseif ( Abstract_Job::STATUS_COMPLETED === ( $child_status['status'] ?? '' ) ) {
+ ++$completed_children;
+ }
+ }
+
+ /*
+ * Run from scratch when no batch has indexed anything yet, or when batches were
+ * cancelled: a failed reindex only cancels its batches when it couldn't create the
+ * next one, so the later batches don't exist and retrying the rest would miss them.
+ * Otherwise indexing had started, so only the failed batches are retried below.
+ */
+ if ( $cancelled_children > 0 || ( 0 === $completed_children && ! empty( $failed_children ) ) ) {
+ return $this->restart_parent_job( $scheduler, $parent_data );
+ }
+
+ if ( empty( $failed_children ) ) {
+ // A failed parent with all children complete is a notify_parent()/cancel() race; reconcile it to completed.
+ if ( $completed_children > 0 ) {
+ // A brand site may be retrying its own batches right now, so the run finishes once they're done.
+ if ( $scheduler->get_remote_sites( $parent_data ) ) {
+ $scheduler->await_remote_sites( $parent_data );
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'All batches on this site already completed. Waiting for the brand sites.', 'onesearch' ),
+ 'job_id' => $parent_id,
+ 'retried' => 0,
+ ]
+ );
+ }
+
+ $repository = new Job_Repository();
+ $parent_data['status'] = Abstract_Job::STATUS_COMPLETED;
+ $parent_data['children_failed'] = 0;
+ $parent_data['children_cancelled'] = 0;
+ $parent_data['error'] = null;
+ $parent_data['updated_at'] = time();
+ $repository->upsert( $parent_data );
+ delete_transient( Job_Scheduler::OPTION_PREFIX . $parent_id );
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'All batches already completed. Job status corrected.', 'onesearch' ),
+ 'job_id' => $parent_id,
+ 'retried' => 0,
+ ]
+ );
+ }
+
+ return new \WP_Error(
+ 'onesearch_retry_no_failed_children',
+ __( 'No failed batches are available to retry.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $this->prepare_parent_for_child_retries( $parent_data, $completed_children );
+
+ $retried = 0;
+ $repository = new Job_Repository();
+ foreach ( $failed_children as $child ) {
+ $child_id = $child->get_id();
+ if ( '' === $child_id ) {
+ continue;
+ }
+
+ $group = 'onesearch_' . $child->get_group();
+ $action_rec = $repository->get_action( $child_id );
+ if ( $action_rec ) {
+ as_unschedule_action( Job_Scheduler::HOOK, $action_rec['args'], $group );
+ }
+
+ $child->set_status( Abstract_Job::STATUS_PENDING );
+ $child->set_retry_count( 0 );
+ $child->clear_error();
+
+ try {
+ $scheduler->schedule( $child );
+ ++$retried;
+ } catch ( \Throwable $e ) {
+ return new \WP_Error(
+ 'onesearch_retry_failed',
+ $e->getMessage(),
+ [ 'status' => 500 ]
+ );
+ }
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Failed batches retry scheduled.', 'onesearch' ),
+ 'job_id' => $parent_id,
+ 'retried' => $retried,
+ 'child_ids' => $terminal_failed_ids,
+ ]
+ );
+ }
+
+ /**
+ * Reset parent counters before retrying failed child jobs.
+ *
+ * @param array $parent_data Stored parent job data.
+ * @param int $completed_children Completed child count to preserve.
+ */
+ private function prepare_parent_for_child_retries( array $parent_data, int $completed_children ): void {
+ $parent_id = (string) ( $parent_data['id'] ?? '' );
+ if ( '' === $parent_id ) {
+ return;
+ }
+
+ $repository = new Job_Repository();
+ $repository->reset_counters( $parent_id, $completed_children, 0, 0 );
+
+ $parent_data = self::forget_finish( $parent_data );
+ $parent_data['status'] = Abstract_Job::STATUS_RUNNING;
+ $parent_data['children_completed'] = $completed_children;
+ $parent_data['children_failed'] = 0;
+ $parent_data['children_cancelled'] = 0;
+ $parent_data['progress'] = min( $completed_children, (int) ( $parent_data['progress_total'] ?? $completed_children ) );
+ $parent_data['error'] = null;
+ $parent_data['finished_at'] = null;
+ $parent_data['updated_at'] = time();
+
+ $repository->upsert( $parent_data );
+ $parent_key = Job_Scheduler::OPTION_PREFIX . $parent_id;
+ set_transient( $parent_key, $parent_data, Job_Scheduler::TRANSIENT_EXPIRATION );
+ }
+
+ /**
+ * Drop what a finished run recorded about its brand sites, so it's recorded afresh when the retried run finishes.
+ *
+ * @param array $parent_data Stored parent job data.
+ * @return array
+ */
+ private static function forget_finish( array $parent_data ): array {
+ unset(
+ $parent_data['data']['_remote_aggregated'],
+ $parent_data['data']['_needs_remote_finalize'],
+ $parent_data['data']['_remote_waiting_since']
+ );
+
+ return $parent_data;
+ }
+
+ /**
+ * Take a retried batch out of its parent's finished and failed counts, reopening the parent if it had finished.
+ *
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param string $parent_id The parent job ID.
+ */
+ private function reset_parent_for_retry( Job_Scheduler $scheduler, string $parent_id ): void {
+ $parent_key = Job_Scheduler::OPTION_PREFIX . $parent_id;
+ $parent_data = $scheduler->get_status( $parent_id );
+ if ( ! $parent_data ) {
+ return;
+ }
+
+ $repository = new Job_Repository();
+
+ $new_done = $repository->decrement_counter( $parent_id, 'children_done' );
+ $new_failed = $repository->decrement_counter( $parent_id, 'children_failed' );
+
+ $parent_data['children_completed'] = $new_done;
+ $parent_data['children_failed'] = $new_failed;
+
+ if ( in_array( $parent_data['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ $parent_data = self::forget_finish( $parent_data );
+ $parent_data['status'] = Abstract_Job::STATUS_RUNNING;
+ $parent_data['updated_at'] = time();
+ }
+
+ $repository->upsert( $parent_data );
+
+ if ( in_array( $parent_data['status'], Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ delete_transient( $parent_key );
+ } else {
+ set_transient( $parent_key, $parent_data, Job_Scheduler::TRANSIENT_EXPIRATION );
+ }
+ }
+
+ /**
+ * Rebuild a stored job as the class registered for its type.
+ *
+ * A failed job can be a Reindex_Job that never created batches, so the stored
+ * type decides the class rather than assuming every retry is a Sync_Job.
+ *
+ * @param array $status Stored job state.
+ * @return \OneSearch\Modules\Jobs\Abstract_Job|\WP_Error
+ */
+ private function job_from_status( array $status ) {
+ $type = (string) ( $status['type'] ?? '' );
+ $classes = Registry::instance()->all();
+
+ if ( ! isset( $classes[ $type ] ) ) {
+ return new \WP_Error(
+ 'onesearch_retry_unknown_type',
+ sprintf(
+ /* translators: %s: job type */
+ __( 'Jobs of type "%s" cannot be retried.', 'onesearch' ),
+ $type
+ ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ return $classes[ $type ]::from_array( $status );
+ }
+
+ /**
+ * Queue a stored job to run again from the start, under its existing ID.
+ *
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param array $status Stored job state.
+ * @return \OneSearch\Modules\Jobs\Abstract_Job|\WP_Error The queued job.
+ */
+ private function requeue_job( Job_Scheduler $scheduler, array $status ) {
+ $job = $this->job_from_status( $status );
+ if ( is_wp_error( $job ) ) {
+ return $job;
+ }
+
+ $group = 'onesearch_' . ( $status['group'] ?? 'default' );
+ $action_rec = ( new Job_Repository() )->get_action( $job->get_id() );
+ if ( $action_rec ) {
+ as_unschedule_action( Job_Scheduler::HOOK, $action_rec['args'], $group );
+ }
+
+ $job->set_status( Abstract_Job::STATUS_PENDING );
+ $job->set_retry_count( 0 );
+ $job->clear_error();
+
+ try {
+ $scheduler->schedule( $job );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error(
+ 'onesearch_retry_failed',
+ $e->getMessage(),
+ [ 'status' => 500 ]
+ );
+ }
+
+ return $job;
+ }
+
+ /**
+ * Re-run a failed parent job from scratch, detaching the batches of its earlier run.
+ *
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param array $parent_data Stored parent job data.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ private function restart_parent_job( Job_Scheduler $scheduler, array $parent_data ) {
+ // An old batch still queued would otherwise report into the new run's counters.
+ foreach ( (array) ( $parent_data['child_ids'] ?? [] ) as $child_id ) {
+ $child_status = $scheduler->get_status( (string) $child_id );
+ if ( $child_status && ! in_array( $child_status['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ $scheduler->cancel( (string) $child_id );
+ }
+ }
+
+ $parent_data = self::forget_finish( $parent_data );
+ $parent_data['child_ids'] = [];
+ $parent_data['children_completed'] = 0;
+ $parent_data['children_failed'] = 0;
+ $parent_data['children_cancelled'] = 0;
+ $parent_data['progress'] = 0;
+ $parent_data['finished_at'] = null;
+ $parent_data['cancelled_at'] = null;
+
+ $job = $this->requeue_job( $scheduler, $parent_data );
+ if ( is_wp_error( $job ) ) {
+ return $job;
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Job restarted from the beginning.', 'onesearch' ),
+ 'job_id' => $job->get_id(),
+ ]
+ );
+ }
+
+ /**
+ * Get job history with pagination.
+ *
+ * Returns top-level terminal jobs (completed, failed, cancelled)
+ * with pagination support.
+ *
+ * @param \WP_REST_Request> $request Request.
+ */
+ public function get_job_history( $request ): WP_REST_Response {
+ $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
+ $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 5 ) ) );
+
+ $repository = new Job_Repository();
+ $total = $repository->count_terminal_jobs( true );
+ $rows = $repository->get_terminal_jobs( $page, $per_page, true );
+
+ $scheduler = new Job_Scheduler();
+ $jobs = [];
+ foreach ( $rows as $data ) {
+ $jobs[] = $this->hydrate_history_job( $data, $scheduler );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'jobs' => $jobs,
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $per_page,
+ 'total_pages' => (int) ceil( $total / $per_page ),
+ ]
+ );
+ }
+
+ /**
+ * Hydrate a history row from its children so parent rows reflect child failures.
+ *
+ * @param array $job Stored parent job data.
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler Job scheduler instance.
+ * @return array
+ */
+ private function hydrate_history_job( array $job, Job_Scheduler $scheduler ): array {
+ $child_ids = $job['child_ids'] ?? [];
+ $remote_sites = $job['data']['sites'] ?? [];
+ $has_remote = Settings::is_governing_site()
+ && is_array( $remote_sites )
+ && count( $remote_sites ) > 1;
+
+ if ( ( ! is_array( $child_ids ) || empty( $child_ids ) ) && ! $has_remote ) {
+ return $job;
+ }
+
+ $children_completed = 0;
+ $children_terminal = 0;
+ $local_failed = 0;
+ $local_cancelled = 0;
+
+ foreach ( $child_ids as $child_id ) {
+ if ( ! is_string( $child_id ) || '' === $child_id ) {
+ continue;
+ }
+
+ $child = $scheduler->get_status( $child_id );
+ if ( ! $child ) {
+ continue;
+ }
+
+ $status = $child['status'] ?? '';
+
+ if ( Abstract_Job::STATUS_COMPLETED === $status ) {
+ ++$children_completed;
+ }
+
+ if ( in_array( $status, Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ ++$children_terminal;
+ }
+
+ if ( Abstract_Job::STATUS_FAILED === $status ) {
+ ++$local_failed;
+ } elseif ( Abstract_Job::STATUS_CANCELLED === $status ) {
+ ++$local_cancelled;
+ }
+ }
+
+ $children_total = count( $child_ids );
+
+ // Capture stored aggregates first: when _remote_aggregated is set they already include remote sites.
+ $stored_children_completed = (int) ( $job['children_completed'] ?? 0 );
+ $stored_children_total = (int) ( $job['children_total'] ?? 0 );
+
+ $job['children_total'] = $children_total;
+ $job['children_completed'] = $children_completed;
+ $job['children_failed'] = max(
+ (int) ( $job['children_failed'] ?? 0 ),
+ $local_failed + $local_cancelled
+ );
+
+ // Totals come from the stored batch_count to avoid an API call; only completed counts need remote polling.
+ if ( $has_remote ) {
+ $current_site = \OneSearch\Utils::normalize_url( get_site_url() );
+
+ $remote_total = 0;
+ foreach ( $remote_sites as $site_info ) {
+ $site_url = \OneSearch\Utils::normalize_url( $site_info['site_url'] ?? '' );
+ if ( $site_url === $current_site ) {
+ continue; // Skip local site, already counted above.
+ }
+ $remote_total += (int) ( $site_info['batch_count'] ?? 0 );
+ }
+
+ if ( ! empty( $job['data']['_remote_aggregated'] ) ) {
+ $children_completed = $stored_children_completed ?: $children_completed;
+ $children_total = $stored_children_total ?: $children_total + $remote_total;
+ $job['children_completed'] = $children_completed;
+ $job['children_total'] = $children_total;
+ } else {
+ $remote_progress = $scheduler->fetch_remote_job_progress( $remote_sites );
+ $children_completed += $remote_progress['completed'];
+ $children_terminal += $remote_progress['terminal'];
+ $children_total += $remote_total;
+
+ $job['children_completed'] = $children_completed;
+ $job['children_total'] = $children_total;
+ }
+ }
+
+ $determine_status_from_children = static function () use ( $local_failed, $local_cancelled, $children_terminal, $children_total ): string {
+ if ( $local_cancelled > 0 ) {
+ return Abstract_Job::STATUS_CANCELLED;
+ }
+ if ( $local_failed > 0 ) {
+ return Abstract_Job::STATUS_FAILED;
+ }
+ if ( $children_terminal === $children_total && $children_total > 0 ) {
+ return Abstract_Job::STATUS_COMPLETED;
+ }
+ return '';
+ };
+
+ // Keep a failed or cancelled run as it is: failing cancels its batches, and cancelling was the user's call.
+ $derived_status = in_array( $job['status'] ?? '', [ Abstract_Job::STATUS_FAILED, Abstract_Job::STATUS_CANCELLED ], true )
+ ? ''
+ : $determine_status_from_children();
+
+ if ( '' !== $derived_status && ( $job['status'] ?? '' ) !== $derived_status ) {
+ $job['status'] = $derived_status;
+
+ if ( Abstract_Job::STATUS_COMPLETED !== $derived_status && empty( $job['error'] ) && $local_failed > 0 ) {
+ $job['error'] = sprintf(
+ /* translators: 1: failed child batches, 2: total child batches */
+ __( '%1$d/%2$d child batches failed', 'onesearch' ),
+ $local_failed,
+ $children_total
+ );
+ }
+ }
+
+ // Covers runs settle_parent() didn't finish, like cancelled ones, so later history loads needn't ask the brand sites again.
+ if ( $has_remote && empty( $job['data']['_remote_aggregated'] ) && in_array( $job['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ unset( $job['data']['_needs_remote_finalize'], $job['data']['_remote_waiting_since'] );
+ $job['data']['_remote_aggregated'] = true;
+ $job['finished_at'] = $job['finished_at'] ?? time();
+ $job['updated_at'] = time();
+
+ ( new Job_Repository() )->upsert( $job );
+ }
+
+ return $job;
+ }
+
+ /**
+ * Permission check for job read endpoints.
+ *
+ * Allows access via WordPress admin session (manage_options)
+ * OR via the X-OneSearch-Token header (for remote governing-site requests).
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return bool|\WP_Error
+ */
+ public function check_job_read_permissions( $request ) {
+ if ( current_user_can( 'manage_options' ) ) {
+ return true;
+ }
+
+ $token = $request->get_header( 'X-OneSearch-Token' );
+ if ( empty( $token ) ) {
+ return false;
+ }
+
+ $api_key = Settings::get_api_key();
+
+ return ! empty( $api_key ) && hash_equals( $api_key, sanitize_text_field( wp_unslash( $token ) ) );
+ }
+
+ /**
+ * Proxy endpoint: fetch job status from a remote (child) site.
+ *
+ * The governing site calls this to poll a child site's job status
+ * without exposing the child's API key to the browser.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_remote_job_status( $request ) {
+ $site_url = $request->get_param( 'site_url' );
+ $job_id = $request->get_param( 'job_id' );
+ $context = $this->get_remote_job_request_context( (string) $site_url );
+
+ if ( is_wp_error( $context ) ) {
+ return $context;
+ }
+
+ $base_url = $context['base_url'];
+ $headers = array_merge(
+ $context['headers'],
+ [ 'Origin' => get_site_url() ]
+ );
+ $namespace = self::NAMESPACE;
+ $encoded_job_id = rawurlencode( (string) $job_id );
+
+ $job_response = wp_safe_remote_get(
+ sprintf( '%s/wp-json/%s/jobs/%s', $base_url, $namespace, $encoded_job_id ),
+ [
+ 'headers' => $headers,
+ 'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
+ ]
+ );
+
+ $children_response = wp_safe_remote_get(
+ sprintf( '%s/wp-json/%s/jobs/%s/children', $base_url, $namespace, $encoded_job_id ),
+ [
+ 'headers' => $headers,
+ 'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
+ ]
+ );
+
+ $job_data = null;
+ $children_data = [];
+
+ if ( ! is_wp_error( $job_response ) ) {
+ $code = wp_remote_retrieve_response_code( $job_response );
+ $body = wp_remote_retrieve_body( $job_response );
+ if ( 200 === $code ) {
+ $decoded = json_decode( $body, true );
+ $job_data = $decoded['job'] ?? null;
+ }
+ }
+
+ if ( ! is_wp_error( $children_response ) ) {
+ $code = wp_remote_retrieve_response_code( $children_response );
+ $body = wp_remote_retrieve_body( $children_response );
+ if ( 200 === $code ) {
+ $decoded = json_decode( $body, true );
+ $children_data = $decoded['children'] ?? [];
+ }
+ }
+
+ if ( ! $job_data ) {
+ return new \WP_Error(
+ 'onesearch_remote_job_not_found',
+ __( 'Job not found on remote site.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'job' => $job_data,
+ 'children' => $children_data,
+ ]
+ );
+ }
+
+ /**
+ * Retry a failed batch on a remote child site through the governing site.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function retry_remote_job( $request ) {
+ $site_url = $request->get_param( 'site_url' );
+ $job_id = $request->get_param( 'job_id' );
+ $context = $this->get_remote_job_request_context( (string) $site_url );
+
+ if ( is_wp_error( $context ) ) {
+ return $context;
+ }
+
+ $response = wp_safe_remote_post(
+ sprintf(
+ '%s/wp-json/%s/jobs/%s/retry',
+ $context['base_url'],
+ self::NAMESPACE,
+ rawurlencode( (string) $job_id )
+ ),
+ [
+ 'headers' => array_merge(
+ $context['headers'],
+ [ 'Origin' => get_site_url() ]
+ ),
+ 'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
+ ]
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ $body = wp_remote_retrieve_body( $response );
+ $data = json_decode( $body, true );
+
+ if ( $code < 200 || $code >= 300 ) {
+ return new \WP_Error(
+ 'onesearch_remote_retry_failed',
+ $data['message'] ?? __( 'Remote retry failed.', 'onesearch' ),
+ [ 'status' => $code ]
+ );
+ }
+
+ return new WP_REST_Response( is_array( $data ) ? $data : [ 'success' => true ] );
+ }
+
+ /**
+ * Send a cancel request to a job on a remote child site.
+ *
+ * @param string $site_url The child site URL.
+ * @param string $job_id The job ID to cancel on the child site.
+ * @return bool|\WP_Error True once the child site accepts the cancel, false if it couldn't be reached, or the
+ * child site's error when it refuses.
+ */
+ private function send_remote_cancel( string $site_url, string $job_id ) {
+ $context = $this->get_remote_job_request_context( $site_url );
+
+ if ( is_wp_error( $context ) ) {
+ return false;
+ }
+
+ $headers = array_merge(
+ $context['headers'],
+ [ 'Origin' => get_site_url() ]
+ );
+
+ $response = wp_safe_remote_request(
+ sprintf(
+ '%s/wp-json/%s/jobs/%s',
+ $context['base_url'],
+ self::NAMESPACE,
+ rawurlencode( $job_id )
+ ),
+ [
+ 'method' => 'DELETE',
+ 'headers' => $headers,
+ 'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
+ ]
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return false;
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( $code >= 200 && $code < 300 ) {
+ return true;
+ }
+
+ $data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+
+ return new \WP_Error(
+ 'onesearch_remote_cancel_failed',
+ is_array( $data ) && ! empty( $data['message'] ) ? (string) $data['message'] : __( 'Remote cancel failed.', 'onesearch' ),
+ [ 'status' => $code ]
+ );
+ }
+
+ /**
+ * Cancel a job on a remote child site through the governing site.
+ *
+ * Proxies a DELETE request to the child site's /jobs/{id} endpoint.
+ * If the remote site is unreachable, still returns success so the
+ * local UI can clean up its state without waiting. A cancel the child
+ * site refuses comes back as its error.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function cancel_remote_job( $request ) {
+ $site_url = $request->get_param( 'site_url' );
+ $job_id = $request->get_param( 'job_id' );
+
+ $sent = $this->send_remote_cancel( (string) $site_url, (string) $job_id );
+
+ if ( is_wp_error( $sent ) ) {
+ return $sent;
+ }
+
+ if ( ! $sent ) {
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'warning' => __( 'Remote site unreachable; local state cleaned.', 'onesearch' ),
+ ]
+ );
+ }
+
+ return new WP_REST_Response( [ 'success' => true ] );
+ }
+
+ /**
+ * Resolve request data needed to talk to a child site's jobs endpoints.
+ *
+ * @param string $site_url Site URL.
+ * @return array{base_url:string,headers:array}|\WP_Error
+ */
+ private function get_remote_job_request_context( string $site_url ) {
+ $shared_sites = Settings::get_shared_sites();
+ $site_key = '';
+ $matched_url = '';
+
+ foreach ( $shared_sites as $url => $site_data ) {
+ if ( Utils::normalize_url( $url ) === Utils::normalize_url( $site_url ) ) {
+ $site_key = $site_data['api_key'] ?? '';
+ $matched_url = $url;
+ break;
+ }
+ }
+
+ if ( empty( $site_key ) ) {
+ return new \WP_Error(
+ 'onesearch_unknown_site',
+ __( 'Site not found in shared sites.', 'onesearch' ),
+ [ 'status' => 404 ]
+ );
+ }
+
+ return [
+ 'base_url' => untrailingslashit( $matched_url ),
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-OneSearch-Token' => $site_key,
+ ],
+ ];
+ }
+}
diff --git a/inc/Modules/Scheduler/Job_Scheduler.php b/inc/Modules/Scheduler/Job_Scheduler.php
new file mode 100644
index 00000000..eea3f2a4
--- /dev/null
+++ b/inc/Modules/Scheduler/Job_Scheduler.php
@@ -0,0 +1,1045 @@
+repository = new Job_Repository();
+ $this->register_hooks();
+ }
+
+ /**
+ * Register WordPress action hooks for job execution and AS lifecycle events.
+ */
+ private function register_hooks(): void {
+ if ( self::$hooks_registered ) {
+ return;
+ }
+ self::$hooks_registered = true;
+
+ add_action( self::HOOK, [ $this, 'execute_job' ], 10, 3 );
+ add_action( self::SETTLE_HOOK, [ $this, 'settle_parent' ] );
+
+ add_action( 'action_scheduler_failed_execution', [ $this, 'on_action_failed' ], 10, 3 );
+ add_action( 'action_scheduler_failed_schedule_new_action', [ $this, 'on_schedule_failed' ], 10, 3 );
+ }
+
+ /**
+ * Schedule a job for immediate async execution via Action Scheduler.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The job to schedule. Modified (status set to PENDING).
+ * @return int The Action Scheduler action ID.
+ *
+ * @throws \RuntimeException If Action Scheduler is unavailable or returns an invalid ID.
+ */
+ public function schedule( Abstract_Job $job ): int {
+ if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ throw new \RuntimeException( 'Action Scheduler dependency missing.' );
+ }
+
+ $job->set_status( Abstract_Job::STATUS_PENDING );
+ $this->persist_job( $job );
+
+ try {
+ return $this->enqueue( get_class( $job ), $job->get_id(), $job->get_group() );
+ } catch ( \RuntimeException $e ) {
+ $job->fail( 'Failed to enqueue action: Action Scheduler returned 0' );
+ $this->persist_job( $job );
+ throw $e;
+ }
+ }
+
+ /**
+ * Queue the Action Scheduler action that runs a job already stored as pending.
+ *
+ * Separate from schedule() so a parent can store its children, then itself, before any child can run and report
+ * back to a parent that doesn't know it yet.
+ *
+ * @param string $job_class FQCN of the job class.
+ * @param string $job_id The stored job's ID.
+ * @param string $group The job's group.
+ * @return int The Action Scheduler action ID.
+ *
+ * @throws \RuntimeException If Action Scheduler is unavailable or returns an invalid ID.
+ */
+ public function enqueue( string $job_class, string $job_id, string $group ): int {
+ if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ throw new \RuntimeException( 'Action Scheduler dependency missing.' );
+ }
+
+ $args = [
+ 'job_class' => $job_class,
+ 'job_id' => $job_id,
+ ];
+
+ $action_id = as_enqueue_async_action( self::HOOK, $args, 'onesearch_' . $group );
+
+ if ( ! $action_id ) {
+ throw new \RuntimeException(
+ sprintf( 'Failed to schedule job %s: Action Scheduler returned invalid action ID.', esc_html( $job_id ) )
+ );
+ }
+
+ $this->repository->set_action( $job_id, $action_id, $args );
+
+ return $action_id;
+ }
+
+ /**
+ * Start Action Scheduler on queued jobs now, rather than on the next admin page load or cron run.
+ *
+ * AS's shutdown-based dispatch only fires in wp-admin, so jobs queued from a REST request must nudge the runner directly.
+ */
+ public static function dispatch_queue(): void {
+ if (
+ class_exists( '\\ActionScheduler_AsyncRequest_QueueRunner' )
+ && class_exists( '\\ActionScheduler_Store' )
+ ) {
+ $runner = new \ActionScheduler_AsyncRequest_QueueRunner( \ActionScheduler_Store::instance() );
+ $runner->maybe_dispatch();
+ return;
+ }
+
+ spawn_cron();
+ }
+
+ /**
+ * Cancel a job and all its children in bulk.
+ *
+ * @param string $job_id The ID of the job to cancel.
+ */
+ public function cancel( string $job_id ): void {
+ global $wpdb;
+
+ $status = $this->get_status( $job_id );
+ $cancelled = false;
+ $child_ids = [];
+
+ if ( $status ) {
+ $group = 'onesearch_' . ( $status['group'] ?? 'default' );
+ $action_rec = $this->repository->get_action( $job_id );
+
+ if ( $action_rec && function_exists( 'as_unschedule_action' ) ) {
+ as_unschedule_action( self::HOOK, $action_rec['args'], $group );
+ }
+ }
+
+ $key = self::OPTION_PREFIX . $job_id;
+ $lock_key = $key . '_lock';
+
+ if ( ! $this->acquire_lock( $lock_key ) ) {
+ return;
+ }
+
+ try {
+ $fresh = $this->get_status( $job_id );
+ if ( $fresh ) {
+ $status = $fresh;
+ }
+
+ if ( $status ) {
+ $previous_status = $status['status'] ?? '';
+ $child_ids = $status['child_ids'] ?? [];
+
+ $has_unsuccessful = false;
+ if ( Abstract_Job::STATUS_COMPLETED === $previous_status && ! empty( $child_ids ) ) {
+ foreach ( $child_ids as $child_id ) {
+ $cs = $this->get_status( $child_id );
+ if ( $cs && ! in_array( $cs['status'] ?? '', [ Abstract_Job::STATUS_COMPLETED ], true ) ) {
+ $has_unsuccessful = true;
+ break;
+ }
+ }
+ }
+
+ if ( ! $has_unsuccessful && Abstract_Job::STATUS_COMPLETED === $previous_status ) {
+ $remote_sites = $status['data']['sites'] ?? [];
+ $current_site = Utils::normalize_url( get_site_url() );
+ foreach ( $remote_sites as $site_info ) {
+ if ( Utils::normalize_url( $site_info['site_url'] ?? '' ) !== $current_site ) {
+ $has_unsuccessful = true;
+ break;
+ }
+ }
+ }
+
+ $can_cancel = ! in_array( $previous_status, self::TERMINAL_STATUSES, true )
+ || ( Abstract_Job::STATUS_COMPLETED === $previous_status && $has_unsuccessful );
+
+ if ( $can_cancel ) {
+ $status['status'] = Abstract_Job::STATUS_CANCELLED;
+ $status['finished_at'] = time();
+ $status['updated_at'] = time();
+
+ // Snapshots lag behind notify_parent()'s direct increments, so the DB counters are the source of truth.
+ $counters = $this->repository->get_counters( $job_id );
+ $status['children_cancelled'] = $counters['cancelled'];
+ $status['children_failed'] = max( (int) ( $status['children_failed'] ?? 0 ), $counters['failed'] );
+ $status['children_completed'] = $counters['done'];
+
+ $this->repository->upsert( $status );
+ delete_transient( $key );
+
+ if ( ! empty( $child_ids ) ) {
+ Search_Controller::clear_reindex_state();
+ }
+
+ $cancelled = true;
+ }
+ }
+ } finally {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( $wpdb->options, [ 'option_name' => $lock_key ] );
+ wp_cache_delete( $lock_key, 'options' );
+ }
+
+ if ( $cancelled && ! empty( $child_ids ) ) {
+ $this->cancel_children( $job_id, $child_ids );
+ }
+ }
+
+ /**
+ * Cancel all pending/running child jobs of a parent in bulk.
+ *
+ * Children share one Action Scheduler group, so a single call unschedules them all. Finished children keep their
+ * status.
+ *
+ * @param string $parent_id Job ID of the parent (used to derive the AS group).
+ * @param string[] $child_ids Job IDs of all children registered on the parent.
+ */
+ private function cancel_children( string $parent_id, array $child_ids ): void {
+ if ( empty( $child_ids ) ) {
+ return;
+ }
+
+ $child_as_group = 'onesearch_reindex_' . $parent_id;
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ // With a hook set, AS matches args exactly, so an empty array would match none of the batches.
+ as_unschedule_all_actions( '', [], $child_as_group );
+ }
+
+ $now = time();
+ $this->repository->batch_cancel( $child_ids, $now );
+
+ foreach ( $child_ids as $child_id ) {
+ delete_transient( self::OPTION_PREFIX . $child_id );
+ }
+ }
+
+ /**
+ * Retrieve the stored job state using the dual storage strategy.
+ *
+ * Checks transients first (where active pending/running jobs live),
+ * then falls back to the custom table (where terminal jobs are stored).
+ *
+ * @param string $job_id The job ID to look up.
+ * @return array|null The job state, or null if not found.
+ */
+ public function get_status( string $job_id ): ?array {
+ $key = self::OPTION_PREFIX . $job_id;
+
+ $data = get_transient( $key );
+ if ( false !== $data && is_array( $data ) ) {
+ return $data;
+ }
+
+ return $this->repository->get_by_id( $job_id );
+ }
+
+ /**
+ * Schedule a retry for a failed job with exponential backoff.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The failed job to retry.
+ * @return int The Action Scheduler action ID for the retry.
+ *
+ * @throws \RuntimeException If Action Scheduler rejects the retry action.
+ */
+ public function schedule_retry( Abstract_Job $job ): int {
+ if ( ! function_exists( 'as_schedule_single_action' ) ) {
+ throw new \RuntimeException( 'Action Scheduler dependency missing.' );
+ }
+
+ $job->set_status( Abstract_Job::STATUS_PENDING );
+ $this->persist_job( $job );
+
+ $delay = $job->get_retry_delay_seconds() * (int) pow( 2, $job->get_retry_count() - 1 );
+
+ $args = [
+ 'job_class' => get_class( $job ),
+ 'job_id' => $job->get_id(),
+ 'retry' => $job->get_retry_count(),
+ ];
+
+ $action_id = as_schedule_single_action(
+ time() + $delay,
+ self::HOOK,
+ $args,
+ 'onesearch_' . $job->get_group()
+ );
+
+ if ( ! $action_id ) {
+ $job->fail( 'Failed to schedule retry action' );
+ $this->persist_job( $job );
+ throw new \RuntimeException(
+ sprintf( 'Failed to schedule retry for job %s.', esc_html( $job->get_id() ) )
+ );
+ }
+
+ $this->repository->set_action( $job->get_id(), $action_id, $args );
+
+ return $action_id;
+ }
+
+ /**
+ * Execute a job when Action Scheduler fires the 'onesearch_execute_job' hook.
+ *
+ * @param string $job_class FQCN of the job class.
+ * @param string $job_id Unique job identifier.
+ * @param int $retry Current retry attempt number (0 on first run).
+ *
+ * @throws \InvalidArgumentException If job_class or job_id is missing/invalid.
+ */
+ public function execute_job( string $job_class, string $job_id, int $retry = 0 ): void {
+ if ( ! $job_class || ! $job_id || ! class_exists( $job_class ) ) {
+ throw new \InvalidArgumentException( 'Invalid job arguments: missing job_class or job_id.' );
+ }
+
+ if ( ! is_a( $job_class, Abstract_Job::class, true ) ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Job class "%s" must extend Abstract_Job.', esc_html( $job_class ) )
+ );
+ }
+
+ $stored = $this->get_status( $job_id );
+ if ( ! $stored ) {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] Job %s not found in storage — action will be skipped.',
+ esc_html( $job_id )
+ )
+ );
+ return;
+ }
+
+ /** @var \OneSearch\Modules\Jobs\Abstract_Job $job */
+ $job = $job_class::from_array( $stored );
+
+ if ( $job->get_status() === Abstract_Job::STATUS_CANCELLED ) {
+ return;
+ }
+
+ $job->mark_running();
+ $this->persist_job( $job );
+
+ try {
+ $job->handle();
+
+ // cancel() may have landed while handle() ran, so re-read status rather than overwrite the cancellation.
+ $current = $this->repository->get_by_id( $job_id );
+ if ( $current && Abstract_Job::STATUS_CANCELLED === ( $current['status'] ?? '' ) ) {
+ $job->set_status( Abstract_Job::STATUS_CANCELLED );
+ $this->persist_job( $job );
+ return;
+ }
+
+ // A parent stored itself before queuing its children, which may have updated it since; this copy would undo that.
+ if ( $job->has_pending_children() ) {
+ return;
+ }
+
+ $job->mark_completed();
+ $this->persist_job( $job );
+ $this->notify_parent( $job );
+ } catch ( \Throwable $e ) {
+ $job->set_retry_count( $retry + 1 );
+
+ // cancel() may have landed while handle() ran, so respect it rather than overwrite.
+ $current = $this->repository->get_by_id( $job_id );
+ if ( $current && Abstract_Job::STATUS_CANCELLED === ( $current['status'] ?? '' ) ) {
+ $job->set_status( Abstract_Job::STATUS_CANCELLED );
+ $this->persist_job( $job );
+ return;
+ }
+
+ if ( $job->should_retry() ) {
+ $job->fail( $e->getMessage() );
+ $this->persist_job( $job );
+ $this->schedule_retry( $job );
+ return;
+ }
+
+ $job->fail( $e->getMessage() . ' (retries exhausted: ' . $job->get_retry_count() . '/' . $job->get_max_retries() . ')' );
+ $this->persist_job( $job );
+ $this->notify_parent( $job );
+
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] Job %s failed permanently: %s',
+ $job->get_id(),
+ $job->get_error()
+ )
+ );
+ }
+ }
+
+ /**
+ * Log when Action Scheduler itself fails to execute an action.
+ *
+ * @param int $action_id The AS action ID that failed.
+ * @param \Throwable $exception The exception from Action Scheduler.
+ * @param string $context Execution context.
+ */
+ public function on_action_failed( int $action_id, \Throwable $exception, string $context ): void {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] Action %d failed in context "%s": %s',
+ $action_id,
+ $context,
+ $exception->getMessage()
+ )
+ );
+ }
+
+ /**
+ * Log when Action Scheduler fails to schedule a new action.
+ *
+ * @param int $action_id The AS action ID involved.
+ * @param \Throwable $exception The scheduling exception.
+ * @param string $context Scheduling context.
+ */
+ public function on_schedule_failed( int $action_id, \Throwable $exception, string $context ): void {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] Failed to schedule action %d in context "%s": %s',
+ $action_id,
+ $context,
+ $exception->getMessage()
+ )
+ );
+ }
+
+ /**
+ * Persist the full job state.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The job whose state to persist.
+ */
+ public function persist_job( Abstract_Job $job ): void {
+ $key = self::OPTION_PREFIX . $job->get_id();
+ $data = $job->to_array();
+
+ $this->repository->upsert( $data );
+
+ if ( in_array( $job->get_status(), self::TERMINAL_STATUSES, true ) ) {
+ delete_transient( $key );
+ } else {
+ set_transient( $key, $data, self::TRANSIENT_EXPIRATION );
+ }
+ }
+
+ /**
+ * Notify a parent job that one of its children has completed.
+ *
+ * Uses three atomic SQL counters (children_done, children_failed,
+ * children_cancelled) to prevent race conditions when multiple children
+ * complete simultaneously, then settles the parent from those counters.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The child job that just completed or failed.
+ */
+ private function notify_parent( Abstract_Job $job ): void {
+ $parent_id = $job->get_parent_id();
+ if ( ! $parent_id || ! $this->get_status( $parent_id ) ) {
+ return;
+ }
+
+ $job_status = $job->get_status();
+ $is_failed = Abstract_Job::STATUS_FAILED === $job_status;
+ $is_cancelled = Abstract_Job::STATUS_CANCELLED === $job_status;
+
+ $this->repository->increment_counter( $parent_id, 'children_done' );
+
+ if ( $is_failed ) {
+ $this->repository->increment_counter( $parent_id, 'children_failed' );
+ }
+
+ if ( $is_cancelled ) {
+ $this->repository->increment_counter( $parent_id, 'children_cancelled' );
+ $this->repository->increment_counter( $parent_id, 'children_failed' );
+ }
+
+ $this->settle_parent( $parent_id );
+ }
+
+ /**
+ * Bring a parent job's progress and status in line with its children's counters.
+ *
+ * Safe to run more than once, since it reads the counters rather than adding to them. When another
+ * process holds the parent's lock, it tries again later, so the parent isn't left running after its
+ * last child has finished.
+ *
+ * @internal Also the callback for SETTLE_HOOK.
+ *
+ * @param string $parent_id The parent job ID.
+ */
+ public function settle_parent( string $parent_id ): void {
+ global $wpdb;
+
+ $parent_data = $this->get_status( $parent_id );
+ if ( ! $parent_data || in_array( $parent_data['status'] ?? '', self::TERMINAL_STATUSES, true ) ) {
+ return;
+ }
+
+ $counters = $this->repository->get_counters( $parent_id );
+ $done = $counters['done'];
+ $local_failed = $counters['failed'];
+ $local_cancelled = $counters['cancelled'];
+ $child_total = count( $parent_data['child_ids'] ?? [] );
+ $remote_sites = $this->get_remote_sites( $parent_data );
+
+ $parent_key = self::OPTION_PREFIX . $parent_id;
+ $lock_key = $parent_key . '_lock';
+
+ // A governing parent with nothing to index here is done locally, but still waits on its brand sites.
+ $all_local_done = $done >= $child_total && ( $child_total > 0 || ! empty( $remote_sites ) );
+
+ // Poll remote statuses before taking the lock so slow remote calls don't block cancel().
+ $remote = $all_local_done && ! empty( $remote_sites ) ? $this->check_remote_job_statuses( $remote_sites ) : null;
+
+ if ( ! $this->acquire_lock( $lock_key ) ) {
+ $this->schedule_settle( $parent_id, self::SETTLE_RETRY_DELAY );
+
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] settle_parent: Could not acquire lock for parent %s. Retrying in %d seconds.',
+ esc_html( $parent_id ),
+ self::SETTLE_RETRY_DELAY
+ )
+ );
+ return;
+ }
+
+ try {
+ $fresh = $this->get_status( $parent_id );
+ if ( $fresh ) {
+ $parent_data = $fresh;
+ }
+
+ if ( in_array( $parent_data['status'] ?? '', self::TERMINAL_STATUSES, true ) ) {
+ return;
+ }
+
+ $parent_data['children_completed'] = $done;
+ $parent_data['children_failed'] = $local_failed + $local_cancelled;
+ $parent_data['progress'] = min( $done, $parent_data['progress_total'] ?? $done );
+ $parent_data['updated_at'] = time();
+
+ if ( $all_local_done ) {
+ if ( null !== $remote && $this->is_waiting_on_remote( $remote, $parent_data ) ) {
+ $parent_data['data']['_needs_remote_finalize'] = true;
+ $parent_data['data']['_remote_waiting_since'] = $parent_data['data']['_remote_waiting_since'] ?? time();
+ $parent_data['status'] = Abstract_Job::STATUS_RUNNING;
+ $this->repository->upsert( $parent_data );
+ set_transient( $parent_key, $parent_data, self::TRANSIENT_EXPIRATION );
+
+ // Checked again on a schedule, so the run finishes even when no one has the progress view open.
+ $this->schedule_settle( $parent_id, self::REMOTE_POLL_DELAY );
+ return;
+ }
+
+ $remote_failed = null !== $remote ? $remote['failed'] + $remote['unreachable'] : 0;
+ $remote_cancelled = null !== $remote ? $remote['cancelled'] : 0;
+
+ // Cancelled beats failed beats completed.
+ if ( $local_cancelled + $remote_cancelled > 0 ) {
+ $parent_data['status'] = Abstract_Job::STATUS_CANCELLED;
+ } elseif ( $local_failed > 0 ) {
+ $parent_data['status'] = Abstract_Job::STATUS_FAILED;
+ $parent_data['error'] = sprintf( '%d/%d child batches failed', $local_failed, $child_total );
+ } elseif ( $remote_failed > 0 ) {
+ $parent_data['status'] = Abstract_Job::STATUS_FAILED;
+ $parent_data['error'] = sprintf(
+ /* translators: 1: failed remote sites */
+ __( '%1$d remote site(s) failed', 'onesearch' ),
+ $remote_failed
+ );
+ } else {
+ $parent_data['status'] = Abstract_Job::STATUS_COMPLETED;
+ $parent_data['error'] = null;
+ }
+
+ $parent_data['children_failed'] = $local_failed + $local_cancelled + $remote_failed + $remote_cancelled;
+ $parent_data['progress'] = $parent_data['progress_total'];
+ $parent_data['finished_at'] = time();
+ unset( $parent_data['data']['_needs_remote_finalize'], $parent_data['data']['_remote_waiting_since'] );
+
+ $this->store_batch_totals( $parent_data, min( $done, $child_total ) - $local_failed, $remote );
+ Search_Controller::clear_reindex_state();
+
+ // A check on the brand sites may still be queued, which has nothing left to do.
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( '', [], 'onesearch_settle_' . $parent_id );
+ }
+ }
+
+ $this->repository->upsert( $parent_data );
+
+ if ( in_array( $parent_data['status'], self::TERMINAL_STATUSES, true ) ) {
+ delete_transient( $parent_key );
+ } else {
+ set_transient( $parent_key, $parent_data, self::TRANSIENT_EXPIRATION );
+ }
+ } finally {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( $wpdb->options, [ 'option_name' => $lock_key ] );
+ wp_cache_delete( $lock_key, 'options' );
+ }
+ }
+
+ /**
+ * Queue another attempt to settle a parent job.
+ *
+ * @internal Public so retries can hand a reopened parent back to the scheduler.
+ *
+ * @param string $parent_id The parent job ID.
+ * @param int $delay Seconds to wait first.
+ */
+ public function schedule_settle( string $parent_id, int $delay ): void {
+ if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_get_scheduled_actions' ) ) {
+ return;
+ }
+
+ $group = 'onesearch_settle_' . $parent_id;
+
+ // Not a unique action: AS counts this running attempt as a duplicate, so it couldn't reschedule itself.
+ $pending = as_get_scheduled_actions(
+ [
+ 'hook' => self::SETTLE_HOOK,
+ 'group' => $group,
+ 'status' => 'pending',
+ ],
+ 'ids'
+ );
+ if ( ! empty( $pending ) ) {
+ return;
+ }
+
+ as_schedule_single_action( time() + $delay, self::SETTLE_HOOK, [ 'parent_id' => $parent_id ], $group );
+ }
+
+ /**
+ * Reopen a finished governing parent so it settles once its brand sites have finished.
+ *
+ * Used when this site's own batches are done but a brand site's may not be, such as right after a brand's batches
+ * were retried.
+ *
+ * @param array $parent_data Stored parent job data.
+ */
+ public function await_remote_sites( array $parent_data ): void {
+ $parent_id = (string) ( $parent_data['id'] ?? '' );
+ if ( '' === $parent_id ) {
+ return;
+ }
+
+ unset( $parent_data['data']['_remote_aggregated'], $parent_data['data']['_remote_waiting_since'] );
+ $parent_data['data']['_needs_remote_finalize'] = true;
+
+ $parent_data['status'] = Abstract_Job::STATUS_RUNNING;
+ $parent_data['error'] = null;
+ $parent_data['finished_at'] = null;
+ $parent_data['updated_at'] = time();
+
+ $this->repository->upsert( $parent_data );
+ set_transient( self::OPTION_PREFIX . $parent_id, $parent_data, self::TRANSIENT_EXPIRATION );
+
+ $this->schedule_settle( $parent_id, self::SETTLE_RETRY_DELAY );
+ }
+
+ /**
+ * Get the brand site jobs a governing parent ran alongside its own.
+ *
+ * @param array $parent_data Stored parent job data.
+ * @return array Empty when the parent didn't run on any brand site.
+ */
+ public function get_remote_sites( array $parent_data ): array {
+ $sites = $parent_data['data']['sites'] ?? [];
+
+ // The first entry is this site's own job, so a run that reached a brand site has more than one.
+ return Settings::is_governing_site() && is_array( $sites ) && count( $sites ) > 1 ? $sites : [];
+ }
+
+ /**
+ * Whether a governing parent should keep waiting on its brand sites.
+ *
+ * A brand site that's briefly unreachable is waited out, but not forever.
+ *
+ * @param array{running:int, unreachable:int} $remote The brand sites' statuses.
+ * @param array $parent_data Stored parent job data.
+ */
+ private function is_waiting_on_remote( array $remote, array $parent_data ): bool {
+ if ( $remote['running'] > 0 ) {
+ return true;
+ }
+
+ if ( 0 === $remote['unreachable'] ) {
+ return false;
+ }
+
+ $waiting_since = (int) ( $parent_data['data']['_remote_waiting_since'] ?? time() );
+
+ return time() - $waiting_since < self::REMOTE_UNREACHABLE_GRACE;
+ }
+
+ /**
+ * Record a finished parent's batch totals across every site it ran on.
+ *
+ * The totals recorded when the run started go stale when batches are rebuilt or retried, and storing the final
+ * ones means the history needn't ask the brand sites again.
+ *
+ * @param array $parent_data Parent job data, updated in place.
+ * @param int $local_completed Batches completed on this site.
+ * @param array{completed:int, total:int, totals:array}|null $remote The brand sites' progress, if any.
+ */
+ private function store_batch_totals( array &$parent_data, int $local_completed, ?array $remote ): void {
+ $local_total = count( $parent_data['child_ids'] ?? [] );
+
+ if ( null === $remote ) {
+ if ( isset( $parent_data['data']['total_batches'] ) ) {
+ $parent_data['data']['total_batches'] = $local_total;
+ }
+ return;
+ }
+
+ $current_site = Utils::normalize_url( get_site_url() );
+ foreach ( $parent_data['data']['sites'] as $i => $site ) {
+ $site_url = Utils::normalize_url( $site['site_url'] ?? '' );
+
+ if ( $site_url === $current_site ) {
+ $parent_data['data']['sites'][ $i ]['batch_count'] = $local_total;
+ } elseif ( isset( $remote['totals'][ $site_url ] ) ) {
+ $parent_data['data']['sites'][ $i ]['batch_count'] = $remote['totals'][ $site_url ];
+ }
+ }
+
+ $parent_data['data']['total_batches'] = (int) array_sum( array_column( $parent_data['data']['sites'], 'batch_count' ) );
+ $parent_data['children_total'] = $parent_data['data']['total_batches'];
+ $parent_data['children_completed'] = max( 0, $local_completed ) + $remote['completed'];
+ $parent_data['data']['_remote_aggregated'] = true;
+ }
+
+ /**
+ * Build a normalized-URL → API-key map from the configured shared sites.
+ *
+ * Read once per remote operation so per-site lookups don't repeatedly
+ * hit the option store and re-decrypt every key inside a loop.
+ *
+ * @return array Map of normalized site URL → API key.
+ */
+ private function get_shared_site_keys(): array {
+ $keys = [];
+ foreach ( Settings::get_shared_sites() as $url => $data ) {
+ $keys[ Utils::normalize_url( $url ) ] = $data['api_key'] ?? '';
+ }
+ return $keys;
+ }
+
+ /**
+ * Fetch a remote child site's job payload over the REST API.
+ *
+ * @param string $site_url Normalized remote site URL.
+ * @param string $job_id Remote job ID.
+ * @param array $site_keys Map of normalized URL → API key.
+ * @return array|null The decoded `job` payload, or null on any failure
+ * (missing key, transport error, non-200, malformed body).
+ */
+ private function fetch_remote_job( string $site_url, string $job_id, array $site_keys ): ?array {
+ $api_key = $site_keys[ $site_url ] ?? '';
+ if ( empty( $api_key ) ) {
+ return null;
+ }
+
+ $response = wp_safe_remote_get(
+ sprintf(
+ '%s/wp-json/%s/jobs/%s',
+ untrailingslashit( $site_url ),
+ 'onesearch/v1',
+ rawurlencode( $job_id )
+ ),
+ [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'Origin' => get_site_url(),
+ 'X-OneSearch-Token' => $api_key,
+ ],
+ 'timeout' => 5, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
+ ]
+ );
+
+ if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
+ return null;
+ }
+
+ $data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+
+ if ( ! is_array( $data ) || ! isset( $data['job'] ) || ! is_array( $data['job'] ) ) {
+ return null;
+ }
+
+ return $data['job'];
+ }
+
+ /**
+ * Check the status of remote child site jobs and count failures/cancellations.
+ *
+ * Called by settle_parent() when all local children complete to verify
+ * that child site jobs also succeeded before marking the parent complete.
+ *
+ * @param array $remote_sites Array of remote site jobs.
+ * @return array{failed:int,cancelled:int,running:int,unreachable:int,completed:int,total:int,totals:array} Status counts per site,
+ * and completed and total batches across them.
+ */
+ public function check_remote_job_statuses( array $remote_sites ): array {
+ $result = [
+ 'failed' => 0,
+ 'cancelled' => 0,
+ 'running' => 0,
+ 'unreachable' => 0,
+ 'completed' => 0,
+ 'total' => 0,
+ 'totals' => [],
+ ];
+ $current_site = Utils::normalize_url( get_site_url() );
+ $site_keys = $this->get_shared_site_keys();
+
+ foreach ( $remote_sites as $site_info ) {
+ $site_url = Utils::normalize_url( $site_info['site_url'] ?? '' );
+ $job_id = $site_info['job_id'] ?? '';
+
+ if ( $site_url === $current_site || empty( $job_id ) ) {
+ continue;
+ }
+
+ $job_data = $this->fetch_remote_job( $site_url, $job_id, $site_keys );
+
+ // Can't be confirmed either way yet, which the caller decides how long to tolerate.
+ if ( null === $job_data ) {
+ ++$result['unreachable'];
+ continue;
+ }
+
+ $remote_status = $job_data['status'] ?? '';
+
+ if ( 'failed' === $remote_status ) {
+ ++$result['failed'];
+ } elseif ( 'cancelled' === $remote_status ) {
+ ++$result['cancelled'];
+ } elseif ( in_array( $remote_status, [ 'pending', 'running' ], true ) ) {
+ ++$result['running'];
+ }
+
+ // A brand's children_completed counts every finished batch, so its failures are taken back out.
+ $site_total = (int) ( $job_data['children_total'] ?? $job_data['progress_total'] ?? 0 );
+
+ $result['completed'] += max( 0, (int) ( $job_data['children_completed'] ?? 0 ) - (int) ( $job_data['children_failed'] ?? 0 ) );
+ $result['total'] += $site_total;
+ $result['totals'][ $site_url ] = $site_total;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch completed batch counts from remote child site jobs.
+ *
+ * Called by hydrate_history_job() to aggregate progress across all sites.
+ *
+ * @param array $remote_sites Array of remote site jobs.
+ * @return array{completed:int,terminal:int,total:int} Sum of completed batches, terminal batches, and total batches from remote sites.
+ */
+ public function fetch_remote_job_progress( array $remote_sites ): array {
+ $completed = 0;
+ $terminal = 0;
+ $total = 0;
+ $current_site = Utils::normalize_url( get_site_url() );
+ $site_keys = $this->get_shared_site_keys();
+
+ foreach ( $remote_sites as $site_info ) {
+ $site_url = Utils::normalize_url( $site_info['site_url'] ?? '' );
+ $job_id = $site_info['job_id'] ?? '';
+
+ if ( $site_url === $current_site || empty( $job_id ) ) {
+ continue;
+ }
+
+ $job_data = $this->fetch_remote_job( $site_url, $job_id, $site_keys );
+
+ if ( null === $job_data ) {
+ continue;
+ }
+
+ // children_completed counts every finished batch, failed ones included.
+ $job_finished = (int) ( $job_data['children_completed'] ?? $job_data['progress'] ?? 0 );
+ $job_failed = (int) ( $job_data['children_failed'] ?? 0 );
+
+ $completed += max( 0, $job_finished - $job_failed );
+ $terminal += $job_finished;
+ $total += (int) ( $job_data['children_total'] ?? $job_data['progress_total'] ?? 0 );
+ }
+
+ return [
+ 'completed' => $completed,
+ 'terminal' => $terminal,
+ 'total' => $total,
+ ];
+ }
+
+ /**
+ * Acquire a spinlock stored in wp_options with a 60-second expiry lease.
+ *
+ * Uses INSERT IGNORE for the fast path (lock is free). If the row already
+ * exists, reads the stored expiry timestamp and overwrites the lock when it
+ * has passed — this recovers from processes that were killed before their
+ * finally block could delete the row. Returns true when the caller owns the
+ * lock, false if all attempts were exhausted against a live holder.
+ *
+ * @param string $lock_key The wp_options option_name to use as the lock key.
+ * @return bool True if the lock was acquired, false otherwise.
+ */
+ private function acquire_lock( string $lock_key ): bool {
+ global $wpdb;
+
+ $max_tries = 10;
+ $lock_expiry = time() + 60;
+
+ for ( $attempt = 0; $attempt < $max_tries; ++$attempt ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $acquired = (bool) $wpdb->query(
+ $wpdb->prepare(
+ "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %d, 'no')",
+ $lock_key,
+ $lock_expiry
+ )
+ );
+
+ if ( $acquired ) {
+ wp_cache_delete( $lock_key, 'options' );
+ return true;
+ }
+
+ // Lock exists — check whether it belongs to a crashed process.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $stored = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ $lock_key
+ )
+ );
+
+ if ( $stored < time() ) {
+ // Expired: overwrite (last-writer-wins is safe — the previous holder is gone).
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update(
+ $wpdb->options,
+ [ 'option_value' => $lock_expiry ],
+ [ 'option_name' => $lock_key ]
+ );
+ wp_cache_delete( $lock_key, 'options' );
+ return true;
+ }
+
+ usleep( 100000 + $attempt * 50000 );
+ }
+
+ return false;
+ }
+}
diff --git a/inc/Modules/Schema/Job_Repository.php b/inc/Modules/Schema/Job_Repository.php
new file mode 100644
index 00000000..2957b336
--- /dev/null
+++ b/inc/Modules/Schema/Job_Repository.php
@@ -0,0 +1,462 @@
+table = $wpdb->prefix . Job_Schema::TABLE_NAME;
+ }
+
+ /**
+ * Insert a new job row.
+ *
+ * @param array $data Flat row data from Abstract_Job::to_array().
+ */
+ public function insert( array $data ): bool {
+ global $wpdb;
+
+ $row = $this->prepare_row( $data );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ return false !== $wpdb->insert( $this->table, $row );
+ }
+
+ /**
+ * Update an existing job row.
+ *
+ * @param string $id Job ID.
+ * @param array $data Partial or full row data.
+ */
+ public function update( string $id, array $data ): bool {
+ global $wpdb;
+
+ $row = $this->prepare_row( $data );
+ unset( $row['id'] ); // never overwrite the PK.
+
+ if ( empty( $row ) ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ return false !== $wpdb->update( $this->table, $row, [ 'id' => $id ] );
+ }
+
+ /**
+ * Insert-or-update a job row (upsert via ON DUPLICATE KEY UPDATE).
+ *
+ * @param array $data Full row data from Abstract_Job::to_array().
+ */
+ public function upsert( array $data ): bool {
+ global $wpdb;
+
+ $row = $this->prepare_row( $data );
+
+ // $wpdb->prepare('%s', null) yields '' not NULL, which would break IS NULL queries on parent_id.
+ $columns = array_keys( $row );
+ $placeholders = [];
+ $values = [];
+ $updates = [];
+
+ foreach ( $row as $col => $val ) {
+ if ( null === $val ) {
+ $placeholders[] = 'NULL';
+ } else {
+ $placeholders[] = '%s';
+ $values[] = $val;
+ }
+
+ if ( 'id' !== $col ) {
+ $updates[] = null === $val ? "{$col} = NULL" : "{$col} = VALUES({$col})";
+ }
+ }
+
+ $columns_sql = implode( ', ', $columns );
+ $values_sql = implode( ', ', $placeholders );
+ $update_sql = implode( ', ', $updates );
+
+ // $this->table is safe: it is always $wpdb->prefix + a hard-coded constant.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ if ( empty( $values ) ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ return false !== $wpdb->query( "INSERT INTO {$this->table} ({$columns_sql}) VALUES ({$values_sql}) ON DUPLICATE KEY UPDATE {$update_sql}" );
+ }
+
+ $sql = $wpdb->prepare(
+ "INSERT INTO {$this->table} ({$columns_sql}) VALUES ({$values_sql}) ON DUPLICATE KEY UPDATE {$update_sql}",
+ ...$values
+ );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is already prepared by $wpdb->prepare() above.
+ return false !== $wpdb->query( $sql );
+ // phpcs:enable
+ }
+
+ /**
+ * Fetch a single job row by ID.
+ *
+ * @param string $id Job ID.
+ * @return array|null
+ */
+ public function get_by_id( string $id ): ?array {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %s", $id ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return $row ? $this->hydrate_row( $row ) : null;
+ }
+
+ /**
+ * Store the Action Scheduler action ID and args for a job.
+ *
+ * @param string $id Job ID.
+ * @param int $action_id AS action ID.
+ * @param mixed[] $args AS action args.
+ */
+ public function set_action( string $id, int $action_id, array $args ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ return false !== $wpdb->update(
+ $this->table,
+ [
+ 'action_id' => $action_id,
+ 'action_args' => wp_json_encode( $args ),
+ ],
+ [ 'id' => $id ]
+ );
+ }
+
+ /**
+ * Read the Action Scheduler action ID and args for a job.
+ *
+ * @param string $id Job ID.
+ * @return array{action_id: int, args: mixed[]}|null
+ */
+ public function get_action( string $id ): ?array {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row(
+ $wpdb->prepare( "SELECT action_id, action_args FROM {$this->table} WHERE id = %s", $id ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( ! $row || ! $row['action_id'] ) {
+ return null;
+ }
+
+ return [
+ 'action_id' => (int) $row['action_id'],
+ 'args' => json_decode( (string) $row['action_args'], true ) ?: [],
+ ];
+ }
+
+ /**
+ * Atomically increment a counter column on the parent row.
+ *
+ * @param string $parent_id Job ID of the parent.
+ * @param string $column One of: children_done, children_failed, children_cancelled.
+ * @param int $amount Amount to add (default 1).
+ */
+ public function increment_counter( string $parent_id, string $column, int $amount = 1 ): bool {
+ global $wpdb;
+
+ $allowed = [ 'children_done', 'children_failed', 'children_cancelled' ];
+ if ( ! in_array( $column, $allowed, true ) ) {
+ return false;
+ }
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return false !== $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$this->table} SET {$column} = {$column} + %d, updated_at = %d WHERE id = %s",
+ $amount,
+ time(),
+ $parent_id
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Atomically decrement a counter column, clamped to 0.
+ *
+ * @param string $parent_id Job ID of the parent.
+ * @param string $column Column to decrement.
+ * @return int New value after decrement.
+ */
+ public function decrement_counter( string $parent_id, string $column ): int {
+ global $wpdb;
+
+ $allowed = [ 'children_done', 'children_failed', 'children_cancelled' ];
+ if ( ! in_array( $column, $allowed, true ) ) {
+ return 0;
+ }
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$this->table} SET {$column} = GREATEST({$column} - 1, 0), updated_at = %d WHERE id = %s",
+ time(),
+ $parent_id
+ )
+ );
+
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT {$column} FROM {$this->table} WHERE id = %s", $parent_id )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Cancel multiple jobs in one UPDATE, skipping already-terminal rows.
+ *
+ * Only transitions 'pending' and 'running' rows to 'cancelled';
+ * completed or already-cancelled children are left untouched.
+ *
+ * @param string[] $ids Array of job IDs to cancel.
+ * @param int $now Unix timestamp for finished_at / cancelled_at / updated_at.
+ */
+ public function batch_cancel( array $ids, int $now ): void {
+ global $wpdb;
+
+ if ( empty( $ids ) ) {
+ return;
+ }
+
+ $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%s' ) );
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$this->table}
+ SET status = 'cancelled', finished_at = %d, cancelled_at = %d, updated_at = %d
+ WHERE id IN ({$placeholders}) AND status IN ('pending', 'running')",
+ $now,
+ $now,
+ $now,
+ ...$ids
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Reset counter columns to explicit values (e.g. when retrying jobs).
+ *
+ * @param string $parent_id Job ID of the parent.
+ * @param int $done New value for children_done.
+ * @param int $failed New value for children_failed.
+ * @param int $cancelled New value for children_cancelled.
+ */
+ public function reset_counters( string $parent_id, int $done = 0, int $failed = 0, int $cancelled = 0 ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ return false !== $wpdb->update(
+ $this->table,
+ [
+ 'children_done' => $done,
+ 'children_failed' => $failed,
+ 'children_cancelled' => $cancelled,
+ 'updated_at' => time(),
+ ],
+ [ 'id' => $parent_id ]
+ );
+ }
+
+ /**
+ * Read all three counter values for a parent job.
+ *
+ * @param string $parent_id Job ID of the parent.
+ * @return array{done: int, failed: int, cancelled: int}
+ */
+ public function get_counters( string $parent_id ): array {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT children_done, children_failed, children_cancelled FROM {$this->table} WHERE id = %s",
+ $parent_id
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( ! $row ) {
+ return [
+ 'done' => 0,
+ 'failed' => 0,
+ 'cancelled' => 0,
+ ];
+ }
+
+ return [
+ 'done' => (int) $row['children_done'],
+ 'failed' => (int) $row['children_failed'],
+ 'cancelled' => (int) $row['children_cancelled'],
+ ];
+ }
+
+ /**
+ * Paginate terminal top-level jobs (no parent_id) or all terminal jobs.
+ *
+ * @param int $page 1-based page number.
+ * @param int $per_page Rows per page.
+ * @param bool $parent_only When true, only returns root jobs (parent_id IS NULL).
+ * @return array[]
+ */
+ public function get_terminal_jobs( int $page, int $per_page, bool $parent_only = true ): array {
+ global $wpdb;
+
+ $offset = ( $page - 1 ) * $per_page;
+ $parent_sql = $parent_only ? 'AND parent_id IS NULL' : '';
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$this->table}
+ WHERE status IN ('completed', 'failed', 'cancelled') {$parent_sql}
+ ORDER BY created_at DESC
+ LIMIT %d OFFSET %d",
+ $per_page,
+ $offset
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable
+
+ return array_map( [ $this, 'hydrate_row' ], $rows ?: [] );
+ }
+
+ /**
+ * Count terminal jobs (optionally root-only).
+ *
+ * @param bool $parent_only When true, only counts rows with parent_id IS NULL.
+ */
+ public function count_terminal_jobs( bool $parent_only = true ): int {
+ global $wpdb;
+
+ $parent_sql = $parent_only ? 'AND parent_id IS NULL' : '';
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$this->table}
+ WHERE status IN ('completed', 'failed', 'cancelled') {$parent_sql}"
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Convert a domain-model array (from Abstract_Job::to_array()) into a flat DB row.
+ *
+ * Retry fields and child_ids are folded into the `data` JSON blob since they
+ * don't have dedicated columns.
+ *
+ * @param array $data Domain-model array from Abstract_Job::to_array().
+ * @return array
+ */
+ private function prepare_row( array $data ): array {
+ $now = time();
+
+ $blob_keys = [ 'max_retries', 'retry_count', 'retry_delay_seconds', 'child_ids' ];
+ $blob = is_array( $data['data'] ?? null ) ? $data['data'] : [];
+ foreach ( $blob_keys as $key ) {
+ if ( array_key_exists( $key, $data ) ) {
+ $blob[ $key ] = $data[ $key ];
+ }
+ }
+
+ return [
+ 'id' => (string) ( $data['id'] ?? '' ),
+ 'type' => (string) ( $data['type'] ?? '' ),
+ 'status' => (string) ( $data['status'] ?? 'pending' ),
+ 'parent_id' => isset( $data['parent_id'] ) ? (string) $data['parent_id'] : null,
+ 'group_name' => (string) ( $data['group'] ?? 'default' ),
+ 'progress' => (int) ( $data['progress'] ?? 0 ),
+ 'progress_total' => (int) ( $data['progress_total'] ?? 0 ),
+ 'progress_percent' => (float) ( $data['progress_percent'] ?? 0.0 ),
+ 'error' => isset( $data['error'] ) ? (string) $data['error'] : null,
+ 'children_total' => (int) ( $data['children_total'] ?? 0 ),
+ 'children_done' => (int) ( $data['children_completed'] ?? $data['children_done'] ?? 0 ),
+ 'children_failed' => (int) ( $data['children_failed'] ?? 0 ),
+ 'children_cancelled' => (int) ( $data['children_cancelled'] ?? 0 ),
+ 'data' => wp_json_encode( $blob ),
+ 'created_at' => (int) ( $data['created_at'] ?? $now ),
+ 'updated_at' => (int) ( $data['updated_at'] ?? $now ),
+ 'finished_at' => isset( $data['finished_at'] ) ? (int) $data['finished_at'] : null,
+ 'cancelled_at' => isset( $data['cancelled_at'] ) ? (int) $data['cancelled_at'] : null,
+ ];
+ }
+
+ /**
+ * Convert a raw DB row back into the domain-model array shape (matching Abstract_Job::to_array()).
+ *
+ * @param array $row Raw database row from $wpdb.
+ * @return array
+ */
+ private function hydrate_row( array $row ): array {
+ $blob = [];
+ if ( isset( $row['data'] ) && is_string( $row['data'] ) ) {
+ $decoded = json_decode( $row['data'], true );
+ $blob = is_array( $decoded ) ? $decoded : [];
+ }
+
+ // Strip the folded-in retry/child_ids so the domain model sees only user-provided metadata.
+ $data_only = array_diff_key(
+ $blob,
+ array_flip( [ 'max_retries', 'retry_count', 'retry_delay_seconds', 'child_ids' ] )
+ );
+
+ return [
+ 'id' => (string) $row['id'],
+ 'type' => (string) ( $row['type'] ?? '' ),
+ 'status' => (string) ( $row['status'] ?? '' ),
+ 'parent_id' => isset( $row['parent_id'] ) ? (string) $row['parent_id'] : null,
+ 'group' => (string) ( $row['group_name'] ?? 'default' ),
+ 'progress' => (int) ( $row['progress'] ?? 0 ),
+ 'progress_total' => (int) ( $row['progress_total'] ?? 0 ),
+ 'progress_percent' => (float) ( $row['progress_percent'] ?? 0.0 ),
+ 'error' => isset( $row['error'] ) ? (string) $row['error'] : null,
+ 'action_id' => isset( $row['action_id'] ) ? (int) $row['action_id'] : null,
+ 'children_total' => (int) ( $row['children_total'] ?? 0 ),
+ 'children_completed' => (int) ( $row['children_done'] ?? 0 ),
+ 'children_failed' => (int) ( $row['children_failed'] ?? 0 ),
+ 'children_cancelled' => (int) ( $row['children_cancelled'] ?? 0 ),
+ 'data' => $data_only,
+ 'max_retries' => (int) ( $blob['max_retries'] ?? 3 ),
+ 'retry_count' => (int) ( $blob['retry_count'] ?? 0 ),
+ 'retry_delay_seconds' => (int) ( $blob['retry_delay_seconds'] ?? 60 ),
+ 'child_ids' => $blob['child_ids'] ?? [],
+ 'created_at' => (int) ( $row['created_at'] ?? 0 ),
+ 'updated_at' => (int) ( $row['updated_at'] ?? 0 ),
+ 'finished_at' => isset( $row['finished_at'] ) ? (int) $row['finished_at'] : null,
+ 'cancelled_at' => isset( $row['cancelled_at'] ) ? (int) $row['cancelled_at'] : null,
+ ];
+ }
+}
diff --git a/inc/Modules/Schema/Job_Schema.php b/inc/Modules/Schema/Job_Schema.php
new file mode 100644
index 00000000..6028ca78
--- /dev/null
+++ b/inc/Modules/Schema/Job_Schema.php
@@ -0,0 +1,75 @@
+= self::SCHEMA_VERSION ) {
+ return;
+ }
+
+ self::create_table();
+ update_option( self::VERSION_OPTION, self::SCHEMA_VERSION, false );
+ }
+
+ /**
+ * Create or upgrade the table using dbDelta.
+ */
+ public static function create_table(): void {
+ global $wpdb;
+
+ $table = $wpdb->prefix . self::TABLE_NAME;
+ $charset = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE {$table} (
+ id varchar(36) NOT NULL,
+ type varchar(50) NOT NULL,
+ status varchar(20) NOT NULL,
+ parent_id varchar(36) DEFAULT NULL,
+ group_name varchar(50) NOT NULL,
+ progress int NOT NULL DEFAULT 0,
+ progress_total int NOT NULL DEFAULT 0,
+ progress_percent decimal(5,1) NOT NULL DEFAULT 0.0,
+ error longtext DEFAULT NULL,
+ action_id bigint DEFAULT NULL,
+ action_args longtext DEFAULT NULL,
+ children_total int NOT NULL DEFAULT 0,
+ children_done int NOT NULL DEFAULT 0,
+ children_failed int NOT NULL DEFAULT 0,
+ children_cancelled int NOT NULL DEFAULT 0,
+ data longtext DEFAULT NULL,
+ created_at int unsigned NOT NULL,
+ updated_at int unsigned NOT NULL,
+ finished_at int unsigned DEFAULT NULL,
+ cancelled_at int unsigned DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY idx_status (status),
+ KEY idx_parent (parent_id),
+ KEY idx_created (created_at),
+ KEY idx_type_status (type, status)
+) {$charset};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+ }
+}
diff --git a/inc/Modules/Search/Indexer.php b/inc/Modules/Search/Indexer.php
index 2158bdd1..b98a3320 100644
--- a/inc/Modules/Search/Indexer.php
+++ b/inc/Modules/Search/Indexer.php
@@ -34,11 +34,6 @@ final class Indexer {
*/
private const REQUIRED_ACL = [ 'search', 'addObject', 'deleteIndex', 'settings', 'editSettings' ];
- /**
- * The default batch size for indexing.
- */
- private const DEFAULT_BATCH_SIZE = 100;
-
/**
* The number of posts whose records are fetched per request, so they fit in a single page of results.
*/
@@ -164,48 +159,18 @@ public function get_post_records( array $site_post_ids ): array|\WP_Error {
}
/**
- * Replaces this site's records with its current indexable posts.
- *
- * Batches that fail to save are skipped, and reported in the returned error.
+ * Deletes this site's records, so a re-index can rebuild them from scratch.
*
* @return true|\WP_Error
*/
- public function reindex(): bool|\WP_Error {
- $post_types = self::get_indexable_post_types();
- if ( is_wp_error( $post_types ) ) {
- return $post_types;
- }
-
+ public function clear_site_records(): bool|\WP_Error {
// Set up the index first, since it may not exist yet.
$index = $this->get_initialized_index();
if ( is_wp_error( $index ) ) {
return $index;
}
- $is_deleted = $this->delete_site_records( [ get_site_url() ] );
- if ( is_wp_error( $is_deleted ) ) {
- return $is_deleted;
- }
-
- if ( empty( $post_types ) ) {
- return true;
- }
-
- $errors = new \WP_Error();
-
- foreach ( $this->generate_post_batches( $post_types, self::DEFAULT_BATCH_SIZE ) as $records ) {
- $is_saved = $this->save_records( $records );
-
- if ( is_wp_error( $is_saved ) ) {
- $data = $is_saved->get_error_data();
-
- // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- @todo Surface this better with a Logger class.
- error_log( sprintf( 'Algolia indexing error: %s %s', $is_saved->get_error_message(), $data['message'] ?? '' ) );
- $errors->merge_from( $is_saved );
- }
- }
-
- return $errors->has_errors() ? $errors : true;
+ return $this->delete_site_records( [ get_site_url() ] );
}
/**
@@ -216,29 +181,39 @@ public function reindex(): bool|\WP_Error {
* @return true|\WP_Error
*/
public function save_post( \WP_Post $post ): bool|\WP_Error {
+ return $this->save_posts( [ $post ] );
+ }
+
+ /**
+ * Adds or updates the records of several posts, in a single write.
+ *
+ * @param \WP_Post[] $posts The posts.
+ *
+ * @return true|\WP_Error
+ */
+ public function save_posts( array $posts ): bool|\WP_Error {
+ if ( empty( $posts ) ) {
+ return true;
+ }
+
$post_record = new Post_Record();
- $site_post_id = $post_record->get_site_post_id( $post->ID );
- $records = $post_record->to_records( $post );
+ $records = [];
+ $chunk_counts = [];
+
+ foreach ( $posts as $post ) {
+ $post_records = $post_record->to_records( $post );
+
+ $chunk_counts[ $post_record->get_site_post_id( $post->ID ) ] = count( $post_records );
+
+ $records = array_merge( $records, $post_records );
+ }
$is_saved = $this->save_records( $records );
if ( is_wp_error( $is_saved ) ) {
return $is_saved;
}
- // Chunks from when the post was longer would still match searches, so delete them. Checked first, since deleting is slow.
- $saved_records = $this->get_post_records( [ $site_post_id ] );
- $has_stale = is_wp_error( $saved_records ) || [] !== array_filter(
- $saved_records[ $site_post_id ] ?? [],
- static fn ( array $record ): bool => (int) ( $record['chunk_index'] ?? 0 ) >= count( $records )
- );
-
- if ( ! $has_stale ) {
- return true;
- }
-
- return $this->delete_by_filter(
- sprintf( '%s AND chunk_index >= %d', self::filter_any_of( 'site_post_id', [ $site_post_id ] ), count( $records ) )
- );
+ return $this->delete_stale_chunks( $chunk_counts );
}
/**
@@ -249,8 +224,28 @@ public function save_post( \WP_Post $post ): bool|\WP_Error {
* @return true|\WP_Error
*/
public function delete_post( int $post_id ): bool|\WP_Error {
+ return $this->delete_posts( [ $post_id ] );
+ }
+
+ /**
+ * Deletes the records of several posts, in a single request.
+ *
+ * @param int[] $post_ids The post IDs.
+ *
+ * @return true|\WP_Error
+ */
+ public function delete_posts( array $post_ids ): bool|\WP_Error {
+ if ( empty( $post_ids ) ) {
+ return true;
+ }
+
+ $post_record = new Post_Record();
+
return $this->delete_by_filter(
- self::filter_any_of( 'site_post_id', [ ( new Post_Record() )->get_site_post_id( $post_id ) ] )
+ self::filter_any_of(
+ 'site_post_id',
+ array_map( static fn ( int $post_id ): string => $post_record->get_site_post_id( $post_id ), $post_ids )
+ )
);
}
@@ -307,6 +302,46 @@ private function save_records( array $records ): bool|\WP_Error {
return $index->save_records( $records );
}
+ /**
+ * Deletes chunks left over from when posts were longer, since they'd still match searches.
+ *
+ * The saved records are checked first, since deleting is slow.
+ *
+ * @param array $chunk_counts The number of chunks each post has now, keyed by `site_post_id`.
+ *
+ * @return true|\WP_Error
+ */
+ private function delete_stale_chunks( array $chunk_counts ): bool|\WP_Error {
+ $saved_records = $this->get_post_records( array_keys( $chunk_counts ) );
+
+ // A filter can't OR together clauses that each contain an AND, so posts are grouped by their chunk count instead.
+ $stale = [];
+ foreach ( $chunk_counts as $site_post_id => $count ) {
+ $has_stale = is_wp_error( $saved_records ) || [] !== array_filter(
+ $saved_records[ $site_post_id ] ?? [],
+ static fn ( array $record ): bool => (int) ( $record['chunk_index'] ?? 0 ) >= $count
+ );
+
+ if ( $has_stale ) {
+ $stale[ $count ][] = (string) $site_post_id;
+ }
+ }
+
+ foreach ( $stale as $count => $site_post_ids ) {
+ $filter = self::filter_any_of( 'site_post_id', $site_post_ids );
+ if ( count( $site_post_ids ) > 1 ) {
+ $filter = '(' . $filter . ')';
+ }
+
+ $is_deleted = $this->delete_by_filter( sprintf( '%s AND chunk_index >= %d', $filter, $count ) );
+ if ( is_wp_error( $is_deleted ) ) {
+ return $is_deleted;
+ }
+ }
+
+ return true;
+ }
+
/**
* Deletes the records matching a filter.
*
@@ -428,34 +463,4 @@ private static function get_credentials(): array|\WP_Error {
'write_key' => $config['algolia_credentials']['write_key'] ?? null,
];
}
-
- /**
- * Generator that yields batches of Algolia records for indexing.
- *
- * We `yield` to avoid loading all posts into memory at once.
- *
- * @param string[] $post_types The post types to index.
- * @param int $batch_size The number of posts to process per batch.
- *
- * @return \Generator>
- */
- private function generate_post_batches( array $post_types, int $batch_size ): \Generator {
- $page = 1;
-
- while ( true ) {
- $posts = Post_Record::get_indexable_posts( $post_types, $page, $batch_size );
-
- if ( empty( $posts ) ) {
- break;
- }
-
- $records = [];
- foreach ( $posts as $post ) {
- $records = array_merge( $records, ( new Post_Record() )->to_records( $post ) );
- }
- yield $records;
-
- ++$page;
- }
- }
}
diff --git a/inc/Modules/Search/Search.php b/inc/Modules/Search/Search.php
index 17d04a7e..e472bc8f 100644
--- a/inc/Modules/Search/Search.php
+++ b/inc/Modules/Search/Search.php
@@ -147,7 +147,7 @@ public function get_post_type_permalink( $permalink, $post ) {
return $permalink;
}
- $remote_post = $this->resolve_remote_post_for_permalink( $post );
+ $remote_post = $this->resolve_remote_post( $post );
if ( $remote_post instanceof \WP_Post && ! empty( $remote_post->guid ) ) {
return $remote_post->guid;
@@ -298,7 +298,8 @@ public function get_post_terms( $terms, $post_id, $taxonomy ) {
return $terms;
}
- $post = get_post( $post_id );
+ // get_post() rejects the negative IDs remote posts carry, so it can never return one.
+ $post = $this->resolve_remote_post( (int) $post_id );
if ( ! $post || ! isset( $post->onesearch_remote_taxonomies ) ) {
return $terms;
@@ -536,18 +537,18 @@ private function is_remote_post( $post ): bool {
}
/**
- * Locate the remote post a permalink request belongs to.
+ * Locate the remote post a filter call belongs to.
*
- * Some link filters pass the WP_Post object, others only an ID. Attachment
+ * Some filters pass the WP_Post object, others only an ID. Attachment
* results share a single proxy ID, so an ID alone can't identify them — for
* those we rely on the in-loop global post. Regular remote posts have unique
* negative IDs and can be matched by scanning the result set.
*
- * @param int|\WP_Post $post Post object or ID from the link filter.
+ * @param int|\WP_Post $post Post object or ID from the filter.
*
* @return \WP_Post|null The matching remote post, or null when not ours.
*/
- private function resolve_remote_post_for_permalink( $post ): ?\WP_Post {
+ private function resolve_remote_post( $post ): ?\WP_Post {
global $wp_query;
if ( $post instanceof \WP_Post ) {
diff --git a/inc/Modules/Search/Watcher.php b/inc/Modules/Search/Watcher.php
index 2d1bcb0e..f69e94a9 100644
--- a/inc/Modules/Search/Watcher.php
+++ b/inc/Modules/Search/Watcher.php
@@ -10,6 +10,8 @@
namespace OneSearch\Modules\Search;
use OneSearch\Contracts\Interfaces\Registrable;
+use OneSearch\Modules\Jobs\Sync_Job;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
/**
* Class - Watcher
@@ -31,26 +33,54 @@ public function register_hooks(): void {
/**
* Triggered when a post's status changes (e.g., publish, update, trash, etc.)
*
+ * Schedules an async Sync_Job to update the post in Algolia instead of
+ * performing the sync inline, keeping the request fast.
+ *
* @internal Hook callback
*
* @param string $new_status The new post status.
* @param string $old_status The previous post status.
* @param \WP_Post $post The post object.
*/
- public function on_post_transition( $new_status, $old_status, $post ): void {
+ public function on_post_transition( $new_status, $old_status, $post ): void { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
if ( ! $post instanceof \WP_Post || ! $this->is_post_type_indexable( (string) $post->post_type ) ) {
return;
}
- $allowed_statuses = Post_Record::get_allowed_statuses( [ $post->post_type ] );
-
- // Check if the new status is allowed before reindexing.
- if ( ! in_array( $new_status, $allowed_statuses, true ) ) {
- // Only clean up if the post was indexed under its previous status.
- if ( in_array( $old_status, $allowed_statuses, true ) ) {
- $this->log_error( (int) $post->ID, $this->get_indexer()->delete_post( (int) $post->ID ) );
- }
+ $job = new Sync_Job();
+ $job->set_data(
+ [
+ 'post_ids' => [ (int) $post->ID ],
+ ]
+ );
+ $job->set_group( 'watcher' );
+ $job->set_max_retries( 2 );
+ $job->set_retry_delay_seconds( 30 );
+
+ $scheduler = new Job_Scheduler();
+
+ try {
+ $scheduler->schedule( $job );
+
+ // AS never starts its runner after a REST request, such as a block editor save; once per request is enough.
+ add_action( 'shutdown', [ Job_Scheduler::class, 'dispatch_queue' ] );
+ } catch ( \Throwable $e ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( sprintf( '[OneSearch] Failed to schedule Sync_Job: %s', $e->getMessage() ) );
+
+ // The edit still has to reach the index, so it's synced in this request instead.
+ $this->sync_post_inline( $post );
+ }
+ }
+ /**
+ * Synchronously sync a post to Algolia (fallback when async scheduling fails).
+ *
+ * @param \WP_Post $post The post to sync.
+ */
+ private function sync_post_inline( \WP_Post $post ): void {
+ if ( ! in_array( $post->post_status, Post_Record::get_allowed_statuses( [ $post->post_type ] ), true ) ) {
+ $this->log_error( (int) $post->ID, $this->get_indexer()->delete_post( (int) $post->ID ) );
return;
}
diff --git a/onesearch.php b/onesearch.php
index f7a123fd..a101cc88 100644
--- a/onesearch.php
+++ b/onesearch.php
@@ -69,6 +69,14 @@ function constants(): void {
return;
}
+// Load Action Scheduler early, before plugins_loaded, so its own hooks register on time.
+if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ $onesearch_as_path = ONESEARCH_DIR . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
+ if ( file_exists( $onesearch_as_path ) ) {
+ require_once $onesearch_as_path; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable
+ }
+}
+
// Load the plugin.
if ( class_exists( '\OneSearch\Main' ) ) {
\OneSearch\Main::instance();
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index dabf6fe7..8aadf78b 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -23,6 +23,8 @@ parameters:
- onesearch.php
stubFiles:
- tests/phpstan/stubs/wordpress-extended.php
+ scanFiles:
+ - tests/phpstan/stubs/action-scheduler.php
paths:
- onesearch.php
- uninstall.php
diff --git a/tests/js/HistoryTable.test.tsx b/tests/js/HistoryTable.test.tsx
new file mode 100644
index 00000000..d3c44881
--- /dev/null
+++ b/tests/js/HistoryTable.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import { render, screen, within } from '@testing-library/react';
+
+import HistoryTable from '@/components/SiteIndexableEntities/HistoryTable';
+import type { JobStatus } from '@/components/SiteIndexableEntities/types';
+
+const baseJob: JobStatus = {
+ id: 'job-0000000000000001',
+ status: 'completed',
+ progress: 0,
+ progress_total: 0,
+ progress_percent: 100,
+ created_at: 1760000000,
+ finished_at: 1760000039,
+};
+
+describe( 'HistoryTable', () => {
+ it( 'shows finished batches out of the total for each kind of job', () => {
+ const watcherSync: JobStatus = {
+ ...baseJob,
+ type: 'sync',
+ group: 'watcher',
+ progress: 1,
+ progress_total: 1,
+ children_completed: 0,
+ children_total: 0,
+ };
+
+ render(
+
+ );
+
+ const [ , ...rows ] = screen.getAllByRole( 'row' );
+
+ // A watcher sync is its own single batch, finished only if it completed.
+ expect(
+ rows.map(
+ ( row ) =>
+ within( row as HTMLElement ).getAllByRole( 'cell' )[ 5 ]
+ ?.textContent
+ )
+ ).toEqual( [ '1/1', '0/1', '2/3' ] );
+ } );
+
+ it( 'offers a retry for a failed run but not for a cancelled one', () => {
+ const withFailedBatch: JobStatus = {
+ ...baseJob,
+ type: 'reindex',
+ child_ids: [ 'a', 'b' ],
+ children_completed: 1,
+ children_failed: 1,
+ children_total: 2,
+ };
+
+ render(
+
+ );
+
+ const [ , failedRow, cancelledRow ] = screen.getAllByRole( 'row' );
+
+ expect(
+ within( failedRow as HTMLElement ).getByRole( 'button', {
+ name: 'Retry',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ within( cancelledRow as HTMLElement ).queryByRole( 'button', {
+ name: 'Retry',
+ } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/tests/js/SiteIndexableEntities.test.tsx b/tests/js/SiteIndexableEntities.test.tsx
index 5f46c14b..135f82fd 100644
--- a/tests/js/SiteIndexableEntities.test.tsx
+++ b/tests/js/SiteIndexableEntities.test.tsx
@@ -20,17 +20,22 @@ const okJson = ( data: unknown ) =>
json: jest.fn().mockResolvedValue( data ),
} ) as unknown as Response;
+const noActiveReindex = okJson( { success: true, active: false } );
+
describe( 'SiteIndexableEntities', () => {
it( 'loads saved entities and enables reindexing when data exists', async () => {
- global.fetch = jest.fn().mockResolvedValueOnce(
- okJson( {
- indexableEntities: {
- entities: {
- [ currentSiteUrl ]: [ 'post' ],
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( noActiveReindex )
+ .mockResolvedValueOnce(
+ okJson( {
+ indexableEntities: {
+ entities: {
+ [ currentSiteUrl ]: [ 'post' ],
+ },
},
- },
- } )
- ) as typeof fetch;
+ } )
+ ) as typeof fetch;
render(
{
} );
it( 'shows a message when a brand site has no selectable entities', async () => {
- global.fetch = jest.fn().mockResolvedValueOnce(
- okJson( {
- indexableEntities: {
- entities: {},
- },
- } )
- ) as typeof fetch;
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce( noActiveReindex )
+ .mockResolvedValueOnce(
+ okJson( {
+ indexableEntities: {
+ entities: {},
+ },
+ } )
+ ) as typeof fetch;
render(
{
).toBeInTheDocument();
} );
- it( 'saves entities and reindexes after changes', async () => {
+ it( 'saves entities and handles reindex error', async () => {
const setNotice = jest.fn();
const onEntitiesSaved = jest.fn();
const setSaving = jest.fn();
global.fetch = jest
.fn()
+ .mockResolvedValueOnce( noActiveReindex )
.mockResolvedValueOnce(
okJson( {
indexableEntities: {
@@ -107,9 +116,15 @@ describe( 'SiteIndexableEntities', () => {
.mockResolvedValueOnce( okJson( { success: true } ) )
.mockResolvedValueOnce(
okJson( {
- success: true,
- message: 'Reindex complete.',
+ jobs: [],
+ total: 0,
+ page: 1,
+ per_page: 5,
+ total_pages: 0,
} )
+ )
+ .mockResolvedValueOnce(
+ okJson( { success: false, message: 'Reindex failed.' } )
) as typeof fetch;
render(
@@ -141,9 +156,11 @@ describe( 'SiteIndexableEntities', () => {
expect( setSaving ).toHaveBeenCalledWith( true );
expect( setSaving ).toHaveBeenLastCalledWith( false );
- expect( setNotice ).toHaveBeenCalledWith( {
- message: 'Reindex complete.',
- type: 'success',
+ await waitFor( () => {
+ expect( setNotice ).toHaveBeenCalledWith( {
+ message: 'Reindex failed.',
+ type: 'error',
+ } );
} );
} );
@@ -179,6 +196,7 @@ describe( 'SiteIndexableEntities', () => {
global.fetch = jest
.fn()
+ .mockResolvedValueOnce( noActiveReindex )
.mockResolvedValueOnce(
okJson( {
indexableEntities: {
@@ -228,6 +246,7 @@ describe( 'SiteIndexableEntities', () => {
global.fetch = jest
.fn()
+ .mockResolvedValueOnce( noActiveReindex )
.mockResolvedValueOnce(
okJson( {
indexableEntities: {
@@ -275,11 +294,10 @@ describe( 'SiteIndexableEntities', () => {
} );
} );
- global.fetch = jest.fn().mockResolvedValueOnce(
- okJson( {
- success: false,
- message: 'failed',
- } )
+ global.fetch = jest.fn( async ( input: RequestInfo | URL ) =>
+ String( input ).endsWith( '/re-index' )
+ ? okJson( { success: false, message: 'failed' } )
+ : okJson( { jobs: [], page: 1, total_pages: 0 } )
) as typeof fetch;
fireEvent.click( screen.getByRole( 'button', { name: 'Re-index' } ) );
@@ -288,10 +306,10 @@ describe( 'SiteIndexableEntities', () => {
await screen.findByText( 'Re-index saved entities' )
).toBeInTheDocument();
- fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
- expect(
- screen.queryByText( 'Re-index saved entities' )
- ).not.toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Close' } ) );
+ await waitFor( () => {
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ } );
fireEvent.click( screen.getByRole( 'button', { name: 'Re-index' } ) );
fireEvent.click(
diff --git a/tests/js/useReindexJob.test.ts b/tests/js/useReindexJob.test.ts
new file mode 100644
index 00000000..df42c425
--- /dev/null
+++ b/tests/js/useReindexJob.test.ts
@@ -0,0 +1,223 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react';
+
+import { useReindexJob } from '@/components/SiteIndexableEntities/useReindexJob';
+
+const currentSiteUrl = 'https://governing.example.com/';
+const brandSiteUrl = 'https://brand.example.com/';
+
+const governingSite = {
+ site_name: 'Governing Site',
+ site_url: currentSiteUrl,
+ job_id: 'gov-job',
+ batch_count: 1,
+};
+const brandSite = {
+ site_name: 'Brand',
+ site_url: brandSiteUrl,
+ job_id: 'brand-job',
+ batch_count: 1,
+};
+
+const respond = ( data: unknown, ok = true ) =>
+ ( {
+ ok,
+ status: ok ? 200 : 404,
+ json: jest.fn().mockResolvedValue( data ),
+ } ) as unknown as Response;
+
+const tick = ( ms: number ) =>
+ act( async () => {
+ await jest.advanceTimersByTimeAsync( ms );
+ } );
+
+describe( 'useReindexJob', () => {
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it( 'keeps polling while a job status read fails', async () => {
+ jest.useFakeTimers();
+
+ let governingJob = { id: 'gov-job', status: 'running' };
+ let brandStatus = respond( {
+ success: true,
+ job: { id: 'brand-job', status: 'running' },
+ children: [],
+ } );
+
+ global.fetch = jest.fn( async ( input: RequestInfo | URL ) => {
+ const url = String( input );
+ if ( url.endsWith( '/re-index' ) ) {
+ return respond( {
+ success: true,
+ jobs: [ governingSite, brandSite ],
+ } );
+ }
+ if ( url.includes( '/jobs/remote-status' ) ) {
+ return brandStatus;
+ }
+ if ( url.includes( '/jobs/gov-job/children' ) ) {
+ return respond( {
+ success: true,
+ children: [ { id: 'batch', status: 'completed' } ],
+ } );
+ }
+ if ( url.includes( '/jobs/gov-job' ) ) {
+ return respond( { success: true, job: governingJob } );
+ }
+ return respond( { jobs: [], page: 1, total_pages: 0 } );
+ } ) as typeof fetch;
+
+ const { result } = renderHook( () =>
+ useReindexJob( { currentSiteUrl, setNotice: jest.fn() } )
+ );
+
+ await act( async () => {
+ await result.current.handleReIndex();
+ } );
+ await tick( 0 );
+
+ // This site's only batch is done; the brand's run is still going when its status read fails.
+ brandStatus = respond(
+ {
+ code: 'onesearch_remote_job_not_found',
+ message: 'Job not found on remote site.',
+ },
+ false
+ );
+ await tick( 2000 );
+
+ expect( result.current.reindexing ).toBe( true );
+ expect( result.current.siteStates ).toHaveLength( 2 );
+
+ governingJob = { id: 'gov-job', status: 'completed' };
+ brandStatus = respond( {
+ success: true,
+ job: { id: 'brand-job', status: 'completed' },
+ children: [],
+ } );
+ await tick( 2000 );
+
+ expect( result.current.reindexing ).toBe( false );
+ } );
+
+ it( 'follows and can cancel the runs that started when another brand site failed to start', async () => {
+ jest.useFakeTimers();
+
+ const setNotice = jest.fn();
+ const fetchMock = jest.fn( async ( input: RequestInfo | URL ) => {
+ const url = String( input );
+ if ( url.endsWith( '/re-index' ) ) {
+ return respond( {
+ success: false,
+ message:
+ 'Failed to connect to the child site. Error code 500',
+ jobs: [ governingSite, brandSite ],
+ } );
+ }
+ if ( url.includes( '/jobs/remote-status' ) ) {
+ return respond( {
+ success: true,
+ job: { id: 'brand-job', status: 'running' },
+ children: [],
+ } );
+ }
+ if ( url.includes( '/jobs/gov-job/children' ) ) {
+ return respond( {
+ success: true,
+ children: [ { id: 'batch', status: 'running' } ],
+ } );
+ }
+ if ( url.includes( '/jobs/gov-job' ) ) {
+ return respond( {
+ success: true,
+ job: { id: 'gov-job', status: 'running' },
+ } );
+ }
+ return respond( { jobs: [], page: 1, total_pages: 0 } );
+ } );
+ global.fetch = fetchMock as typeof fetch;
+
+ const { result } = renderHook( () =>
+ useReindexJob( { currentSiteUrl, setNotice } )
+ );
+
+ act( () => result.current.setShowReindexingModal( true ) );
+ await act( async () => {
+ await result.current.handleReIndex();
+ } );
+ await tick( 2000 );
+
+ expect( setNotice ).toHaveBeenCalledWith( {
+ message: 'Failed to connect to the child site. Error code 500',
+ type: 'error',
+ } );
+ expect( result.current.reindexing ).toBe( true );
+ expect( result.current.showReindexingModal ).toBe( true );
+ expect(
+ result.current.siteStates.map( ( s ) => s.reindexJob?.status )
+ ).toEqual( [ 'running', 'running' ] );
+
+ await act( async () => {
+ await result.current.handleCancelJob();
+ } );
+
+ expect( fetchMock ).toHaveBeenCalledWith(
+ expect.stringMatching( /\/jobs\/gov-job$/ ),
+ expect.objectContaining( { method: 'DELETE' } )
+ );
+ expect( fetchMock ).toHaveBeenCalledWith(
+ expect.stringContaining( '/jobs/remote-cancel' ),
+ expect.objectContaining( {
+ body: JSON.stringify( {
+ site_url: brandSiteUrl,
+ job_id: 'brand-job',
+ } ),
+ } )
+ );
+ expect( result.current.reindexing ).toBe( false );
+ } );
+
+ it( 'resumes a run that was in progress before a page refresh', async () => {
+ jest.useFakeTimers();
+
+ let governingJob = { id: 'gov-job', status: 'running' };
+
+ global.fetch = jest.fn( async ( input: RequestInfo | URL ) => {
+ const url = String( input );
+ if ( url.endsWith( '/re-index/status' ) ) {
+ return respond( {
+ success: true,
+ active: true,
+ jobs: [ governingSite ],
+ } );
+ }
+ if ( url.includes( '/jobs/gov-job/children' ) ) {
+ return respond( { success: true, children: [] } );
+ }
+ if ( url.includes( '/jobs/gov-job' ) ) {
+ return respond( { success: true, job: governingJob } );
+ }
+ return respond( { jobs: [], page: 1, total_pages: 0 } );
+ } ) as typeof fetch;
+
+ const { result } = renderHook( () =>
+ useReindexJob( { currentSiteUrl, setNotice: jest.fn() } )
+ );
+ await tick( 0 );
+
+ expect( result.current.reindexing ).toBe( true );
+ expect( result.current.showReindexingModal ).toBe( true );
+ expect( result.current.siteStates[ 0 ]?.reindexJob?.status ).toBe(
+ 'running'
+ );
+
+ governingJob = { id: 'gov-job', status: 'completed' };
+ await tick( 2000 );
+
+ expect( result.current.reindexing ).toBe( false );
+ } );
+} );
diff --git a/tests/phpstan/stubs/action-scheduler.php b/tests/phpstan/stubs/action-scheduler.php
new file mode 100644
index 00000000..9c2277e9
--- /dev/null
+++ b/tests/phpstan/stubs/action-scheduler.php
@@ -0,0 +1,48 @@
+ $args
+ * @param string $group
+ * @return int
+ */
+ function as_enqueue_async_action( string $hook, array $args = [], string $group = '' ): int {}
+
+ /**
+ * @param int $timestamp
+ * @param string $hook
+ * @param array $args
+ * @param string $group
+ * @param bool $unique
+ * @param int $priority
+ * @return int
+ */
+ function as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int {}
+
+ /**
+ * @param string $hook
+ * @param array $args
+ * @param string $group
+ * @return int|null
+ */
+ function as_unschedule_action( string $hook, array $args = [], string $group = '' ): ?int {}
+
+ /**
+ * @param string $hook
+ * @param array $args
+ * @param string $group
+ * @return void
+ */
+ function as_unschedule_all_actions( string $hook, array $args = [], string $group = '' ): void {}
+}
\ No newline at end of file
diff --git a/tests/phpunit/Integration/Modules/Jobs/Reindex_JobTest.php b/tests/phpunit/Integration/Modules/Jobs/Reindex_JobTest.php
new file mode 100644
index 00000000..5f99cba9
--- /dev/null
+++ b/tests/phpunit/Integration/Modules/Jobs/Reindex_JobTest.php
@@ -0,0 +1,83 @@
+tear_down_governing_site();
+
+ parent::tearDown();
+ }
+
+ /**
+ * The site's old records are cleared before batches are queued, so posts that are gone don't linger, and a re-run
+ * drops batches from an earlier attempt, which were cancelled when it failed and would keep the job from completing.
+ */
+ public function test_handle_clears_the_site_records_and_queues_fresh_batches(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $requests = [];
+ $this->mock_algolia_http_client( $paths, null, null, $requests );
+
+ self::factory()->post->create( [ 'post_status' => 'publish' ] );
+
+ $j = new Reindex_Job();
+ $j->set_data( [ 'post_types' => [ 'post' ] ] );
+ $j->set_child_ids( [ 'batch-from-an-earlier-attempt' ] );
+
+ $j->handle();
+
+ $this->assertSame(
+ [ sprintf( 'site_url:"%s"', Utils::normalize_url( get_site_url() ) ) ],
+ $this->get_delete_filters( $requests )
+ );
+ $this->assertNotContains( 'batch-from-an-earlier-attempt', $j->get_child_ids() );
+ $this->assertCount( 1, $j->get_child_ids() );
+ }
+
+ /**
+ * A reindex that can't clear the site's records fails before queueing any batches.
+ */
+ public function test_handle_throws_when_records_cannot_be_cleared(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths, null, '/deleteByQuery' );
+
+ self::factory()->post->create( [ 'post_status' => 'publish' ] );
+
+ $j = new Reindex_Job();
+ $j->set_data( [ 'post_types' => [ 'post' ] ] );
+
+ try {
+ $j->handle();
+ $this->fail( 'Expected the reindex to fail.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'Failed to clear the site\'s Algolia records', $e->getMessage() );
+ $this->assertStringContainsString( 'forced test exception', $e->getMessage(), 'Algolia\'s own message explains the failure.' );
+ }
+
+ $this->assertSame( [], $j->get_child_ids() );
+ }
+}
diff --git a/tests/phpunit/Integration/Modules/Jobs/Sync_JobTest.php b/tests/phpunit/Integration/Modules/Jobs/Sync_JobTest.php
new file mode 100644
index 00000000..6a6a6051
--- /dev/null
+++ b/tests/phpunit/Integration/Modules/Jobs/Sync_JobTest.php
@@ -0,0 +1,124 @@
+tear_down_governing_site();
+
+ parent::tearDown();
+ }
+
+ /**
+ * A batch saves its indexable posts in a single write, deletes the records of the rest, and counts every post
+ * towards progress, including one that no longer exists.
+ */
+ public function test_a_mixed_batch_saves_deletes_and_counts_every_post(): void {
+ $this->set_up_governing_site();
+
+ $paths = [];
+ $requests = [];
+ $this->mock_algolia_http_client( $paths, null, null, $requests );
+
+ $saved_ids = self::factory()->post->create_many( 2, [ 'post_status' => 'publish' ] );
+ $deleted_id = self::factory()->post->create( [ 'post_status' => 'draft' ] );
+ $missing_id = self::factory()->post->create( [ 'post_status' => 'publish' ] );
+ wp_delete_post( $missing_id, true );
+
+ $job = ( new Sync_Job() )->set_data( [ 'post_ids' => [ ...$saved_ids, $deleted_id, $missing_id ] ] );
+ $job->handle();
+
+ $post_record = new Post_Record();
+
+ $this->assertCount( 1, array_filter( $paths, static fn ( string $path ): bool => str_ends_with( $path, '/batch' ) ) );
+ $this->assertEqualsCanonicalizing(
+ array_map( static fn ( int $post_id ): string => $post_record->get_site_post_id( $post_id ), $saved_ids ),
+ $this->get_indexed_site_post_ids( $requests, 2 )
+ );
+ $this->assertSame(
+ [ sprintf( 'site_post_id:"%s"', $post_record->get_site_post_id( $deleted_id ) ) ],
+ $this->get_delete_filters( $requests )
+ );
+ $this->assertSame( 4, $job->get_progress_total(), 'progress_total must match the batch size.' );
+ $this->assertSame( 4, $job->get_progress(), 'Every post in the batch must advance progress.' );
+ }
+
+ /**
+ * A batch that drops a post has to delete it by the very same `site_post_id`
+ * the records were written with, otherwise Algolia matches nothing and still
+ * reports success.
+ *
+ * @see Post_Record::get_site_post_id()
+ */
+ public function test_delete_filter_uses_the_stored_site_post_id(): void {
+ $this->set_up_governing_site();
+
+ $paths = [];
+ $requests = [];
+ $this->mock_algolia_http_client( $paths, null, null, $requests );
+
+ $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] );
+
+ $this->run_sync_job( [ $post_id ] );
+ $stored_id = $this->get_indexed_site_post_id( $requests );
+
+ // Drop the publish traffic, so only the delete request is left to assert on.
+ $requests = [];
+
+ wp_update_post(
+ [
+ 'ID' => $post_id,
+ 'post_status' => 'draft',
+ ]
+ );
+ $this->run_sync_job( [ $post_id ] );
+
+ $filters = $this->get_delete_filters( $requests );
+
+ $this->assertSame(
+ [ sprintf( 'site_post_id:"%s"', $stored_id ) ],
+ $filters,
+ 'Syncing an unpublished post must delete its records by the stored site_post_id.'
+ );
+
+ /*
+ * $stored_id is read back from the write traffic, so a regression that broke
+ * the write and the filter in the same way would slip past the assertion
+ * above. The raw site URL is the shape that regression takes.
+ */
+ $this->assertStringNotContainsString(
+ Utils::normalize_url( get_site_url() ),
+ $filters[0],
+ 'The raw site URL never appears in a stored site_post_id, so it must not be filtered on.'
+ );
+ }
+
+ /**
+ * Runs a Sync_Job over a batch of posts, the way the scheduler would.
+ *
+ * @param int[] $post_ids The posts to sync.
+ */
+ private function run_sync_job( array $post_ids ): void {
+ ( new Sync_Job() )->set_data( [ 'post_ids' => $post_ids ] )->handle();
+ }
+}
diff --git a/tests/phpunit/Integration/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php b/tests/phpunit/Integration/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
index b0aa5b9c..877327fc 100644
--- a/tests/phpunit/Integration/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
+++ b/tests/phpunit/Integration/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
@@ -60,6 +60,9 @@ public function tear_down(): void {
AlgoliaSDK::resetHttpClient();
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+
parent::tear_down();
}
diff --git a/tests/phpunit/Integration/Modules/Rest/Governing_Data_HandlerTest.php b/tests/phpunit/Integration/Modules/Rest/Governing_Data_HandlerTest.php
index 66776b91..d463ede6 100644
--- a/tests/phpunit/Integration/Modules/Rest/Governing_Data_HandlerTest.php
+++ b/tests/phpunit/Integration/Modules/Rest/Governing_Data_HandlerTest.php
@@ -19,6 +19,17 @@
*/
#[CoversClass( Governing_Data_Handler::class )]
class Governing_Data_HandlerTest extends TestCase {
+ /**
+ * {@inheritDoc}
+ */
+ protected function tearDown(): void {
+ delete_option( Settings::OPTION_SITE_TYPE );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+ delete_transient( Governing_Data_Handler::TRANSIENT_KEY );
+
+ parent::tearDown();
+ }
+
/**
* Returns error when site is not a consumer site.
*/
diff --git a/tests/phpunit/Integration/Modules/Rest/Search_Controller_ConsumerSiteTest.php b/tests/phpunit/Integration/Modules/Rest/Search_Controller_ConsumerSiteTest.php
index fd88d69f..7af5c0d3 100644
--- a/tests/phpunit/Integration/Modules/Rest/Search_Controller_ConsumerSiteTest.php
+++ b/tests/phpunit/Integration/Modules/Rest/Search_Controller_ConsumerSiteTest.php
@@ -9,9 +9,12 @@
namespace OneSearch\Tests\Integration\Modules\Rest;
+use OneSearch\Modules\Jobs\Abstract_Job;
+use OneSearch\Modules\Jobs\Reindex_Job;
use OneSearch\Modules\Rest\Abstract_REST_Controller;
use OneSearch\Modules\Rest\Governing_Data_Handler;
use OneSearch\Modules\Rest\Search_Controller;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Tests\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -119,52 +122,56 @@ public function test_reindex_fails_when_brand_config_fails(): void {
}
/**
- * Consumer reindex reaches the indexing step when the parent returns a valid
- * brand-config payload; without Algolia credentials, indexing reports failure
- * but the brand-config fetch path is fully traversed.
+ * A reindex the governing site starts here runs in the background, since the governing site is waiting on the
+ * response and clearing the old records could outlast its request.
*/
- public function test_reindex_proceeds_when_parent_returns_valid_brand_config(): void {
+ public function test_reindex_from_governing_site_runs_in_the_background(): void {
Settings::set_parent_site_url( 'https://governing.example.com' );
- delete_transient( Governing_Data_Handler::TRANSIENT_KEY );
Settings::regenerate_api_key();
- $payload = [
- 'algolia_credentials' => [
- 'app_id' => '',
- 'write_key' => '',
- ],
- 'search_settings' => [
- 'algolia_enabled' => false,
- 'searchable_sites' => [],
- ],
- 'indexable_entities' => [ 'post' ],
- 'available_sites' => [],
- ];
-
- $filter = static function ( $preempt, $args, $url ) use ( $payload ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
- if ( false === strpos( $url, '/brand-config' ) ) {
- return $preempt;
- }
- return [
- 'response' => [
- 'code' => 200,
- 'message' => 'OK',
+ $method = new \ReflectionMethod( Governing_Data_Handler::class, 'set_brand_config_cache' );
+ $method->invoke(
+ null,
+ [
+ 'algolia_credentials' => [
+ 'app_id' => 'test-app',
+ 'write_key' => 'test-key',
],
- 'body' => wp_json_encode( $payload ),
- 'headers' => [],
- 'cookies' => [],
- ];
- };
- add_filter( 'pre_http_request', $filter, 10, 3 );
-
- $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' );
- $response = $this->server->dispatch( $request );
- $data = $response->get_data();
-
- remove_filter( 'pre_http_request', $filter );
+ 'search_settings' => [
+ 'algolia_enabled' => true,
+ 'searchable_sites' => [],
+ ],
+ 'indexable_entities' => [ 'post' ],
+ 'available_sites' => [],
+ ]
+ );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' );
+ $request->set_header( 'X-OneSearch-Token', Settings::get_api_key() );
+ $request->set_header( 'Origin', 'https://governing.example.com' );
+ $data = $this->server->dispatch( $request )->get_data();
+
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( 1, $data['batch_count'], 'The batch count is estimated up front.' );
+ $this->assertSame( [], $paths, 'Algolia is only called once the background run starts.' );
+
+ $this->assertSame( Abstract_Job::STATUS_PENDING, ( new Job_Scheduler() )->get_status( $data['job_id'] )['status'] );
+ $this->assertNotFalse(
+ as_next_scheduled_action(
+ Job_Scheduler::HOOK,
+ [
+ 'job_class' => Reindex_Job::class,
+ 'job_id' => $data['job_id'],
+ ],
+ 'onesearch_reindex'
+ )
+ );
- $this->assertSame( 200, $response->get_status() );
- $this->assertArrayHasKey( 'success', $data );
- $this->assertArrayHasKey( 'message', $data );
+ delete_transient( Search_Controller::REINDEX_STATE_TRANSIENT );
}
}
diff --git a/tests/phpunit/Integration/Modules/Rest/Search_Controller_GoverningSiteTest.php b/tests/phpunit/Integration/Modules/Rest/Search_Controller_GoverningSiteTest.php
index fdd676dd..850b4083 100644
--- a/tests/phpunit/Integration/Modules/Rest/Search_Controller_GoverningSiteTest.php
+++ b/tests/phpunit/Integration/Modules/Rest/Search_Controller_GoverningSiteTest.php
@@ -9,8 +9,11 @@
namespace OneSearch\Tests\Integration\Modules\Rest;
+use OneSearch\Modules\Jobs\Abstract_Job;
+use OneSearch\Modules\Jobs\Reindex_Job;
use OneSearch\Modules\Rest\Abstract_REST_Controller;
use OneSearch\Modules\Rest\Search_Controller;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Tests\TestCase;
@@ -38,6 +41,8 @@ class Search_Controller_GoverningSiteTest extends TestCase {
public function set_up(): void {
parent::set_up();
+ Search_Controller::clear_reindex_state();
+
update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
global $wp_rest_server;
@@ -58,6 +63,11 @@ public function tear_down(): void {
global $wp_rest_server;
$wp_rest_server = null;
+ AlgoliaSDK::resetHttpClient();
+
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+
parent::tear_down();
}
@@ -323,94 +333,179 @@ public function test_reindex_dispatches_to_each_shared_site(): void {
}
/**
- * Non-200 from a child site flips `success` to false on the governing response.
+ * GET /re-index/status hands the UI a live run to restore after a page refresh, and forgets it once it finishes,
+ * so a finished run is neither restored nor left blocking the next re-index.
*/
- public function test_reindex_records_child_non_200_as_failure(): void {
- delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES );
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => 'APP',
- 'write_key' => 'KEY',
- ]
- );
- Settings::set_shared_sites(
+ public function test_reindex_status_restores_a_live_run_and_forgets_a_finished_one(): void {
+ $scheduler = new Job_Scheduler();
+ $job = new Reindex_Job();
+ $job->mark_running();
+ $scheduler->persist_job( $job );
+
+ $jobs = [
[
- [
- 'name' => 'Site A',
- 'url' => 'https://site-a.example.com/',
- 'api_key' => 'key-a',
- ],
- ]
- );
+ 'site_name' => 'Test Site',
+ 'site_url' => 'https://example.com',
+ 'job_id' => $job->get_id(),
+ 'batch_count' => 5,
+ ],
+ ];
+ set_transient( Search_Controller::REINDEX_STATE_TRANSIENT, $jobs, 3600 );
- $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
- if ( false === strpos( $url, '/re-index' ) ) {
- return $preempt;
- }
- return [
- 'response' => [
- 'code' => 500,
- 'message' => 'Internal Server Error',
- ],
- 'body' => '',
- 'headers' => [],
- 'cookies' => [],
- ];
- };
- add_filter( 'pre_http_request', $filter, 10, 3 );
+ $data = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' ) )->get_data();
+
+ $this->assertTrue( $data['active'] );
+ $this->assertEquals( $jobs, $data['jobs'] );
+
+ $job->mark_completed();
+ $scheduler->persist_job( $job );
+
+ $data = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' ) )->get_data();
+
+ $this->assertFalse( $data['active'] );
+ $this->assertNull( $data['jobs'] );
+ }
+
+ /**
+ * POST /re-index returns 409 when a reindex is already active.
+ */
+ public function test_reindex_returns_409_when_active_reindex_exists(): void {
+ $scheduler = new Job_Scheduler();
+ $job = new Reindex_Job();
+ $job->mark_running();
+ $scheduler->persist_job( $job );
+
+ $jobs = [
+ [
+ 'site_name' => 'Test Site',
+ 'site_url' => get_site_url(),
+ 'job_id' => $job->get_id(),
+ 'batch_count' => 3,
+ ],
+ ];
+ set_transient( Search_Controller::REINDEX_STATE_TRANSIENT, $jobs, 3600 );
$request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
- remove_filter( 'pre_http_request', $filter );
-
- $this->assertFalse( $data['success'] );
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'onesearch_reindex_active', $data['code'] );
}
/**
- * A WP_Error from a child site flips `success` to false on the governing response.
+ * The lock keeps a second re-index request out while it's held, and one left behind by a request that died is
+ * taken over once it lapses, instead of blocking every re-index after it.
*/
- public function test_reindex_records_child_wp_error_as_failure(): void {
+ public function test_reindex_lock_blocks_while_held_and_is_taken_over_once_lapsed(): void {
+ $lock_key = Search_Controller::REINDEX_STATE_TRANSIENT . '_lock';
+
+ update_option( $lock_key, time() + MINUTE_IN_SECONDS, false );
+
+ $this->assertSame( 409, $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ) )->get_status() );
+
delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES );
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => 'APP',
- 'write_key' => 'KEY',
- ]
- );
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ update_option( $lock_key, time() - 1, false );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ) );
+
+ $this->assertNotSame( 409, $response->get_status() );
+ $this->assertFalse( get_option( $lock_key ), 'The request releases the lock it took over.' );
+ }
+
+ /**
+ * When some brand sites fail to start, the response reports each failure but still lists the runs that did start,
+ * and keeps them as the live re-index, so the admin can follow and cancel them instead of starting over.
+ */
+ public function test_partial_reindex_reports_failures_and_keeps_the_runs_that_started(): void {
+ $this->set_up_governing_site();
Settings::set_shared_sites(
- [
- [
- 'name' => 'Site A',
- 'url' => 'https://site-a.example.com/',
- 'api_key' => 'key-a',
+ array_map(
+ static fn ( string $site ): array => [
+ 'name' => 'Site ' . strtoupper( $site ),
+ 'url' => sprintf( 'https://site-%s.example.com/', $site ),
+ 'api_key' => 'key-' . $site,
],
- ]
+ [ 'a', 'b', 'c', 'd' ]
+ )
);
- $filter = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
- if ( false === strpos( $url, '/re-index' ) ) {
+ $recorded_paths = [];
+ $this->mock_algolia_http_client( $recorded_paths );
+ self::factory()->post->create( [ 'post_status' => 'publish' ] );
+
+ $brands = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ $host = (string) wp_parse_url( $url, PHP_URL_HOST );
+
+ if ( 'site-a.example.com' === $host ) {
+ return new \WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
+ }
+
+ $responses = [
+ 'site-b.example.com' => [ 500, [] ],
+ 'site-c.example.com' => [
+ 200,
+ [
+ 'success' => false,
+ 'errors' => [ [ 'message' => 'Site C has no indexable entities.' ] ],
+ ],
+ ],
+ 'site-d.example.com' => [
+ 200,
+ [
+ 'success' => true,
+ 'job_id' => 'brand-d-job',
+ 'batch_count' => 2,
+ ],
+ ],
+ ];
+
+ if ( ! isset( $responses[ $host ] ) ) {
return $preempt;
}
- return new \WP_Error( 'http_request_failed', 'cURL timeout' );
- };
- add_filter( 'pre_http_request', $filter, 10, 3 );
- $request = new WP_REST_Request( 'POST', '/onesearch/v1/re-index' );
- $response = $this->server->dispatch( $request );
- $data = $response->get_data();
+ return [
+ 'response' => [
+ 'code' => $responses[ $host ][0],
+ 'message' => '',
+ ],
+ 'body' => (string) wp_json_encode( $responses[ $host ][1] ),
+ 'headers' => [],
+ 'cookies' => [],
+ ];
+ };
+ add_filter( 'pre_http_request', $brands, 10, 3 );
- remove_filter( 'pre_http_request', $filter );
+ $data = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ) )->get_data();
$this->assertFalse( $data['success'] );
+ $this->assertSame(
+ [ 'https://site-a.example.com/', 'https://site-b.example.com/', 'https://site-c.example.com/' ],
+ array_column( $data['errors'], 'site_url' )
+ );
+ $this->assertStringContainsString( 'Site C has no indexable entities.', $data['message'], 'The brand site\'s own reason is passed on.' );
+ $this->assertSame( [ $data['job_id'], 'brand-d-job' ], array_column( $data['jobs'], 'job_id' ) );
+
+ $status = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' ) )->get_data();
+
+ $this->assertTrue( $status['active'], 'A page refresh has to bring back the runs that started.' );
+ $this->assertEquals( $data['jobs'], $status['jobs'] );
+ $this->assertSame( 409, $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ) )->get_status() );
}
/**
- * A child site that responds but reports `success: false` flips `success` to false on the governing response.
+ * A governing site with nothing of its own to index still waits for the brand sites it started.
*/
- public function test_reindex_records_child_unsuccessful_response_as_failure(): void {
- delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES );
+ public function test_reindex_waits_for_brand_sites_when_nothing_is_indexed_here(): void {
+ update_option(
+ Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES,
+ [
+ 'entities' => [
+ \OneSearch\Utils::normalize_url( get_site_url() ) => [],
+ ],
+ ]
+ );
Search_Settings::set_algolia_credentials(
[
'app_id' => 'APP',
@@ -427,47 +522,50 @@ public function test_reindex_records_child_unsuccessful_response_as_failure(): v
]
);
- // The governing site's own reindex succeeds.
$recorded_paths = [];
$this->mock_algolia_http_client( $recorded_paths );
- $succeeded = $this->reindex_with_child_response( [ 'success' => true ] );
- $failed = $this->reindex_with_child_response( [ 'success' => false ] );
-
- AlgoliaSDK::resetHttpClient();
-
- $this->assertTrue( $succeeded['success'] );
- $this->assertFalse( $failed['success'] );
- }
-
- /**
- * Reindexes with every child site responding with the given body.
- *
- * @param array $child_body The child response body.
- *
- * @return array The governing site's response data.
- */
- private function reindex_with_child_response( array $child_body ): array {
- $filter = static function ( $preempt, $args, $url ) use ( $child_body ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
- if ( false === strpos( $url, '/re-index' ) ) {
+ $brand = static function ( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ if ( ! str_contains( $url, 'site-a.example.com' ) ) {
return $preempt;
}
+
+ $body = str_contains( $url, '/re-index' )
+ ? [
+ 'success' => true,
+ 'job_id' => 'brand-job',
+ 'batch_count' => 2,
+ ]
+ : [
+ 'success' => true,
+ 'job' => [
+ 'status' => Abstract_Job::STATUS_RUNNING,
+ 'children_total' => 2,
+ 'children_completed' => 0,
+ ],
+ ];
+
return [
'response' => [
'code' => 200,
'message' => 'OK',
],
- 'body' => wp_json_encode( $child_body ),
+ 'body' => (string) wp_json_encode( $body ),
'headers' => [],
'cookies' => [],
];
};
- add_filter( 'pre_http_request', $filter, 10, 3 );
+ add_filter( 'pre_http_request', $brand, 10, 3 );
$data = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/re-index' ) )->get_data();
- remove_filter( 'pre_http_request', $filter );
+ remove_filter( 'pre_http_request', $brand );
+ AlgoliaSDK::resetHttpClient();
+
+ $this->assertTrue( $data['success'] );
- return $data;
+ $job = ( new Job_Scheduler() )->get_status( $data['job_id'] );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $job['status'], 'The brand site is still running.' );
+ $this->assertTrue( $job['data']['_needs_remote_finalize'] );
}
}
diff --git a/tests/phpunit/Integration/Modules/Scheduler/Action_Scheduler_DependencyTest.php b/tests/phpunit/Integration/Modules/Scheduler/Action_Scheduler_DependencyTest.php
new file mode 100644
index 00000000..3c9cae01
--- /dev/null
+++ b/tests/phpunit/Integration/Modules/Scheduler/Action_Scheduler_DependencyTest.php
@@ -0,0 +1,36 @@
+assertArrayHasKey( 'woocommerce/action-scheduler', $composer['require'] );
+ $this->assertContains( 'woocommerce/action-scheduler', array_column( $lock['packages'], 'name' ), 'It must be locked as a runtime package, not a dev one.' );
+ $this->assertFileExists( ONESEARCH_DIR . 'vendor/woocommerce/action-scheduler/action-scheduler.php', 'onesearch.php loads Action Scheduler from here.' );
+ $this->assertTrue( function_exists( 'as_enqueue_async_action' ), 'Action Scheduler is loaded once the plugin is.' );
+ }
+}
diff --git a/tests/phpunit/Integration/Modules/Scheduler/Job_REST_Controller_Test.php b/tests/phpunit/Integration/Modules/Scheduler/Job_REST_Controller_Test.php
new file mode 100644
index 00000000..64fc1b22
--- /dev/null
+++ b/tests/phpunit/Integration/Modules/Scheduler/Job_REST_Controller_Test.php
@@ -0,0 +1,610 @@
+
+ */
+ private array $brand_job = [];
+
+ /**
+ * REST server.
+ */
+ private ?\WP_REST_Server $server;
+
+ /**
+ * Job_Scheduler instance.
+ */
+ private Job_Scheduler $scheduler;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ global $wp_rest_server;
+ $wp_rest_server = new \WP_REST_Server();
+ $this->server = $wp_rest_server;
+
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ $this->scheduler = new Job_Scheduler();
+
+ // Main skips module loading under the test install's plain permalinks, so register the job types retries resolve.
+ ( new Bootstrap() )->init();
+
+ ( new Job_REST_Controller() )->register_hooks();
+ do_action( 'rest_api_init' );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tear_down(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ $this->tear_down_governing_site();
+
+ parent::tear_down();
+ }
+
+ /**
+ * The admin UI polls a run and its batches, and a governing site polls a brand site's run with its token.
+ */
+ public function test_a_run_can_be_polled_by_the_admin_and_by_the_governing_site(): void {
+ $parent = new Reindex_Job();
+ $batch = new Sync_Job();
+ $batch->set_parent_id( $parent->get_id() );
+ $batch->set_data( [ 'post_ids' => [ 1 ] ] );
+ $this->scheduler->schedule( $batch );
+
+ $parent->set_child_ids( [ $batch->get_id() ] );
+ $parent->mark_running();
+ $this->scheduler->persist_job( $parent );
+
+ $job = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $parent->get_id() ) )->get_data();
+ $children = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $parent->get_id() . '/children' ) )->get_data();
+
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $job['job']['status'] );
+ $this->assertSame( [ $batch->get_id() ], array_column( $children['children'], 'id' ) );
+
+ $api_key = Settings::regenerate_api_key();
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $parent->get_id() );
+ $request->set_header( 'X-OneSearch-Token', $api_key );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( $parent->get_id(), $response->get_data()['job']['id'] );
+ }
+
+ /**
+ * Every job endpoint answers 404 for a job that doesn't exist.
+ */
+ public function test_unknown_jobs_return_404(): void {
+ $requests = [
+ new WP_REST_Request( 'GET', '/onesearch/v1/jobs/nonexistent' ),
+ new WP_REST_Request( 'GET', '/onesearch/v1/jobs/nonexistent/children' ),
+ new WP_REST_Request( 'POST', '/onesearch/v1/jobs/nonexistent/retry' ),
+ new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/nonexistent' ),
+ ];
+
+ foreach ( $requests as $request ) {
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 'onesearch_job_not_found', $response->get_data()['code'], $request->get_method() . ' ' . $request->get_route() );
+ $this->assertSame( 404, $response->get_status() );
+ }
+ }
+
+ /**
+ * A subscriber cannot access jobs endpoints.
+ */
+ public function test_jobs_endpoint_requires_manage_options(): void {
+ $subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ wp_set_current_user( $subscriber_id );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 403, $response->get_status() );
+ }
+
+ /**
+ * Cancelling stops a live job, and leaves a finished one as it ended.
+ */
+ public function test_cancel_stops_a_live_job_and_refuses_a_finished_one(): void {
+ $live = $this->create_scheduled_job();
+
+ $finished = new Sync_Job();
+ $finished->set_data( [ 'post_ids' => [ 1 ] ] );
+ $finished->mark_completed();
+ $this->scheduler->persist_job( $finished );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/' . $live->get_id() ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $live->get_id() )['status'] );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/' . $finished->get_id() ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_job_terminal', $response->get_data()['code'] );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $finished->get_id() )['status'] );
+ }
+
+ /**
+ * Only a failed job can be retried, so a job that's still queued isn't queued twice.
+ */
+ public function test_retry_job_requires_failed_status(): void {
+ $job = $this->create_scheduled_job();
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_retry_not_failed', $data['code'] );
+ }
+
+ /**
+ * A reindex whose batches can't be trusted to have covered every post is re-run from scratch when retried, rather
+ * than having its cancelled batches counted as done and being marked completed.
+ *
+ * It has to be re-queued as a Reindex_Job; rebuilt as a Sync_Job it would fail forever for lack of post_ids.
+ *
+ * @param string[] $batch_statuses How each of the failed reindex's batches ended.
+ */
+ #[DataProvider( 'unreliable_batches_provider' )]
+ public function test_retry_restarts_a_reindex_whose_batches_cant_be_kept( array $batch_statuses ): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create( [ 'post_status' => 'publish' ] );
+ $job = $this->create_failed_reindex_with_batches( $batch_statuses );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $status = $this->scheduler->get_status( $job->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $status['status'], 'The reindex must be re-queued, not marked completed.' );
+ $this->assertSame( [], $status['child_ids'] );
+
+ $action = ( new Job_Repository() )->get_action( $job->get_id() );
+ $this->scheduler->execute_job( $action['args']['job_class'], $job->get_id(), 0 );
+
+ $status = $this->scheduler->get_status( $job->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $status['status'] );
+ $this->assertNull( $status['error'] );
+ $this->assertCount( 1, $status['child_ids'], 'The re-run creates its own batches.' );
+ }
+
+ /**
+ * Failed reindexes whose batches indexed nothing reliable.
+ *
+ * Cancelled batches mean scheduling was cut short, so the later batches were never created even if some indexed.
+ *
+ * @return array
+ */
+ public static function unreliable_batches_provider(): array {
+ return [
+ 'failed before creating any batch' => [ [] ],
+ 'every batch failed' => [ [ Abstract_Job::STATUS_FAILED, Abstract_Job::STATUS_FAILED ] ],
+ 'failed while scheduling, batches cancelled' => [ [ Abstract_Job::STATUS_CANCELLED, Abstract_Job::STATUS_CANCELLED ] ],
+ 'cut short after a batch indexed' => [ [ Abstract_Job::STATUS_COMPLETED, Abstract_Job::STATUS_CANCELLED ] ],
+ ];
+ }
+
+ /**
+ * Retrying a partly failed governing run re-runs only its failed batch, and once that's done the run records its
+ * totals afresh, rather than keeping the ones from before the retry.
+ */
+ public function test_retry_reruns_only_failed_batches_and_records_fresh_totals(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ $job = $this->create_failed_reindex_with_batches( [ Abstract_Job::STATUS_COMPLETED, Abstract_Job::STATUS_FAILED ] );
+ [ $completed_batch, $failed_batch ] = $job->get_child_ids();
+ ( new Job_Repository() )->reset_counters( $job->get_id(), 2, 1, 0 );
+ $this->run_on_brand_site( $job->get_id(), Abstract_Job::STATUS_COMPLETED, 3 );
+
+ // Totals recorded when the run failed, before the retry.
+ $data = $this->scheduler->get_status( $job->get_id() );
+ $data['children_completed'] = 1;
+ $data['data']['_remote_aggregated'] = true;
+ ( new Job_Repository() )->upsert( $data );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $completed_batch )['status'], 'A batch that indexed is not re-run.' );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $this->scheduler->get_status( $failed_batch )['status'] );
+
+ $this->scheduler->execute_job( Sync_Job::class, $failed_batch );
+
+ $parent = $this->scheduler->get_status( $job->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $parent['status'] );
+ $this->assertSame( 5, $parent['data']['total_batches'] );
+ $this->assertSame( 5, $parent['children_completed'], '2 batches here and 3 on the brand site.' );
+ }
+
+ /**
+ * A run left as failed though all its batches completed is corrected to completed when retried.
+ */
+ public function test_retry_corrects_a_failed_run_whose_batches_all_completed(): void {
+ $job = $this->create_failed_reindex_with_batches( [ Abstract_Job::STATUS_COMPLETED ] );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $job->get_id() )['status'] );
+ }
+
+ /**
+ * Correcting a governing run whose batches here all finished waits for its brand sites, which may be retrying their own.
+ */
+ public function test_correcting_a_governing_run_waits_for_its_brand_sites(): void {
+ $job = $this->create_failed_reindex_with_batches( [ Abstract_Job::STATUS_COMPLETED, Abstract_Job::STATUS_COMPLETED ] );
+ ( new Job_Repository() )->reset_counters( $job->get_id(), 2, 0, 0 );
+ $this->run_on_brand_site( $job->get_id(), Abstract_Job::STATUS_RUNNING, 1 );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertStringContainsString( 'Waiting for the brand sites', $response->get_data()['message'] );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $this->scheduler->get_status( $job->get_id() )['status'] );
+
+ $this->brand_job['status'] = Abstract_Job::STATUS_COMPLETED;
+
+ $data = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $job->get_id() ) )->get_data();
+
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $data['job']['status'] );
+ }
+
+ /**
+ * Retrying a failed batch of a run the user cancelled is refused, since it would reopen the run.
+ */
+ public function test_retry_refuses_a_batch_of_a_cancelled_run(): void {
+ $job = $this->create_cancelled_reindex_with_batches( [ Abstract_Job::STATUS_FAILED ] );
+ [ $batch_id ] = $job->get_child_ids();
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $batch_id . '/retry' ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_retry_cancelled', $response->get_data()['code'] );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $job->get_id() )['status'] );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $this->scheduler->get_status( $batch_id )['status'] );
+ }
+
+ /**
+ * Loading the history keeps a run the user cancelled as cancelled, with no retry, even when one of its batches had
+ * failed before the cancel.
+ */
+ public function test_history_keeps_a_cancelled_run_cancelled(): void {
+ $job = $this->create_cancelled_reindex_with_batches( [ Abstract_Job::STATUS_COMPLETED, Abstract_Job::STATUS_FAILED ] );
+ $this->run_on_brand_site( $job->get_id(), Abstract_Job::STATUS_COMPLETED, 1 );
+
+ $history = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' ) )->get_data();
+ $jobs = array_column( $history['jobs'], null, 'id' );
+
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $jobs[ $job->get_id() ]['status'] );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $job->get_id() )['status'], 'The stored run must stay cancelled too.' );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ /**
+ * Loading the history keeps a reindex that failed while scheduling its batches as failed.
+ *
+ * Failing cancels the batches it had queued. Reading those as a cancellation would store the run as
+ * cancelled on a governing site, which can't be retried, so it could never be restarted.
+ */
+ public function test_history_keeps_a_reindex_that_failed_while_scheduling_retryable(): void {
+ $job = $this->create_failed_reindex_with_batches( [ Abstract_Job::STATUS_CANCELLED, Abstract_Job::STATUS_CANCELLED ] );
+ $this->run_on_brand_site( $job->get_id(), Abstract_Job::STATUS_COMPLETED, 1 );
+
+ $history = $this->server->dispatch( new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' ) )->get_data();
+ $jobs = array_column( $history['jobs'], null, 'id' );
+
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $jobs[ $job->get_id() ]['status'] );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $this->scheduler->get_status( $job->get_id() )['status'], 'The stored run must stay failed too.' );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $job->get_id() . '/retry' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $this->scheduler->get_status( $job->get_id() )['status'], 'The run has to restart from scratch.' );
+ }
+
+ /**
+ * The /jobs/history endpoint reports a parent job as failed when any child failed.
+ */
+ public function test_history_marks_parent_failed_when_child_failed(): void {
+ $parent = new Reindex_Job();
+ $child = new Sync_Job();
+
+ $child->set_parent_id( $parent->get_id() );
+ $child->set_data( [ 'post_ids' => [ 1 ] ] );
+ $child->fail( 'Permanent batch failure.' );
+ $this->scheduler->persist_job( $child );
+
+ $parent->set_child_ids( [ $child->get_id() ] );
+ $parent->set_progress_total( 1 );
+ $parent->mark_completed();
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $jobs = array_column( $data['jobs'], null, 'id' );
+ $this->assertArrayHasKey( $parent->get_id(), $jobs );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $jobs[ $parent->get_id() ]['status'] );
+ $this->assertSame( 1, $jobs[ $parent->get_id() ]['children_failed'] );
+ }
+
+ /**
+ * The history pages through finished runs only; one still in progress shows in the progress view instead.
+ */
+ public function test_history_pages_through_finished_runs_only(): void {
+ for ( $i = 0; $i < 3; $i++ ) {
+ $job = new Sync_Job();
+ $job->set_data( [ 'post_ids' => [ 1 ] ] );
+ $job->mark_completed();
+ $this->scheduler->persist_job( $job );
+ }
+
+ $running = new Sync_Job();
+ $running->set_data( [ 'post_ids' => [ 1 ] ] );
+ $running->mark_running();
+ $this->scheduler->persist_job( $running );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' );
+ $request->set_param( 'page', 2 );
+ $request->set_param( 'per_page', 2 );
+ $data = $this->server->dispatch( $request )->get_data();
+
+ $this->assertCount( 1, $data['jobs'] );
+ $this->assertSame( 3, $data['total'] );
+ $this->assertSame( 2, $data['total_pages'] );
+ }
+
+ /**
+ * The governing site's proxies to brand-site jobs only ever reach registered brand sites, and pass on a brand
+ * site's refusal rather than reporting it as success.
+ */
+ public function test_remote_job_proxies_only_reach_registered_brand_sites(): void {
+ $requested = [];
+ add_filter(
+ 'pre_http_request',
+ static function ( $preempt, $args, $url ) use ( &$requested ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ $requested[] = $url;
+
+ return [
+ 'response' => [
+ 'code' => 404,
+ 'message' => 'Not Found',
+ ],
+ 'body' => (string) wp_json_encode(
+ [
+ 'code' => 'onesearch_job_not_found',
+ 'message' => 'Job not found.',
+ ]
+ ),
+ 'headers' => [],
+ 'cookies' => [],
+ ];
+ },
+ 10,
+ 3
+ );
+
+ $routes = [
+ 'remote-status' => 'GET',
+ 'remote-retry' => 'POST',
+ 'remote-cancel' => 'POST',
+ ];
+ $responses = [];
+ foreach ( $routes as $route => $method ) {
+ $request = new WP_REST_Request( $method, '/onesearch/v1/jobs/' . $route );
+ $request->set_param( 'site_url', 'https://unknown.example.com' );
+ $request->set_param( 'job_id', 'some_job_id' );
+ $responses[ $route ] = $this->server->dispatch( $request );
+ }
+
+ $this->assertSame( [], $requested, 'Nothing is sent to a site that is not a registered brand site.' );
+ $this->assertSame( 'onesearch_unknown_site', $responses['remote-status']->get_data()['code'] );
+ $this->assertSame( 'onesearch_unknown_site', $responses['remote-retry']->get_data()['code'] );
+ $this->assertSame( 200, $responses['remote-cancel']->get_status(), 'Cancelling still clears a run whose brand site is gone.' );
+
+ update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
+ Settings::set_shared_sites(
+ [
+ [
+ 'name' => 'Brand',
+ 'url' => 'https://brand.example.com/',
+ 'api_key' => 'brand-key',
+ ],
+ ]
+ );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/remote-cancel' );
+ $request->set_param( 'site_url', 'https://brand.example.com/' );
+ $request->set_param( 'job_id', 'brand-job' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertCount( 1, $requested );
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'Job not found.', $response->get_data()['message'] );
+ }
+
+ /**
+ * Create a Sync_Job and schedule it for testing.
+ */
+ private function create_scheduled_job(): Sync_Job {
+ $job = new Sync_Job();
+ $job->set_data( [ 'post_ids' => [ 1 ] ] );
+ $job->set_max_retries( 2 );
+ $this->scheduler->schedule( $job );
+ return $job;
+ }
+
+ /**
+ * Persist a Reindex_Job the user cancelled after its batches ended in the given statuses.
+ *
+ * @param string[] $batch_statuses One terminal status per batch.
+ */
+ private function create_cancelled_reindex_with_batches( array $batch_statuses ): Reindex_Job {
+ $job = $this->create_failed_reindex_with_batches( $batch_statuses );
+
+ $data = $this->scheduler->get_status( $job->get_id() );
+ $data['status'] = Abstract_Job::STATUS_CANCELLED;
+ $data['error'] = null;
+ ( new Job_Repository() )->upsert( $data );
+
+ return $job;
+ }
+
+ /**
+ * Persist a failed Reindex_Job whose batches ended in the given statuses.
+ *
+ * @param string[] $batch_statuses One terminal status per batch.
+ */
+ private function create_failed_reindex_with_batches( array $batch_statuses ): Reindex_Job {
+ $job = new Reindex_Job();
+ $job->set_data( [ 'post_types' => [ 'post' ] ] );
+
+ foreach ( $batch_statuses as $i => $batch_status ) {
+ $batch = new Sync_Job();
+ $batch->set_data( [ 'post_ids' => [ $i + 1 ] ] );
+ $batch->set_parent_id( $job->get_id() );
+ if ( Abstract_Job::STATUS_FAILED === $batch_status ) {
+ $batch->fail( 'Permanent batch failure.' );
+ } else {
+ $batch->set_status( $batch_status );
+ }
+ $this->scheduler->persist_job( $batch );
+ $job->add_child_id( $batch->get_id() );
+ }
+
+ $job->set_progress_total( count( $batch_statuses ) );
+ $job->fail( 'Simulated reindex failure.' );
+ $this->scheduler->persist_job( $job );
+
+ return $job;
+ }
+
+ /**
+ * Record a run as having also started on a brand site, whose job the mocked brand site reports.
+ *
+ * @param string $job_id The governing site's job ID.
+ * @param string $brand_status The brand job's status.
+ * @param int $brand_batches The brand job's batch count.
+ */
+ private function run_on_brand_site( string $job_id, string $brand_status, int $brand_batches ): void {
+ update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
+ Settings::set_shared_sites(
+ [
+ [
+ 'name' => 'Brand',
+ 'url' => 'https://brand.example.com/',
+ 'api_key' => 'brand-key',
+ ],
+ ]
+ );
+
+ $this->brand_job = [
+ 'status' => $brand_status,
+ 'children_total' => $brand_batches,
+ 'children_completed' => Abstract_Job::STATUS_COMPLETED === $brand_status ? $brand_batches : 0,
+ 'children_failed' => 0,
+ ];
+ add_filter( 'pre_http_request', [ $this, 'mock_brand_job' ], 10, 3 );
+
+ $data = $this->scheduler->get_status( $job_id );
+ $data['data']['sites'] = [
+ [
+ 'site_url' => get_site_url(),
+ 'job_id' => $job_id,
+ 'batch_count' => count( $data['child_ids'] ),
+ ],
+ [
+ 'site_url' => 'https://brand.example.com/',
+ 'job_id' => 'brand-job',
+ 'batch_count' => $brand_batches,
+ ],
+ ];
+ ( new Job_Repository() )->upsert( $data );
+ }
+
+ /**
+ * Answers the brand site's job requests with $this->brand_job.
+ *
+ * @param false|array|\WP_Error $preempt Whether to preempt the request.
+ * @param array $args The request arguments.
+ * @param string $url The request URL.
+ * @return false|array|\WP_Error
+ */
+ public function mock_brand_job( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ if ( ! str_contains( $url, 'brand.example.com' ) ) {
+ return $preempt;
+ }
+
+ return [
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ 'body' => (string) wp_json_encode(
+ [
+ 'success' => true,
+ 'job' => $this->brand_job,
+ ]
+ ),
+ 'headers' => [],
+ 'cookies' => [],
+ ];
+ }
+}
diff --git a/tests/phpunit/Integration/Modules/Scheduler/Job_SchedulerTest.php b/tests/phpunit/Integration/Modules/Scheduler/Job_SchedulerTest.php
new file mode 100644
index 00000000..b2eb7e97
--- /dev/null
+++ b/tests/phpunit/Integration/Modules/Scheduler/Job_SchedulerTest.php
@@ -0,0 +1,499 @@
+|\WP_Error
+ */
+ private $brand_job = [];
+
+ /**
+ * Scheduler instance used across tests.
+ *
+ * @var \OneSearch\Modules\Scheduler\Job_Scheduler
+ */
+ private Job_Scheduler $scheduler;
+
+ /**
+ * Repository for direct table assertions.
+ *
+ * @var \OneSearch\Modules\Schema\Job_Repository
+ */
+ private Job_Repository $repository;
+
+ /**
+ * Sets up the test environment.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $this->scheduler = new Job_Scheduler();
+ $this->repository = new Job_Repository();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tear_down(): void {
+ $this->tear_down_governing_site();
+
+ parent::tear_down();
+ }
+
+ /**
+ * A reindex completes once every batch has run, its batches cover every post exactly once,
+ * and a late failing run of a batch can't reopen it.
+ */
+ public function test_reindex_completes_once_every_batch_completes(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ $post_ids = self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $parent_id = $this->start_reindex( 2 );
+ $batch_ids = $this->scheduler->get_status( $parent_id )['child_ids'];
+
+ $this->assertCount( 2, $batch_ids, '3 posts at 2 per batch make 2 batches.' );
+ $this->assertEqualsCanonicalizing( $post_ids, $this->get_batched_post_ids( $batch_ids ) );
+
+ foreach ( $batch_ids as $batch_id ) {
+ $this->scheduler->execute_job( Sync_Job::class, $batch_id );
+ }
+
+ $parent = $this->scheduler->get_status( $parent_id );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $parent['status'] );
+ $this->assertSame( 2, $parent['children_completed'] );
+
+ // Action Scheduler can run an action twice; a failure on the second run must not touch the finished parent.
+ $this->mock_algolia_http_client( $paths, null, '/batch' );
+ $this->scheduler->execute_job( Sync_Job::class, $batch_ids[0], 2 );
+
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $parent_id )['status'] );
+ }
+
+ /**
+ * A batch that runs out of retries fails the reindex.
+ */
+ public function test_reindex_fails_when_a_batch_runs_out_of_retries(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $parent_id = $this->start_reindex( 2 );
+ [ $good_batch, $bad_batch ] = $this->scheduler->get_status( $parent_id )['child_ids'];
+
+ $this->scheduler->execute_job( Sync_Job::class, $good_batch );
+
+ // Reindex_Job gives each batch 2 retries, so retry 2 is its last attempt.
+ $this->mock_algolia_http_client( $paths, null, '/batch' );
+ $this->scheduler->execute_job( Sync_Job::class, $bad_batch, 2 );
+
+ $batch = $this->scheduler->get_status( $bad_batch );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $batch['status'] );
+ $this->assertStringContainsString( 'retries exhausted', (string) $batch['error'] );
+ $this->assertStringContainsString( 'forced test exception', (string) $batch['error'], 'Algolia\'s own message explains the failure.' );
+
+ $parent = $this->scheduler->get_status( $parent_id );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $parent['status'] );
+ $this->assertSame( '1/2 child batches failed', $parent['error'] );
+ }
+
+ /**
+ * A batch another runner finishes while its reindex is still queueing batches is counted.
+ *
+ * Batches used to be queued before the reindex recorded them, and its own later save wrote over the count of a
+ * batch that had already finished, leaving the reindex running forever.
+ */
+ public function test_a_batch_finished_while_its_reindex_is_queueing_is_counted(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ // Another runner picks up the first batch the moment it's queued.
+ $ran_early = null;
+ $run_early = function ( $action_id ) use ( &$ran_early ): void {
+ $action = \ActionScheduler::store()->fetch_action( $action_id );
+ if ( null !== $ran_early || Job_Scheduler::HOOK !== $action->get_hook() ) {
+ return;
+ }
+
+ $ran_early = $action->get_args()['job_id'];
+ $this->scheduler->execute_job( Sync_Job::class, $ran_early );
+ };
+ add_action( 'action_scheduler_stored_action', $run_early );
+
+ $parent_id = $this->start_reindex( 2 );
+ remove_action( 'action_scheduler_stored_action', $run_early );
+
+ $batch_ids = $this->scheduler->get_status( $parent_id )['child_ids'];
+ $this->assertContains( $ran_early, $batch_ids );
+ $this->assertSame( 1, $this->repository->get_counters( $parent_id )['done'] );
+
+ foreach ( array_diff( $batch_ids, [ $ran_early ] ) as $batch_id ) {
+ $this->scheduler->execute_job( Sync_Job::class, $batch_id );
+ }
+
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $parent_id )['status'] );
+ }
+
+ /**
+ * A brand's failed batches count as finished, not as completed, when a run's progress is read from it.
+ */
+ public function test_remote_progress_counts_failed_batches_as_finished_not_completed(): void {
+ $this->set_up_governing_site();
+ Settings::set_shared_sites(
+ [
+ [
+ 'name' => 'Brand',
+ 'url' => 'https://brand.example.com/',
+ 'api_key' => 'brand-key',
+ ],
+ ]
+ );
+ add_filter( 'pre_http_request', [ $this, 'mock_brand_job' ], 10, 3 );
+
+ // children_completed counts both finished batches, the failed one included.
+ $this->brand_job = [
+ 'status' => Abstract_Job::STATUS_FAILED,
+ 'children_total' => 2,
+ 'children_completed' => 2,
+ 'children_failed' => 1,
+ ];
+
+ $progress = $this->scheduler->fetch_remote_job_progress(
+ [
+ [
+ 'site_url' => 'https://brand.example.com/',
+ 'job_id' => 'brand-job',
+ ],
+ ]
+ );
+
+ $this->assertSame(
+ [
+ 'completed' => 1,
+ 'terminal' => 2,
+ 'total' => 2,
+ ],
+ $progress
+ );
+ }
+
+ /**
+ * A batch that finishes while another process holds the parent's lock still gets its parent settled.
+ *
+ * The counters are bumped regardless, but the status update needs the lock, so it's retried later
+ * instead of leaving the parent running, and blocking new reindexes, after its last batch finished.
+ */
+ public function test_parent_is_settled_later_when_its_lock_is_held(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $parent_id = $this->start_reindex( 2 );
+ [ $first_batch, $last_batch ] = $this->scheduler->get_status( $parent_id )['child_ids'];
+
+ $this->scheduler->execute_job( Sync_Job::class, $first_batch );
+
+ // Another process is mid-update on the parent.
+ $lock_key = Job_Scheduler::OPTION_PREFIX . $parent_id . '_lock';
+ add_option( $lock_key, time() + 60, '', false );
+
+ $this->scheduler->execute_job( Sync_Job::class, $last_batch );
+
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $this->scheduler->get_status( $parent_id )['status'] );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Job_Scheduler::SETTLE_HOOK, [ 'parent_id' => $parent_id ], 'onesearch_settle_' . $parent_id ),
+ 'Settling the parent has to be retried.'
+ );
+
+ // The other process is done, and the retry runs.
+ delete_option( $lock_key );
+ $this->scheduler->settle_parent( $parent_id );
+
+ $parent = $this->scheduler->get_status( $parent_id );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $parent['status'] );
+ $this->assertSame( 2, $parent['children_completed'] );
+ }
+
+ /**
+ * A governing reindex finishes once its brand site has, and records the batch totals across both.
+ */
+ public function test_governing_parent_waits_for_its_brand_site_and_records_the_totals(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $this->brand_job = [
+ 'status' => Abstract_Job::STATUS_RUNNING,
+ 'children_total' => 3,
+ 'children_completed' => 1,
+ 'children_failed' => 0,
+ ];
+ $parent_id = $this->start_reindex_with_brand( 2, 1 );
+
+ foreach ( $this->scheduler->get_status( $parent_id )['child_ids'] as $batch_id ) {
+ $this->scheduler->execute_job( Sync_Job::class, $batch_id );
+ }
+
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $this->scheduler->get_status( $parent_id )['status'], 'The brand site is still running.' );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Job_Scheduler::SETTLE_HOOK, [ 'parent_id' => $parent_id ], 'onesearch_settle_' . $parent_id ),
+ 'The brand site has to be checked again, even with no one watching.'
+ );
+
+ $this->brand_job = [
+ 'status' => Abstract_Job::STATUS_COMPLETED,
+ 'children_total' => 3,
+ 'children_completed' => 3,
+ 'children_failed' => 0,
+ ];
+ $this->scheduler->settle_parent( $parent_id );
+
+ $parent = $this->scheduler->get_status( $parent_id );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $parent['status'] );
+ $this->assertEmpty( $parent['error'] ?? null );
+ $this->assertSame( 5, $parent['data']['total_batches'], '2 batches here and 3 on the brand site, which had estimated 1.' );
+ $this->assertSame( 5, $parent['children_completed'] );
+ $this->assertSame( 3, $parent['data']['sites'][1]['batch_count'] );
+ $this->assertTrue( $parent['data']['_remote_aggregated'] );
+ }
+
+ /**
+ * A brand site that's briefly unreachable is waited out, and only fails the run once it's been unreachable a while.
+ */
+ public function test_unreachable_brand_site_fails_the_run_only_after_a_grace_period(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $this->brand_job = new \WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
+ $parent_id = $this->start_reindex_with_brand( 2, 1 );
+
+ foreach ( $this->scheduler->get_status( $parent_id )['child_ids'] as $batch_id ) {
+ $this->scheduler->execute_job( Sync_Job::class, $batch_id );
+ }
+
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $this->scheduler->get_status( $parent_id )['status'], 'One failed request must not fail the run.' );
+
+ // Still unreachable after the grace period.
+ $parent = $this->scheduler->get_status( $parent_id );
+ $parent['data']['_remote_waiting_since'] = time() - 601;
+ $this->scheduler->persist_job( Reindex_Job::from_array( $parent ) );
+
+ $this->scheduler->settle_parent( $parent_id );
+
+ $parent = $this->scheduler->get_status( $parent_id );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $parent['status'] );
+ $this->assertSame( '1 remote site(s) failed', $parent['error'] );
+ }
+
+ /**
+ * An Algolia failure with retries left puts the job back to pending with the next attempt queued.
+ */
+ public function test_algolia_error_leaves_job_pending_with_a_retry_scheduled(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths, null, '/batch' );
+
+ $job = new Sync_Job();
+ $job->set_data( [ 'post_ids' => [ self::factory()->post->create( [ 'post_status' => 'publish' ] ) ] ] );
+ $job->set_max_retries( 2 );
+ $this->scheduler->persist_job( $job );
+
+ $this->scheduler->execute_job( Sync_Job::class, $job->get_id() );
+
+ $status = $this->scheduler->get_status( $job->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $status['status'] );
+ $this->assertSame( 1, $status['retry_count'] );
+ $this->assertNotFalse(
+ as_next_scheduled_action(
+ Job_Scheduler::HOOK,
+ [
+ 'job_class' => Sync_Job::class,
+ 'job_id' => $job->get_id(),
+ 'retry' => 1,
+ ],
+ 'onesearch_' . $job->get_group()
+ ),
+ 'The first retry has to be queued.'
+ );
+ }
+
+ /**
+ * Cancelling a running reindex cancels and dequeues its unfinished batches, and leaves finished ones alone.
+ */
+ public function test_cancelling_a_running_reindex_cancels_only_unfinished_batches(): void {
+ $this->set_up_governing_site();
+ $paths = [];
+ $this->mock_algolia_http_client( $paths );
+
+ self::factory()->post->create_many( 3, [ 'post_status' => 'publish' ] );
+
+ $parent_id = $this->start_reindex( 2 );
+ [ $done_batch, $pending_batch ] = $this->scheduler->get_status( $parent_id )['child_ids'];
+ $pending_action = [
+ 'job_class' => Sync_Job::class,
+ 'job_id' => $pending_batch,
+ ];
+ $batch_group = 'onesearch_reindex_' . $parent_id;
+
+ $this->scheduler->execute_job( Sync_Job::class, $done_batch );
+ $this->assertNotFalse( as_next_scheduled_action( Job_Scheduler::HOOK, $pending_action, $batch_group ) );
+
+ $this->scheduler->cancel( $parent_id );
+
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $parent_id )['status'] );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $pending_batch )['status'] );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $this->scheduler->get_status( $done_batch )['status'] );
+ $this->assertFalse(
+ as_next_scheduled_action( Job_Scheduler::HOOK, $pending_action, $batch_group ),
+ 'The unfinished batch must not stay queued.'
+ );
+ }
+
+ /**
+ * Starts a reindex that also ran on a brand site, and returns its job ID.
+ *
+ * The brand site's job is the one in $this->brand_job.
+ *
+ * @param int $batch_size Posts per batch on this site.
+ * @param int $brand_batches The batch count the brand site estimated when it started.
+ */
+ private function start_reindex_with_brand( int $batch_size, int $brand_batches ): string {
+ Settings::set_shared_sites(
+ [
+ [
+ 'name' => 'Brand',
+ 'url' => 'https://brand.example.com/',
+ 'api_key' => 'brand-key',
+ ],
+ ]
+ );
+ add_filter( 'pre_http_request', [ $this, 'mock_brand_job' ], 10, 3 );
+
+ $parent_id = $this->start_reindex( $batch_size );
+ $parent = $this->scheduler->get_status( $parent_id );
+
+ $parent['data']['sites'] = [
+ [
+ 'site_url' => get_site_url(),
+ 'job_id' => $parent_id,
+ 'batch_count' => count( $parent['child_ids'] ),
+ ],
+ [
+ 'site_url' => 'https://brand.example.com/',
+ 'job_id' => 'brand-job',
+ 'batch_count' => $brand_batches,
+ ],
+ ];
+ $parent['data']['total_batches'] = count( $parent['child_ids'] ) + $brand_batches;
+ $this->scheduler->persist_job( Reindex_Job::from_array( $parent ) );
+
+ return $parent_id;
+ }
+
+ /**
+ * Answers the brand site's job requests with $this->brand_job.
+ *
+ * @param false|array|\WP_Error $preempt Whether to preempt the request.
+ * @param array $args The request arguments.
+ * @param string $url The request URL.
+ * @return false|array|\WP_Error
+ */
+ public function mock_brand_job( $preempt, $args, $url ) { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ if ( ! str_contains( $url, 'brand.example.com' ) ) {
+ return $preempt;
+ }
+
+ if ( is_wp_error( $this->brand_job ) ) {
+ return $this->brand_job;
+ }
+
+ return [
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ 'body' => (string) wp_json_encode(
+ [
+ 'success' => true,
+ 'job' => $this->brand_job,
+ ]
+ ),
+ 'headers' => [],
+ 'cookies' => [],
+ ];
+ }
+
+ /**
+ * Starts a reindex of all posts and returns its job ID.
+ *
+ * @param int $batch_size Posts per batch.
+ */
+ private function start_reindex( int $batch_size ): string {
+ $parent = new Reindex_Job();
+ $parent->set_data(
+ [
+ 'post_types' => [ 'post' ],
+ 'batch_size' => $batch_size,
+ ]
+ );
+ $this->scheduler->persist_job( $parent );
+
+ $this->scheduler->execute_job( Reindex_Job::class, $parent->get_id() );
+
+ return $parent->get_id();
+ }
+
+ /**
+ * Collects the post IDs across a reindex's batches.
+ *
+ * @param string[] $batch_ids The batch job IDs.
+ *
+ * @return int[]
+ */
+ private function get_batched_post_ids( array $batch_ids ): array {
+ $post_ids = [];
+
+ foreach ( $batch_ids as $batch_id ) {
+ $post_ids = array_merge( $post_ids, $this->scheduler->get_status( $batch_id )['data']['post_ids'] );
+ }
+
+ return $post_ids;
+ }
+}
diff --git a/tests/phpunit/Integration/Modules/Search/IndexerTest.php b/tests/phpunit/Integration/Modules/Search/IndexerTest.php
index 93486191..3395be82 100644
--- a/tests/phpunit/Integration/Modules/Search/IndexerTest.php
+++ b/tests/phpunit/Integration/Modules/Search/IndexerTest.php
@@ -12,7 +12,6 @@
use OneSearch\Modules\Rest\Governing_Data_Handler;
use OneSearch\Modules\Search\Indexer;
use OneSearch\Modules\Search\Post_Record;
-use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Tests\TestCase;
use OneSearch\Utils;
@@ -234,7 +233,7 @@ public function test_save_post_deletes_stale_chunks(): void {
);
$this->assertTrue( ( new Indexer() )->save_post( $post ) );
- $this->assertSame( [ sprintf( 'site_post_id:"%s" AND chunk_index >= 1', $site_post_id ) ], $this->get_delete_filters() );
+ $this->assertSame( [ sprintf( 'site_post_id:"%s" AND chunk_index >= 1', $site_post_id ) ], $this->get_delete_filters( $this->requests ) );
}
/**
@@ -245,7 +244,93 @@ public function test_save_post_skips_deleting_without_stale_chunks(): void {
$this->mock_algolia_http_client( $this->paths, null, null, $this->requests );
$this->assertTrue( ( new Indexer() )->save_post( self::factory()->post->create_and_get() ) );
- $this->assertSame( [], $this->get_delete_filters() );
+ $this->assertSame( [], $this->get_delete_filters( $this->requests ) );
+ }
+
+ /**
+ * Stale chunks are deleted with one filter per chunk count, since a filter can't pair each post with its own count.
+ */
+ public function test_save_posts_deletes_stale_chunks_grouped_by_chunk_count(): void {
+ $this->set_up_governing_site();
+
+ $posts = [
+ self::factory()->post->create_and_get( [ 'post_content' => 'Short.' ] ),
+ self::factory()->post->create_and_get( [ 'post_content' => 'Short.' ] ),
+ self::factory()->post->create_and_get( [ 'post_content' => str_repeat( 'Long content for Algolia records. ', 1000 ) ] ),
+ self::factory()->post->create_and_get( [ 'post_content' => 'Short.' ] ),
+ ];
+
+ $post_record = new Post_Record();
+ $site_post_ids = array_map( static fn ( \WP_Post $post ): string => $post_record->get_site_post_id( $post->ID ), $posts );
+ $long_chunks = count( $post_record->to_records( $posts[2] ) );
+
+ $this->assertGreaterThan( 1, $long_chunks );
+
+ // Every post but the last was longer before.
+ $hits = [
+ [
+ 'site_post_id' => $site_post_ids[0],
+ 'chunk_index' => 1,
+ ],
+ [
+ 'site_post_id' => $site_post_ids[1],
+ 'chunk_index' => 2,
+ ],
+ [
+ 'site_post_id' => $site_post_ids[2],
+ 'chunk_index' => $long_chunks,
+ ],
+ [
+ 'site_post_id' => $site_post_ids[3],
+ 'chunk_index' => 0,
+ ],
+ ];
+
+ $this->mock_algolia_http_client(
+ $this->paths,
+ static fn ( string $path ): string => str_ends_with( $path, '/query' )
+ ? (string) wp_json_encode( [ 'hits' => $hits ] )
+ : '{"taskID":1,"status":"published"}',
+ null,
+ $this->requests
+ );
+
+ $this->assertTrue( ( new Indexer() )->save_posts( $posts ) );
+ $this->assertSame(
+ [
+ sprintf( '(site_post_id:"%s" OR site_post_id:"%s") AND chunk_index >= 1', $site_post_ids[0], $site_post_ids[1] ),
+ sprintf( 'site_post_id:"%s" AND chunk_index >= %d', $site_post_ids[2], $long_chunks ),
+ ],
+ $this->get_delete_filters( $this->requests )
+ );
+ }
+
+ /**
+ * A failed write is returned without checking for stale chunks.
+ */
+ public function test_save_posts_returns_error_when_write_fails(): void {
+ $this->set_up_governing_site();
+ $this->mock_algolia_http_client( $this->paths, null, '/batch' );
+
+ $result = ( new Indexer() )->save_posts( [ self::factory()->post->create_and_get() ] );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'onesearch_algolia_save_records_failed', $result->get_error_code() );
+ $this->assertSame( [], array_filter( $this->paths, static fn ( string $path ): bool => str_ends_with( $path, '/query' ) ) );
+ }
+
+ /**
+ * Saving or deleting no posts doesn't call Algolia.
+ */
+ public function test_save_and_delete_posts_without_posts(): void {
+ $this->set_up_governing_site();
+ $this->mock_algolia_http_client( $this->paths );
+
+ $indexer = new Indexer();
+
+ $this->assertTrue( $indexer->save_posts( [] ) );
+ $this->assertTrue( $indexer->delete_posts( [] ) );
+ $this->assertSame( [], $this->paths );
}
/**
@@ -286,7 +371,23 @@ public function test_delete_post(): void {
$this->assertTrue( ( new Indexer() )->delete_post( 42 ) );
$this->assertSame(
[ sprintf( 'site_post_id:"%s"', ( new Post_Record() )->get_site_post_id( 42 ) ) ],
- $this->get_delete_filters()
+ $this->get_delete_filters( $this->requests )
+ );
+ }
+
+ /**
+ * Several posts' records are deleted with a single filter.
+ */
+ public function test_delete_posts(): void {
+ $this->set_up_governing_site();
+ $this->mock_algolia_http_client( $this->paths, null, null, $this->requests );
+
+ $post_record = new Post_Record();
+
+ $this->assertTrue( ( new Indexer() )->delete_posts( [ 42, 43 ] ) );
+ $this->assertSame(
+ [ sprintf( 'site_post_id:"%s" OR site_post_id:"%s"', $post_record->get_site_post_id( 42 ), $post_record->get_site_post_id( 43 ) ) ],
+ $this->get_delete_filters( $this->requests )
);
}
@@ -300,7 +401,7 @@ public function test_delete_site_records(): void {
$this->assertTrue( ( new Indexer() )->delete_site_records( [ 'https://a.example.com', 'https://b.example.com/' ] ) );
$this->assertSame(
[ 'site_url:"https://a.example.com/" OR site_url:"https://b.example.com/"' ],
- $this->get_delete_filters()
+ $this->get_delete_filters( $this->requests )
);
}
@@ -397,37 +498,29 @@ public function test_get_post_records_returns_error_when_request_fails(): void {
}
/**
- * Reindexing replaces the site's records with its indexable posts.
+ * Clearing the site's records sets up the index first, since it may not exist yet.
*/
- public function test_reindex(): void {
- $this->set_up_governing_site( [ 'page' ] );
- $page_id = self::factory()->post->create( [ 'post_type' => 'page' ] );
- self::factory()->post->create( [ 'post_type' => 'post' ] );
-
+ public function test_clear_site_records(): void {
+ $this->set_up_governing_site();
$this->mock_algolia_http_client( $this->paths, null, null, $this->requests );
- $this->assertTrue( ( new Indexer() )->reindex() );
+ $this->assertTrue( ( new Indexer() )->clear_site_records() );
$this->assertSame( self::INDEX_PATH . '/settings', $this->paths[0], 'The index should be set up before anything else.' );
$this->assertSame(
[ sprintf( 'site_url:"%s"', Utils::normalize_url( get_site_url() ) ) ],
- $this->get_delete_filters()
+ $this->get_delete_filters( $this->requests )
);
- $this->assertSame( [ ( new Post_Record() )->get_site_post_id( $page_id ) ], $this->get_saved_site_post_ids() );
}
/**
- * Reindexing reports batches that failed to save.
+ * Nothing is deleted when the index can't be set up.
*/
- public function test_reindex_returns_error_when_a_batch_fails(): void {
+ public function test_clear_site_records_returns_error_when_setup_fails(): void {
$this->set_up_governing_site();
- self::factory()->post->create();
-
- $this->mock_algolia_http_client( $this->paths, null, '/batch' );
-
- $result = ( new Indexer() )->reindex();
+ $this->mock_algolia_http_client( $this->paths, null, '/settings', $this->requests );
- $this->assertWPError( $result );
- $this->assertSame( 'onesearch_algolia_save_records_failed', $result->get_error_code() );
+ $this->assertWPError( ( new Indexer() )->clear_site_records() );
+ $this->assertSame( [], $this->get_delete_filters( $this->requests ) );
}
/**
@@ -443,66 +536,14 @@ private function mock_api_key_acl( array $acl ): void {
}
/**
- * Collects the `filters` of every deleteByQuery request.
- *
- * @return string[]
- */
- private function get_delete_filters(): array {
- $filters = [];
-
- foreach ( $this->requests as $request ) {
- if ( str_contains( $request['path'], '/deleteByQuery' ) ) {
- $filters[] = (string) json_decode( $request['body'], true )['filters'];
- }
- }
-
- return $filters;
- }
-
- /**
- * Collects the unique `site_post_id` of every saved record.
- *
- * @return string[]
- */
- private function get_saved_site_post_ids(): array {
- $ids = [];
-
- foreach ( $this->requests as $request ) {
- if ( ! str_contains( $request['path'], '/batch' ) ) {
- continue;
- }
-
- foreach ( json_decode( $request['body'], true )['requests'] as $operation ) {
- $ids[] = (string) $operation['body']['site_post_id'];
- }
- }
-
- return array_values( array_unique( $ids ) );
- }
-
- /**
- * Configures the current site as a governing site with credentials and indexable entities.
+ * {@inheritDoc}
*
- * @param string[] $entities The indexable post types.
+ * Names the index after a known host, since the test site's URL depends on the environment.
*/
- private function set_up_governing_site( array $entities = [ 'post' ] ): void {
- // Name the index after a known host, since the test site's URL depends on the environment.
+ protected function set_up_governing_site( array $entities = [ 'post' ] ): void {
add_filter( 'option_siteurl', static fn (): string => 'https://governing.example.org', 11 );
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => 'test-app',
- 'write_key' => 'test-key',
- ]
- );
- update_option(
- Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES,
- [
- 'entities' => [
- Utils::normalize_url( get_site_url() ) => $entities,
- ],
- ]
- );
+
+ parent::set_up_governing_site( $entities );
}
/**
diff --git a/tests/phpunit/Integration/Modules/Search/PostRecordTest.php b/tests/phpunit/Integration/Modules/Search/PostRecordTest.php
index d0932700..6f70cf7f 100644
--- a/tests/phpunit/Integration/Modules/Search/PostRecordTest.php
+++ b/tests/phpunit/Integration/Modules/Search/PostRecordTest.php
@@ -183,21 +183,6 @@ public function test_to_records_with_empty_content(): void {
$this->assertSame( '', $records[0]['content'] );
}
- /**
- * Tests that split_content_into_chunks splits oversized content.
- */
- public function test_split_content_into_chunks_splits_oversized_content_into_multiple_chunks(): void {
- $record = new Post_Record();
- $method = new \ReflectionMethod( Post_Record::class, 'split_content_into_chunks' );
-
- $chunks = $method->invoke( $record, str_repeat( 'Chunked content for Algolia records. ', 80 ), 120 );
-
- $this->assertIsArray( $chunks );
- $this->assertGreaterThan( 1, count( $chunks ) );
- $this->assertFalse( str_starts_with( $chunks[0], '… ' ) );
- $this->assertStringStartsWith( '… ', $chunks[1] );
- }
-
/**
* Joining the chunks restores the content they were split from, whatever order they're in.
*/
diff --git a/tests/phpunit/Integration/Modules/Search/SearchTest.php b/tests/phpunit/Integration/Modules/Search/SearchTest.php
index 0c82d3b5..541f1918 100644
--- a/tests/phpunit/Integration/Modules/Search/SearchTest.php
+++ b/tests/phpunit/Integration/Modules/Search/SearchTest.php
@@ -41,22 +41,6 @@ public function test_class_instantiation(): void {
$this->assertTrue( true );
}
- /**
- * Returns original posts when search is not enabled.
- */
- public function test_get_algolia_results_returns_original_posts_when_search_disabled(): void {
- delete_option( Search_Settings::OPTION_GOVERNING_SEARCH_SETTINGS );
- delete_option( Settings::OPTION_SITE_TYPE );
-
- $search = new Search();
- $query = new \WP_Query();
- $original = [ self::factory()->post->create_and_get() ];
-
- $result = $search->get_algolia_results( $original, $query );
-
- $this->assertSame( $original, $result );
- }
-
/**
* Returns original posts when query is not a search query.
*/
@@ -233,98 +217,6 @@ static function () use ( $matched ): string {
$this->assertSame( '… middle', $posts[0]->post_content );
}
- /**
- * Sorts hits by Algolia ranking score (descending) without raising notices.
- */
- public function test_get_algolia_results_sorts_hits_by_ranking_score(): void {
- $this->enable_search_for_governing_site();
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => 'TEST_APP',
- 'write_key' => 'TEST_KEY',
- ]
- );
-
- $recorded_paths = [];
- $this->mock_algolia_http_client(
- $recorded_paths,
- static function ( string $path ): string {
- if ( str_contains( $path, '/query' ) ) {
- return wp_json_encode(
- [
- 'hits' => [
- [
- 'objectID' => '20',
- 'site_post_id' => '20',
- 'post_id' => 20,
- 'post_title' => 'Middle Score',
- 'post_type' => 'post',
- 'permalink' => 'https://remote.example.com/posts/20/',
- 'site_url' => 'https://remote.example.com/',
- 'site_name' => 'Remote',
- 'total_chunks' => 1,
- 'post_date_gmt' => 1710000000,
- 'post_modified_gmt' => 1710000000,
- '_rankingInfo' => [ 'rankingScore' => 0.5 ],
- ],
- [
- 'objectID' => '30',
- 'site_post_id' => '30',
- 'post_id' => 30,
- 'post_title' => 'Lowest Score',
- 'post_type' => 'post',
- 'permalink' => 'https://remote.example.com/posts/30/',
- 'site_url' => 'https://remote.example.com/',
- 'site_name' => 'Remote',
- 'total_chunks' => 1,
- 'post_date_gmt' => 1710000000,
- 'post_modified_gmt' => 1710000000,
- '_rankingInfo' => [ 'rankingScore' => 0.1 ],
- ],
- [
- 'objectID' => '40',
- 'site_post_id' => '40',
- 'post_id' => 40,
- 'post_title' => 'Highest Score',
- 'post_type' => 'post',
- 'permalink' => 'https://remote.example.com/posts/40/',
- 'site_url' => 'https://remote.example.com/',
- 'site_name' => 'Remote',
- 'total_chunks' => 1,
- 'post_date_gmt' => 1710000000,
- 'post_modified_gmt' => 1710000000,
- '_rankingInfo' => [ 'rankingScore' => 0.9 ],
- ],
- ],
- 'nbHits' => 3,
- 'page' => 0,
- 'hitsPerPage' => 10,
- ]
- ) ?: '{}';
- }
-
- if ( str_contains( $path, '/task/' ) ) {
- return '{"status":"published","pendingTask":false}';
- }
-
- return '{"taskID":1,"updatedAt":"2024-01-01T00:00:00.000Z"}';
- }
- );
-
- $this->prime_main_search_query( 'remote test' );
-
- // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reading query prepared by helper.
- global $wp_query;
-
- $search = new Search();
- $result = $search->get_algolia_results( [], $wp_query );
-
- $this->assertCount( 3, $result );
- $this->assertSame( 'Highest Score', $result[0]->post_title );
- $this->assertSame( 'Middle Score', $result[1]->post_title );
- $this->assertSame( 'Lowest Score', $result[2]->post_title );
- }
-
/**
* Returns default permalink for local posts (positive ID).
*/
@@ -657,18 +549,21 @@ public function test_get_post_terms_returns_original_when_search_disabled(): voi
}
/**
- * Returns mapped terms for enabled search when remote taxonomy metadata exists.
+ * Maps a remote post's taxonomy metadata to terms when core passes its negative ID.
+ *
+ * Goes through get_the_terms() so the filter receives the ID exactly as core sends it.
*/
- public function test_get_post_terms_returns_remote_terms_when_search_enabled(): void {
+ public function test_get_post_terms_returns_remote_terms_for_negative_id(): void {
$this->enable_search_for_governing_site();
$this->prime_main_search_query( 'test query' );
- $post_id = self::factory()->post->create();
- $post = get_post( $post_id );
-
- $this->assertInstanceOf( \WP_Post::class, $post );
+ global $wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- $post->onesearch_remote_taxonomies = [
+ $remote_post = new \WP_Post( new \stdClass() );
+ $remote_post->ID = -23;
+ $remote_post->filter = 'raw';
+ $remote_post->onesearch_original_id = 22;
+ $remote_post->onesearch_remote_taxonomies = [
[
'taxonomy' => 'category',
'term_id' => 22,
@@ -679,12 +574,23 @@ public function test_get_post_terms_returns_remote_terms_when_search_enabled():
'description' => 'Remote category',
'parent' => 0,
],
+ [
+ 'taxonomy' => 'post_tag',
+ 'term_id' => 7,
+ 'slug' => 'remote-tag',
+ 'name' => 'Remote Tag',
+ 'term_link' => 'https://remote.example.com/tag/remote-tag/',
+ 'count' => 1,
+ 'description' => '',
+ 'parent' => 0,
+ ],
];
- wp_cache_set( $post_id, $post, 'posts' );
+ $wp_query->posts = [ $remote_post ];
- $search = new Search();
- $result = $search->get_post_terms( [], $post_id, 'category' );
+ add_filter( 'get_the_terms', [ new Search(), 'get_post_terms' ], 10, 3 );
+
+ $result = get_the_terms( $remote_post, 'category' );
$this->assertIsArray( $result );
$this->assertCount( 1, $result );
diff --git a/tests/phpunit/Integration/Modules/Search/SettingsTest.php b/tests/phpunit/Integration/Modules/Search/SettingsTest.php
index 5e5d190e..9360b157 100644
--- a/tests/phpunit/Integration/Modules/Search/SettingsTest.php
+++ b/tests/phpunit/Integration/Modules/Search/SettingsTest.php
@@ -21,10 +21,22 @@
*/
#[CoversClass( \OneSearch\Modules\Search\Settings::class )]
final class SettingsTest extends TestCase {
+ /**
+ * {@inheritDoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ // A site-type option left by an earlier test class would make is_governing_site() true here.
+ delete_option( Settings::OPTION_SITE_TYPE );
+ }
+
/**
* {@inheritDoc}
*/
protected function tearDown(): void {
+ delete_option( Settings::OPTION_SITE_TYPE );
+
AlgoliaSDK::resetHttpClient();
parent::tearDown();
diff --git a/tests/phpunit/Integration/Modules/Search/WatcherTest.php b/tests/phpunit/Integration/Modules/Search/WatcherTest.php
index cb305a02..4b9f35e6 100644
--- a/tests/phpunit/Integration/Modules/Search/WatcherTest.php
+++ b/tests/phpunit/Integration/Modules/Search/WatcherTest.php
@@ -10,12 +10,12 @@
namespace OneSearch\Tests\Integration\Modules\Search;
use OneSearch\Modules\Rest\Governing_Data_Handler;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Search\Watcher;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Tests\TestCase;
use OneSearch\Utils;
-use OneSearch\Vendor\Algolia\AlgoliaSearch\Algolia as AlgoliaSDK;
use PHPUnit\Framework\Attributes\CoversClass;
/**
@@ -27,177 +27,14 @@ final class WatcherTest extends TestCase {
* {@inheritDoc}
*/
protected function tearDown(): void {
- AlgoliaSDK::resetHttpClient();
+ // Clean up any AS actions enqueued by the Watcher during this test.
+ as_unschedule_all_actions( Job_Scheduler::HOOK );
- // Reset governing site config to avoid polluting other tests.
- delete_option( Settings::OPTION_SITE_TYPE );
- delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES );
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => '',
- 'write_key' => '',
- ]
- );
+ $this->tear_down_governing_site();
parent::tearDown();
}
- /**
- * Ensures the class is initialized correctly
- */
- public function test_class_instantiation(): void {
- $watcher = new Watcher();
- $watcher->register_hooks();
- // If we made it this far, we're good.
- $this->assertTrue( true );
- }
-
- /**
- * Skips when post is not a WP_Post instance.
- */
- public function test_on_post_transition_skips_non_wp_post(): void {
- $watcher = new Watcher();
-
- // Should not throw or error out.
- // @phpstan-ignore argument.type -- Non-WP_Post passed intentionally.
- $watcher->on_post_transition( 'publish', 'draft', 'not-a-post' );
-
- $this->assertTrue( true );
- }
-
- /**
- * Skips when post type is not indexable (no entities configured).
- */
- public function test_on_post_transition_skips_non_indexable_post_type(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
- // Set indexable entities to only 'page', not 'post'.
- update_option(
- Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES,
- [
- 'entities' => [
- Utils::normalize_url( get_site_url() ) => [ 'page' ],
- ],
- ]
- );
-
- $post = self::factory()->post->create_and_get( [ 'post_type' => 'post' ] );
- $watcher = new Watcher();
-
- // Should exit early without hitting Algolia since 'post' is not indexable.
- $watcher->on_post_transition( 'publish', 'draft', $post );
-
- $this->assertTrue( true );
- }
-
- /**
- * Skips when no indexable entities are configured at all.
- */
- public function test_on_post_transition_skips_when_no_entities_configured(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
- delete_option( Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES );
-
- $post = self::factory()->post->create_and_get();
- $watcher = new Watcher();
-
- // Should exit early without hitting Algolia since no indexable entities are configured.
- $watcher->on_post_transition( 'publish', 'draft', $post );
-
- $this->assertTrue( true );
- }
-
- /**
- * Processes indexable post types (though Algolia call will fail without credentials,
- * we verify it gets past the post_type guard).
- */
- public function test_on_post_transition_processes_indexable_post_type(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
- update_option(
- Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES,
- [
- 'entities' => [
- Utils::normalize_url( get_site_url() ) => [ 'post' ],
- ],
- ]
- );
- // No Algolia credentials → delete_by will return WP_Error, so on_post_transition
- // will return early after the failed delete. This tests the flow gets past the
- // post_type check.
- delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
-
- $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
- $watcher = new Watcher();
-
- $watcher->on_post_transition( 'publish', 'draft', $post );
-
- $this->assertTrue( true );
- }
-
- /**
- * Skips reindexing when new status is not an allowed status (e.g., trashed).
- *
- * Injects a fake Algolia HTTP client to intercept SDK-level requests (the SDK
- * does not use wp_remote_*, so pre_http_request cannot be used here). After
- * transitioning to 'trash', only the deleteBy call should have been made — no
- * /batch (saveObjects) request should appear.
- */
- public function test_on_post_transition_does_not_reindex_disallowed_status(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER );
- update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' );
-
- $this->set_consumer_brand_config_cache();
-
- // Intercept every Algolia SDK HTTP call and record the request paths.
- $recorded_paths = [];
- $this->mock_algolia_http_client( $recorded_paths );
-
- $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
- $watcher = new Watcher();
-
- // Transitioning to 'trash' should delete from the index but must not reindex.
- $watcher->on_post_transition( 'trash', 'publish', $post );
-
- // deleteBy was called (some path was recorded), but no /batch (saveObjects) call
- // should have been made — the status guard must have stopped reindexing.
- $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) );
- $this->assertEmpty( $batch_calls, 'Trash transition should perform delete only and must not reindex.' );
- }
-
- /**
- * Consumer site attempts to fetch allowed post types from brand config.
- */
- public function test_on_post_transition_consumer_site_checks_brand_config(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER );
- delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL );
- delete_transient( Governing_Data_Handler::TRANSIENT_KEY );
-
- $post = self::factory()->post->create_and_get();
- $watcher = new Watcher();
-
- // No parent URL → get_brand_config returns WP_Error → post type is not indexable.
- $watcher->on_post_transition( 'publish', 'draft', $post );
-
- $this->assertTrue( true );
- }
-
- /**
- * Consumer site with cached brand config recognizes indexable post types.
- */
- public function test_on_post_transition_consumer_with_cached_config(): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER );
- update_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL, 'https://governing.example.com' );
-
- $this->set_consumer_brand_config_cache( 'test-app', 'test-key' );
-
- $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
- $watcher = new Watcher();
-
- // 'post' is in indexable_entities, so it passes the guard.
- // Algolia SDK will fail with test credentials, but the guard logic is tested.
- $watcher->on_post_transition( 'publish', 'draft', $post );
-
- $this->assertTrue( true );
- }
-
/**
* Prime the consumer brand config cache used by Watcher guards.
*
@@ -223,8 +60,8 @@ private function set_consumer_brand_config_cache( string $app_id = 'TEST_APP', s
}
/**
- * Indexable post triggers Algolia saveObjects (reindex) call.
- * This verifies the full integration flow for a governing site.
+ * Indexable post schedules an async Sync_Job for a governing site.
+ * Algolia isn't called during the request; the job runs in an AS worker.
*/
public function test_on_post_transition_triggers_algolia_reindex(): void {
update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
@@ -244,24 +81,33 @@ public function test_on_post_transition_triggers_algolia_reindex(): void {
]
);
- $recorded_paths = [];
- $this->mock_algolia_http_client( $recorded_paths );
-
$post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
$watcher = new Watcher();
- // Transition from 'draft' to 'publish' (should trigger reindex).
$watcher->on_post_transition( 'publish', 'draft', $post );
- // Assert that a /batch (saveObjects) call was made.
- $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) );
- $this->assertNotEmpty( $batch_calls, 'Happy path should trigger Algolia reindex (saveObjects).' );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Job_Scheduler::HOOK ),
+ 'Happy path should schedule an async Sync_Job via Action Scheduler.'
+ );
}
/**
- * Indexable post triggers Algolia saveObjects (reindex) call on a consumer (brand) site.
- * This verifies the full integration flow where credentials and indexable entities
- * are resolved from the governing site's brand config cache.
+ * A queued sync starts Action Scheduler's runner once the request ends, since a REST save won't.
+ */
+ public function test_on_post_transition_dispatches_the_queue_at_shutdown(): void {
+ $this->set_up_governing_site();
+
+ $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
+ ( new Watcher() )->on_post_transition( 'publish', 'draft', $post );
+
+ $this->assertNotFalse( has_action( 'shutdown', [ Job_Scheduler::class, 'dispatch_queue' ] ) );
+ }
+
+ /**
+ * Indexable post on a consumer site schedules an async Sync_Job.
+ * Credentials and indexable entities are resolved from the brand config cache,
+ * but Algolia isn't called during the request; the job runs in an AS worker.
*/
public function test_on_post_transition_triggers_algolia_reindex_consumer_site(): void {
update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_CONSUMER );
@@ -269,49 +115,46 @@ public function test_on_post_transition_triggers_algolia_reindex_consumer_site()
$this->set_consumer_brand_config_cache( 'test-app', 'test-key' );
- $recorded_paths = [];
- $this->mock_algolia_http_client( $recorded_paths );
-
$post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
$watcher = new Watcher();
- // Transition from 'draft' to 'publish' (should trigger reindex).
$watcher->on_post_transition( 'publish', 'draft', $post );
- // Assert that a /batch (saveObjects) call was made.
- $batch_calls = array_filter( $recorded_paths, static fn ( $p ) => str_contains( $p, '/batch' ) );
- $this->assertNotEmpty( $batch_calls, 'Consumer site happy path should trigger Algolia reindex (saveObjects).' );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Job_Scheduler::HOOK ),
+ 'Consumer site happy path should schedule an async Sync_Job via Action Scheduler.'
+ );
}
/**
- * A post leaving `publish` must have its records removed from Algolia.
+ * When the Sync_Job can't be queued, the post is synced during the request instead.
*/
- public function test_deletes_records_when_a_post_leaves_publish(): void {
+ public function test_syncs_inline_when_scheduling_fails(): void {
$this->set_up_governing_site();
$paths = [];
$requests = [];
$this->mock_algolia_http_client( $paths, null, null, $requests );
- ( new Watcher() )->register_hooks();
+ $post = self::factory()->post->create_and_get( [ 'post_status' => 'publish' ] );
- $post_id = self::factory()->post->create( [ 'post_status' => 'publish' ] );
+ // Action Scheduler reports a failed enqueue as action ID 0.
+ add_filter( 'pre_as_enqueue_async_action', '__return_zero' );
+
+ ( new Watcher() )->on_post_transition( 'publish', 'draft', $post );
$stored_id = $this->get_indexed_site_post_id( $requests );
- // Drop the publish traffic, so only the delete request is left to assert on.
- $requests = [];
+ $requests = [];
+ $post->post_status = 'draft';
- wp_update_post(
- [
- 'ID' => $post_id,
- 'post_status' => 'draft',
- ]
- );
+ ( new Watcher() )->on_post_transition( 'draft', 'publish', $post );
+
+ remove_filter( 'pre_as_enqueue_async_action', '__return_zero' );
$this->assertSame(
[ sprintf( 'site_post_id:"%s"', $stored_id ) ],
$this->get_delete_filters( $requests ),
- 'Unpublishing a post must delete its records by the stored site_post_id.'
+ 'An unpublished post must have its records deleted inline.'
);
}
@@ -353,78 +196,4 @@ function () use ( &$requests, &$filters_while_the_post_still_exists ): void {
'The records still have to go once the post has been deleted.'
);
}
-
- /**
- * Reads the `site_post_id` the records were actually written with.
- *
- * @param array $requests The intercepted requests.
- */
- private function get_indexed_site_post_id( array $requests ): string {
- $ids = [];
-
- foreach ( $requests as $request ) {
- if ( ! str_contains( $request['path'], '/batch' ) ) {
- continue;
- }
-
- $body = json_decode( $request['body'], true );
- foreach ( $body['requests'] ?? [] as $operation ) {
- if ( isset( $operation['body']['site_post_id'] ) ) {
- $ids[] = (string) $operation['body']['site_post_id'];
- }
- }
- }
-
- $ids = array_values( array_unique( $ids ) );
- $this->assertCount( 1, $ids, 'Publishing should write records under exactly one site_post_id.' );
-
- return $ids[0];
- }
-
- /**
- * Collects the `filters` argument of every deleteByQuery request that was sent.
- *
- * @param array $requests The intercepted requests.
- *
- * @return list
- */
- private function get_delete_filters( array $requests ): array {
- $filters = [];
-
- foreach ( $requests as $request ) {
- if ( ! str_contains( $request['path'], '/deleteByQuery' ) ) {
- continue;
- }
-
- $body = json_decode( $request['body'], true );
- if ( is_array( $body ) && isset( $body['filters'] ) ) {
- $filters[] = (string) $body['filters'];
- }
- }
-
- return $filters;
- }
-
- /**
- * Configures the current site as a governing site with credentials and indexable entities.
- *
- * @param string[] $entities The indexable post types.
- */
- private function set_up_governing_site( array $entities = [ 'post' ] ): void {
- update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
- Search_Settings::set_algolia_credentials(
- [
- 'app_id' => 'test-app',
- 'write_key' => 'test-key',
- ]
- );
- update_option(
- Search_Settings::OPTION_GOVERNING_INDEXABLE_SITES,
- [
- 'entities' => [
- Utils::normalize_url( get_site_url() ) => $entities,
- ],
- ]
- );
- }
}
diff --git a/tests/phpunit/Integration/Modules/Settings/SettingsTest.php b/tests/phpunit/Integration/Modules/Settings/SettingsTest.php
index 2bcaa57c..efb8126c 100644
--- a/tests/phpunit/Integration/Modules/Settings/SettingsTest.php
+++ b/tests/phpunit/Integration/Modules/Settings/SettingsTest.php
@@ -24,6 +24,11 @@ final class SettingsTest extends TestCase {
protected function setUp(): void {
parent::setUp();
+ delete_option( Settings::OPTION_SITE_TYPE );
+ delete_option( Settings::OPTION_CONSUMER_API_KEY );
+ delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+
$this->settings = new Settings();
}
diff --git a/tests/phpunit/TestCase.php b/tests/phpunit/TestCase.php
index c7e771ee..52937f22 100644
--- a/tests/phpunit/TestCase.php
+++ b/tests/phpunit/TestCase.php
@@ -157,5 +157,110 @@ public function sendRequest( \Psr\Http\Message\RequestInterface $request, mixed
);
}
- // Add any common setup or utility methods for tests here.
+ /**
+ * Reads the `site_post_id` the intercepted records were actually written with.
+ *
+ * Asserting against the stored value, rather than rebuilding it, is what makes
+ * a drift between the write format and the delete filter visible.
+ *
+ * @see \OneSearch\Modules\Search\Post_Record::get_site_post_id()
+ *
+ * @param array $requests The intercepted requests.
+ * @param int $expected_count How many distinct IDs the write traffic should carry.
+ *
+ * @return list The distinct site_post_id values, in the order first seen.
+ */
+ protected function get_indexed_site_post_ids( array $requests, int $expected_count = 1 ): array {
+ $ids = [];
+
+ foreach ( $requests as $request ) {
+ if ( ! str_contains( $request['path'], '/batch' ) ) {
+ continue;
+ }
+
+ $body = json_decode( $request['body'], true );
+ foreach ( $body['requests'] ?? [] as $operation ) {
+ if ( isset( $operation['body']['site_post_id'] ) ) {
+ $ids[] = (string) $operation['body']['site_post_id'];
+ }
+ }
+ }
+
+ $ids = array_values( array_unique( $ids ) );
+ $this->assertCount( $expected_count, $ids, 'Unexpected number of site_post_id values in the write traffic.' );
+
+ return $ids;
+ }
+
+ /**
+ * Reads the single `site_post_id` the intercepted records were written with.
+ *
+ * @param array $requests The intercepted requests.
+ */
+ protected function get_indexed_site_post_id( array $requests ): string {
+ return $this->get_indexed_site_post_ids( $requests )[0];
+ }
+
+ /**
+ * Collects the `filters` argument of every deleteByQuery request that was sent.
+ *
+ * @param array $requests The intercepted requests.
+ *
+ * @return list
+ */
+ protected function get_delete_filters( array $requests ): array {
+ $filters = [];
+
+ foreach ( $requests as $request ) {
+ if ( ! str_contains( $request['path'], '/deleteByQuery' ) ) {
+ continue;
+ }
+
+ $body = json_decode( $request['body'], true );
+ if ( is_array( $body ) && isset( $body['filters'] ) ) {
+ $filters[] = (string) $body['filters'];
+ }
+ }
+
+ return $filters;
+ }
+
+ /**
+ * Configures the current site as a governing site with credentials and indexable entities.
+ *
+ * @param string[] $entities The indexable post types.
+ */
+ protected function set_up_governing_site( array $entities = [ 'post' ] ): void {
+ update_option( \OneSearch\Modules\Settings\Settings::OPTION_SITE_TYPE, \OneSearch\Modules\Settings\Settings::SITE_TYPE_GOVERNING );
+ \OneSearch\Modules\Search\Settings::set_algolia_credentials(
+ [
+ 'app_id' => 'test-app',
+ 'write_key' => 'test-key',
+ ]
+ );
+ update_option(
+ \OneSearch\Modules\Search\Settings::OPTION_GOVERNING_INDEXABLE_SITES,
+ [
+ 'entities' => [
+ \OneSearch\Utils::normalize_url( get_site_url() ) => $entities,
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Restores the Algolia and site-type state that set_up_governing_site() changed.
+ */
+ protected function tear_down_governing_site(): void {
+ \OneSearch\Vendor\Algolia\AlgoliaSearch\Algolia::resetHttpClient();
+
+ delete_option( \OneSearch\Modules\Settings\Settings::OPTION_SITE_TYPE );
+ delete_option( \OneSearch\Modules\Search\Settings::OPTION_GOVERNING_INDEXABLE_SITES );
+ \OneSearch\Modules\Search\Settings::set_algolia_credentials(
+ [
+ 'app_id' => '',
+ 'write_key' => '',
+ ]
+ );
+ }
}
diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php
index 1d787a62..3c19951d 100644
--- a/tests/phpunit/bootstrap.php
+++ b/tests/phpunit/bootstrap.php
@@ -44,3 +44,9 @@ static function (): void {
// Start up the WP testing environment.
require $_test_root . '/includes/bootstrap.php';
+
+/*
+ * Main skips loading under the test install's plain permalinks, so the jobs table is created here instead.
+ * Left to the first test that needs it, dbDelta's ALTER TABLE would commit that test's transaction, leaking its options into later tests.
+ */
+\OneSearch\Modules\Schema\Job_Schema::maybe_upgrade();
diff --git a/uninstall.php b/uninstall.php
index 0575a6c6..4e0dc61a 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -93,17 +93,44 @@ function delete_options(): void {
// Shared proxy attachment used for remote post thumbnails.
PLUGIN_PREFIX . 'proxy_attachment_id',
+
+ // Job schema version / migration flags.
+ PLUGIN_PREFIX . 'jobs_schema_version',
+ PLUGIN_PREFIX . 'jobs_migrated',
];
foreach ( $options as $option ) {
delete_option( $option );
}
+
+ // Delete spinlocks (ephemeral, but clean up on uninstall).
+ global $wpdb;
+ // phpcs:disable WordPressVIPMinimum.DirectDBQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
+ PLUGIN_PREFIX . 'job_status_%_lock'
+ )
+ );
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name = %s",
+ PLUGIN_PREFIX . 'reindex_state_lock'
+ )
+ );
+ // phpcs:enable
+
+ $table = $wpdb->prefix . 'onesearch_index_jobs';
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( "DROP TABLE IF EXISTS {$table}" );
}
/**
* Deletes transients.
*/
function delete_transients(): void {
+ global $wpdb;
+
$transients = [
// Governing site transients.
PLUGIN_PREFIX . 'brand_config_cache',
@@ -112,6 +139,18 @@ function delete_transients(): void {
foreach ( $transients as $transient ) {
delete_transient( $transient );
}
+
+ // Delete all job status transients and reindex state transients.
+ // phpcs:ignore WordPressVIPMinimum.DirectDBQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s",
+ '_transient_' . PLUGIN_PREFIX . 'job_status_%',
+ '_transient_timeout_' . PLUGIN_PREFIX . 'job_status_%',
+ '_transient_' . PLUGIN_PREFIX . 'reindex_state%',
+ '_transient_timeout_' . PLUGIN_PREFIX . 'reindex_state%'
+ )
+ );
}
/**