diff --git a/assets/src/components/SiteIndexableEntities.tsx b/assets/src/components/SiteIndexableEntities.tsx
deleted file mode 100644
index 5d2083a..0000000
--- 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/BatchRow.tsx b/assets/src/components/SiteIndexableEntities/BatchRow.tsx
new file mode 100644
index 0000000..a2beec2
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/BatchRow.tsx
@@ -0,0 +1,34 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import JobStatusBadge from './JobStatusBadge';
+import type { JobStatus } from './types';
+
+interface BatchRowProps {
+ child: JobStatus;
+ idx: number;
+}
+
+const BatchRow = ( { child, idx }: BatchRowProps ) => (
+
+
+ { sprintf(
+ /* translators: %d: batch number */
+ __( 'Batch %d', 'onesearch' ),
+ idx + 1
+ ) }
+
+
+ { child.error && (
+
+ { child.error.substring( 0, 80 ) }
+
+ ) }
+
+);
+
+export default BatchRow;
diff --git a/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx b/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx
new file mode 100644
index 0000000..579cf1a
--- /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/HistoryDetailsView.tsx b/assets/src/components/SiteIndexableEntities/HistoryDetailsView.tsx
new file mode 100644
index 0000000..65dbc99
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/HistoryDetailsView.tsx
@@ -0,0 +1,180 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Button, __experimentalText as Text } from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import type { JobStatus, SiteJobState } from './types';
+import BatchRow from './BatchRow';
+import JobStatusBadge from './JobStatusBadge';
+import { getHistorySites } from './utils';
+
+interface HistoryDetailsViewProps {
+ selectedHistoryJob: JobStatus;
+ historyDetails: SiteJobState[];
+ historyDetailsLoading: boolean;
+ hasFailedHistoryDetails: boolean;
+ retryingHistoryJob: boolean;
+ currentSiteUrl: string;
+ onBack: () => void;
+ onRetry: () => void;
+}
+
+const HistoryDetailsView = ( {
+ selectedHistoryJob,
+ historyDetails,
+ historyDetailsLoading,
+ hasFailedHistoryDetails,
+ retryingHistoryJob,
+ currentSiteUrl,
+ onBack,
+ onRetry,
+}: HistoryDetailsViewProps ) => {
+ const historySites = getHistorySites( selectedHistoryJob, currentSiteUrl );
+ const totalBatches = historySites.reduce(
+ ( sum, site ) => sum + ( site.batch_count || 0 ),
+ 0
+ );
+
+ return (
+
+
+
+ ‹ { __( 'Back', 'onesearch' ) }
+
+ { ( hasFailedHistoryDetails || retryingHistoryJob ) && (
+
+ { retryingHistoryJob
+ ? __( 'Retrying…', 'onesearch' )
+ : __( 'Retry Failed Batches', 'onesearch' ) }
+
+ ) }
+
+
+
+
+ { __( 'Sites', 'onesearch' ) }
+ { historySites.length }
+
+
+ { __( 'Batches', 'onesearch' ) }
+
+ { totalBatches ||
+ selectedHistoryJob.children_total ||
+ selectedHistoryJob.progress_total }
+
+
+
+ { __( 'Status', 'onesearch' ) }
+
+
+
+
+ { selectedHistoryJob.error && (
+
+ { selectedHistoryJob.error }
+
+ ) }
+
+
+ { historyDetailsLoading && (
+
+
+
+ { __( 'Loading job details…', 'onesearch' ) }
+
+
+ ) }
+
+ { ! historyDetailsLoading && (
+
+ { historyDetails.map( ( state ) => (
+
+
+
+
+ { state.site.site_name }
+
+
+ { state.site.site_url }
+
+
+
+
+ { sprintf(
+ /* translators: %d: batch count */
+ __( '%d batches', 'onesearch' ),
+ state.site.batch_count ||
+ state.children.length
+ ) }
+
+ { state.reindexJob && (
+
+ ) }
+
+
+
+ { state.children.length > 0 ? (
+
+ { state.children.map(
+ ( child, idx ) => (
+
+ )
+ ) }
+
+ ) : (
+
+ { __(
+ 'No batch details available.',
+ 'onesearch'
+ ) }
+
+ ) }
+
+ ) ) }
+
+ ) }
+
+
+ );
+};
+
+export default HistoryDetailsView;
diff --git a/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx
new file mode 100644
index 0000000..82f8793
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx
@@ -0,0 +1,102 @@
+/**
+ * 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( '…' );
+ }
+ if ( last > 1 ) {
+ 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 0000000..7b63264
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/HistoryTable.tsx
@@ -0,0 +1,110 @@
+/**
+ * WordPress dependencies
+ */
+import { __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[];
+ onOpenDetails: ( job: JobStatus ) => void;
+}
+
+const HistoryTable = ( { history, onOpenDetails }: HistoryTableProps ) => {
+ if ( history.length === 0 ) {
+ return (
+ { __( 'No past jobs.', 'onesearch' ) }
+ );
+ }
+
+ return (
+
+
+
+ { __( 'ID', 'onesearch' ) }
+ { __( 'Type', 'onesearch' ) }
+ { __( 'Created at', 'onesearch' ) }
+ { __( 'Duration', 'onesearch' ) }
+ { __( 'Status', 'onesearch' ) }
+ { __( 'Batches', 'onesearch' ) }
+
+
+
+ { history.map( ( job ) => {
+ const totalBatches =
+ ( job.data?.[ 'total_batches' ] as number ) ||
+ job.children_total ||
+ job.progress_total;
+ const completedBatches =
+ job.children_completed ?? job.progress;
+ const batchDisplay =
+ totalBatches > 0
+ ? `${ completedBatches }/${ totalBatches }`
+ : '—';
+
+ const duration =
+ job.finished_at && job.created_at
+ ? job.finished_at - job.created_at
+ : null;
+
+ const handleKeyDown = ( e: React.KeyboardEvent ) => {
+ if ( e.key === 'Enter' || e.key === ' ' ) {
+ e.preventDefault();
+ void onOpenDetails( job );
+ }
+ };
+
+ return (
+ void onOpenDetails( job ) }
+ onKeyDown={ handleKeyDown }
+ role="button"
+ tabIndex={ 0 }
+ >
+
+
+ { job.id.substring( 0, 16 ) }…
+
+
+
+
+ { job.group || 'reindex' }
+
+
+
+
+ { formatTimestamp( job.created_at ) }
+
+
+
+
+ { duration !== null
+ ? formatDuration( duration )
+ : '—' }
+
+
+
+
+
+
+ { batchDisplay }
+
+
+ );
+ } ) }
+
+
+ );
+};
+
+export default HistoryTable;
diff --git a/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx
new file mode 100644
index 0000000..1626e61
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import type { StatusUIType } from '@/types/global';
+/**
+ * Internal dependencies
+ */
+import { STATUS_LABELS } from './constants';
+
+interface JobStatusBadgeProps {
+ status: string;
+ size?: 'normal' | 'small';
+ type?: StatusUIType;
+}
+
+const JobStatusBadge = ( {
+ status,
+ size = 'normal',
+ type = 'badge',
+}: JobStatusBadgeProps ) => {
+ if ( type === 'text' ) {
+ return { STATUS_LABELS[ status ] || status } ;
+ }
+ 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 0000000..9e915f1
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/ReindexModal.tsx
@@ -0,0 +1,106 @@
+/**
+ * WordPress dependencies
+ */
+import { Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import type { JobStatus, SiteJobState } from './types';
+import HistoryDetailsView from './HistoryDetailsView';
+import ReindexModalContent from './ReindexModalContent';
+
+interface ReindexModalProps {
+ reindexing: boolean;
+ siteStates: SiteJobState[];
+ cancelling: boolean;
+ history: JobStatus[];
+ historyPage: number;
+ historyTotalPages: number;
+ selectedHistoryJob: JobStatus | null;
+ historyDetails: SiteJobState[];
+ historyDetailsLoading: boolean;
+ hasFailedHistoryDetails: boolean;
+ retryingHistoryJob: boolean;
+ currentSiteUrl: string;
+ onClose: () => void;
+ onReIndex: () => void;
+ onCancelJob: () => void;
+ onToggleExpand: ( siteUrl: string ) => void;
+ onOpenHistoryDetails: ( job: JobStatus ) => void;
+ onPageChange: ( page: number ) => void;
+ onHistoryDetailsBack: () => void;
+ onRetryHistoryJob: () => void;
+}
+
+const getTitle = (
+ selectedHistoryJob: JobStatus | null,
+ reindexing: boolean
+): string => {
+ if ( selectedHistoryJob ) {
+ return __( 'Sync Job Details', 'onesearch' );
+ }
+ if ( reindexing ) {
+ return __( 'Indexing Progress', 'onesearch' );
+ }
+ return __( 'Re-index saved entities', 'onesearch' );
+};
+
+const ReindexModal = ( {
+ reindexing,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ selectedHistoryJob,
+ historyDetails,
+ historyDetailsLoading,
+ hasFailedHistoryDetails,
+ retryingHistoryJob,
+ currentSiteUrl,
+ onClose,
+ onReIndex,
+ onCancelJob,
+ onToggleExpand,
+ onOpenHistoryDetails,
+ onPageChange,
+ onHistoryDetailsBack,
+ onRetryHistoryJob,
+}: ReindexModalProps ) => (
+
+ { selectedHistoryJob ? (
+
+ ) : (
+
+ ) }
+
+);
+
+export default ReindexModal;
diff --git a/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx
new file mode 100644
index 0000000..8d4a1dd
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx
@@ -0,0 +1,160 @@
+/**
+ * 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;
+ onToggleExpand: ( siteUrl: string ) => void;
+ onOpenHistoryDetails: ( job: JobStatus ) => void;
+ onPageChange: ( page: number ) => void;
+}
+
+const ReindexModalContent = ( {
+ reindexing,
+ siteStates,
+ cancelling,
+ history,
+ historyPage,
+ historyTotalPages,
+ onReIndex,
+ onCancelJob,
+ onToggleExpand,
+ onOpenHistoryDetails,
+ 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 0000000..536268b
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/SiteRow.tsx
@@ -0,0 +1,109 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { __experimentalText as Text } from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import type { SiteJobState } from './types';
+import BatchRow from './BatchRow';
+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;
+ onToggleExpand: ( siteUrl: string ) => void;
+}
+
+const SiteRow = ( { state, onToggleExpand }: SiteRowProps ) => {
+ const hasChildren = state.children.length > 0;
+ const canExpand =
+ hasChildren ||
+ ( !! state.reindexJob &&
+ state.reindexJob.status !== 'completed' &&
+ state.reindexJob.status !== 'cancelled' );
+
+ const handleKeyDown = ( e: React.KeyboardEvent ) => {
+ if ( ( e.key === 'Enter' || e.key === ' ' ) && canExpand ) {
+ e.preventDefault();
+ onToggleExpand( state.site.site_url );
+ }
+ };
+
+ return (
+
+
+ canExpand ? onToggleExpand( state.site.site_url ) : null
+ }
+ onKeyDown={ handleKeyDown }
+ role={ canExpand ? 'button' : undefined }
+ aria-expanded={ canExpand ? state.expanded : undefined }
+ tabIndex={ canExpand ? 0 : undefined }
+ style={ { cursor: canExpand ? 'pointer' : 'default' } }
+ >
+
+ { canExpand && (
+
+ { state.expanded ? '▾ ' : '▸ ' }
+
+ ) }
+ { state.site.site_name }
+
+
+ { state.site.site_url }
+
+
+
+
+
+
+ { state.expanded && hasChildren && (
+
+ { state.children.map( ( child, idx ) => (
+
+ ) ) }
+
+ ) }
+
+ { state.expanded &&
+ ! hasChildren &&
+ state.reindexJob &&
+ state.reindexJob.status !== 'completed' && (
+
+ { __( 'Waiting for batch creation…', 'onesearch' ) }
+
+ ) }
+
+ );
+};
+
+export default SiteRow;
diff --git a/assets/src/components/SiteIndexableEntities/constants.ts b/assets/src/components/SiteIndexableEntities/constants.ts
new file mode 100644
index 0000000..67e7761
--- /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 0000000..e8adb1e
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/index.tsx
@@ -0,0 +1,290 @@
+/**
+ * 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,
+ selectedHistoryJob,
+ historyDetails,
+ historyDetailsLoading,
+ hasFailedHistoryDetails,
+ retryingHistoryJob,
+ handleReIndex,
+ handleCancelJob,
+ handleModalClose,
+ handleHistoryDetailsBack,
+ handleRetryHistoryJob,
+ openHistoryDetails,
+ toggleExpand,
+ 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() }
+ onToggleExpand={ toggleExpand }
+ onOpenHistoryDetails={ ( job ) =>
+ void openHistoryDetails( job )
+ }
+ onPageChange={ fetchHistory }
+ onHistoryDetailsBack={ handleHistoryDetailsBack }
+ onRetryHistoryJob={ () => void handleRetryHistoryJob() }
+ />
+ ) }
+ >
+ );
+};
+
+export default SiteIndexableEntities;
diff --git a/assets/src/components/SiteIndexableEntities/types.ts b/assets/src/components/SiteIndexableEntities/types.ts
new file mode 100644
index 0000000..ead7b4b
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/types.ts
@@ -0,0 +1,74 @@
+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;
+ batch_count?: number;
+}
+
+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;
+ 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[];
+ expanded: boolean;
+}
diff --git a/assets/src/components/SiteIndexableEntities/useReindexJob.ts b/assets/src/components/SiteIndexableEntities/useReindexJob.ts
new file mode 100644
index 0000000..446cd7f
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/useReindexJob.ts
@@ -0,0 +1,621 @@
+/**
+ * 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;
+}
+
+export interface UseReindexJobReturn {
+ reindexing: boolean;
+ showReindexingModal: boolean;
+ setShowReindexingModal: React.Dispatch< React.SetStateAction< boolean > >;
+ siteStates: SiteJobState[];
+ cancelling: boolean;
+ history: JobStatus[];
+ historyPage: number;
+ historyTotalPages: number;
+ selectedHistoryJob: JobStatus | null;
+ historyDetails: SiteJobState[];
+ historyDetailsLoading: boolean;
+ hasFailedHistoryDetails: boolean;
+ retryingHistoryJob: boolean;
+ handleReIndex: () => Promise< boolean >;
+ handleCancelJob: () => Promise< void >;
+ handleModalClose: () => void;
+ handleHistoryDetailsBack: () => void;
+ handleRetryHistoryJob: () => Promise< void >;
+ openHistoryDetails: ( job: JobStatus ) => Promise< void >;
+ toggleExpand: ( siteUrl: string ) => 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 [ selectedHistoryJob, setSelectedHistoryJob ] =
+ useState< JobStatus | null >( null );
+ const [ historyDetails, setHistoryDetails ] = useState< SiteJobState[] >(
+ []
+ );
+ const [ historyDetailsLoading, setHistoryDetailsLoading ] =
+ useState( false );
+ const [ retryingHistoryJob, setRetryingHistoryJob ] = useState( false );
+ 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 selectedHistoryJobRef = useRef< JobStatus | null >( null );
+
+ 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();
+ return {
+ reindexJob: jobData.job || null,
+ 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();
+ return {
+ reindexJob: data.job || null,
+ children: data.children || [],
+ };
+ } catch {
+ return null;
+ }
+ },
+ []
+ );
+
+ const fetchHistory = useCallback( async ( page: number = 1 ) => {
+ 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 {
+ // Silently fail for history.
+ }
+ }, [] );
+
+ 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( ( prev ) => {
+ const expandedMap = new Map(
+ prev.map( ( s ) => [ s.site.site_url, s.expanded ] )
+ );
+ return updated.map( ( s ) => ( {
+ ...s,
+ expanded: expandedMap.get( s.site.site_url ) ?? s.expanded,
+ } ) );
+ } );
+
+ const allTerminal = updated.every( ( s ) => {
+ if ( ! s.reindexJob ) {
+ return true;
+ }
+ 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( () => {
+ selectedHistoryJobRef.current = selectedHistoryJob;
+ }, [ selectedHistoryJob ] );
+
+ 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: [],
+ expanded: false,
+ } )
+ );
+ 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 = currentSiteUrl ) => {
+ 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: [],
+ expanded: true,
+ };
+ }
+
+ 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 || [],
+ expanded: true,
+ };
+ } )
+ );
+
+ const openHistoryDetails = async ( job: JobStatus ) => {
+ setSelectedHistoryJob( job );
+ setHistoryDetailsLoading( true );
+ const siteDetails = await fetchHistoryDetailsForJob( job );
+ setHistoryDetails( siteDetails );
+ setHistoryDetailsLoading( false );
+ };
+
+ const hasFailedHistoryDetails = historyDetails.some(
+ ( state ) =>
+ state.reindexJob?.status === 'failed' ||
+ state.children.some( ( child ) => child.status === 'failed' )
+ );
+
+ const refreshHistoryRetryDetails = async ( job: JobStatus ) => {
+ const siteDetails = await fetchHistoryDetailsForJob( job );
+ setHistoryDetails( siteDetails );
+
+ const updatedSelectedJob =
+ siteDetails.find( ( state ) => state.site.job_id === job.id )
+ ?.reindexJob || job;
+ setSelectedHistoryJob( updatedSelectedJob );
+
+ 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 )
+ : true;
+ } );
+
+ if ( allTerminal ) {
+ stopHistoryRetryPolling();
+ setRetryingHistoryJob( false );
+ fetchHistory( historyPage );
+ }
+ };
+
+ const handleRetryHistoryJob = async () => {
+ if ( ! selectedHistoryJob || retryingHistoryJob ) {
+ return;
+ }
+
+ const jobsToRetry = historyDetails.filter(
+ ( state ) =>
+ state.site.job_id &&
+ ( state.reindexJob?.status === 'failed' ||
+ state.children.some(
+ ( child ) => child.status === 'failed'
+ ) )
+ );
+
+ if ( jobsToRetry.length === 0 ) {
+ return;
+ }
+
+ setRetryingHistoryJob( true );
+ 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 ) {
+ setRetryingHistoryJob( false );
+ setNotice( {
+ type: 'error',
+ message:
+ failedResult.message || __( 'Retry failed.', 'onesearch' ),
+ } );
+ return;
+ }
+
+ setNotice( {
+ type: 'success',
+ message: __( 'Failed batches retry scheduled.', 'onesearch' ),
+ } );
+
+ setHistoryDetails( ( prev ) =>
+ prev.map( ( state ) => {
+ if (
+ ! jobsToRetry.some(
+ ( job ) => job.site.job_id === state.site.job_id
+ )
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ reindexJob: state.reindexJob
+ ? { ...state.reindexJob, status: 'running', error: '' }
+ : state.reindexJob,
+ children: state.children.map( ( child ) =>
+ child.status === 'failed'
+ ? { ...child, status: 'pending', error: '' }
+ : child
+ ),
+ };
+ } )
+ );
+
+ setSelectedHistoryJob( {
+ ...selectedHistoryJob,
+ status: 'running',
+ error: '',
+ children_failed: 0,
+ } );
+
+ stopHistoryRetryPolling();
+ historyRetryIntervalRef.current = setInterval( () => {
+ void refreshHistoryRetryDetails(
+ selectedHistoryJobRef.current ?? selectedHistoryJob
+ );
+ }, 2000 );
+ void refreshHistoryRetryDetails( selectedHistoryJob );
+ };
+
+ const toggleExpand = ( siteUrl: string ) => {
+ const normalized = withTrailingSlash( siteUrl );
+ setSiteStates( ( prev ) =>
+ prev.map( ( s ) =>
+ withTrailingSlash( s.site.site_url ) === normalized
+ ? { ...s, expanded: ! s.expanded }
+ : s
+ )
+ );
+ };
+
+ 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();
+ if ( data.success && data.jobs && data.jobs.length > 0 ) {
+ const initial: SiteJobState[] = data.jobs.map( ( site ) => ( {
+ site,
+ reindexJob: null,
+ children: [],
+ expanded: false,
+ } ) );
+ 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 = () => {
+ setSelectedHistoryJob( null );
+ setHistoryDetails( [] );
+ setShowReindexingModal( false );
+ };
+
+ const handleHistoryDetailsBack = () => {
+ setSelectedHistoryJob( null );
+ setHistoryDetails( [] );
+ setHistoryDetailsLoading( 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,
+ selectedHistoryJob,
+ historyDetails,
+ historyDetailsLoading,
+ hasFailedHistoryDetails,
+ retryingHistoryJob,
+ handleReIndex,
+ handleCancelJob,
+ handleModalClose,
+ handleHistoryDetailsBack,
+ handleRetryHistoryJob,
+ openHistoryDetails,
+ toggleExpand,
+ fetchHistory,
+ };
+};
diff --git a/assets/src/components/SiteIndexableEntities/utils.ts b/assets/src/components/SiteIndexableEntities/utils.ts
new file mode 100644
index 0000000..e203eb1
--- /dev/null
+++ b/assets/src/components/SiteIndexableEntities/utils.ts
@@ -0,0 +1,90 @@
+/**
+ * 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,
+ batch_count:
+ job.children_total ||
+ job.data?.total_batches ||
+ job.progress_total ||
+ 0,
+ },
+ ];
+};
diff --git a/assets/src/css/admin.scss b/assets/src/css/admin.scss
index 9a12f9a..10dccef 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,489 @@ body:has(.onesearch-setup-overlay) .onesearch-search-content {
.onesearch-button-group {
margin-right: 8px;
}
+
+/* Job Progress */
+.onesearch-job-progress {
+ margin: 16px 0;
+}
+
+.onesearch-job-progress-bar {
+ width: 100%;
+ height: 12px;
+ background: #dcdcde;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.onesearch-job-progress-fill {
+ height: 100%;
+ background: #007cba;
+ border-radius: 6px;
+ transition: width 0.3s ease;
+ min-width: 0;
+}
+
+.onesearch-job-progress-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 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;
+}
+
+.onesearch-job-error {
+ margin: 8px 0 0;
+ padding: 8px 12px;
+ background: #fce4e4;
+ border-left: 4px solid #e11d1d;
+ color: #a11d1d;
+ font-size: 13px;
+ border-radius: 2px;
+}
+
+/* ─── Hierarchical Job Panel ─── */
+
+.onesearch-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 24px;
+}
+
+.onesearch-modal-actions .onesearch-btn-cancel,
+.onesearch-job-panel-header .onesearch-btn-cancel {
+ background: #d63638 !important;
+ border-color: #d63638 !important;
+ color: #fff !important;
+ box-shadow: none !important;
+}
+
+.onesearch-modal-actions .onesearch-btn-cancel:hover,
+.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;
+}
+
+@keyframes onesearch-spin {
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.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-header[role="button"]:hover {
+ background: #f0f6fc;
+}
+
+.onesearch-job-site-name {
+ font-weight: 600;
+ font-size: 14px;
+ min-width: 140px;
+}
+
+.onesearch-job-expand-icon {
+ display: inline-block;
+ width: 14px;
+ font-size: 12px;
+}
+
+.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;
+}
+
+/* Batch list */
+.onesearch-batch-list {
+ padding: 4px 24px 8px 48px;
+ background: #fafafa;
+}
+
+.onesearch-batch-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 0;
+ font-size: 13px;
+}
+
+.onesearch-batch-label {
+ font-family: monospace;
+ font-size: 12px;
+ color: #666;
+ min-width: 60px;
+}
+
+.onesearch-batch-error {
+ color: #e11d1d;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 200px;
+}
+
+/* Small status badges */
+.onesearch-job-status--small {
+ font-size: 10px;
+ padding: 1px 6px;
+}
+
+/* Small progress bar */
+.onesearch-job-progress-bar--small {
+ height: 6px;
+ min-width: 60px;
+ flex: 0 0 60px;
+ border-radius: 3px;
+}
+
+/* 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;
+ cursor: pointer;
+ 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;
+ }
+
+ tbody tr.onesearch-history-table-row {
+ cursor: pointer;
+ }
+
+ tbody tr.onesearch-history-table-row:hover,
+ tbody tr.onesearch-history-table-row:focus {
+ background: #f0f6fc;
+ outline: none;
+ }
+
+ td:first-child,
+ th:first-child {
+ padding-left: 0;
+ }
+
+ td:last-child,
+ th:last-child {
+ padding-right: 0;
+ }
+}
+
+.onesearch-history-details-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+
+ > div {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ padding: 10px 12px;
+ background: #f8f9fa;
+ }
+
+ span {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: #646970;
+ margin-bottom: 4px;
+ }
+
+ strong {
+ display: block;
+ font-size: 18px;
+ line-height: 1.3;
+ }
+}
+
+.onesearch-history-details-view {
+ display: grid;
+ gap: 16px;
+}
+
+.onesearch-history-details-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.onesearch-history-details-back.components-button {
+ justify-self: start;
+ min-height: 30px;
+ padding-left: 0;
+}
+
+.onesearch-history-details-content {
+ min-height: 220px;
+ transition: opacity 0.18s ease;
+}
+
+.onesearch-history-details-content--loading {
+ display: grid;
+ place-items: center;
+}
+
+.onesearch-history-details-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 220px;
+}
+
+.onesearch-history-details-sites {
+ display: grid;
+ gap: 12px;
+ animation: onesearch-details-in 0.18s ease-out;
+}
+
+@keyframes onesearch-details-in {
+
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.onesearch-history-details-site {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.onesearch-history-details-site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 16px;
+ background: #fff;
+
+ strong {
+ display: block;
+ font-size: 14px;
+ margin-bottom: 2px;
+ }
+}
+
+.onesearch-history-details-site-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ white-space: nowrap;
+}
+
+.onesearch-history-details-empty {
+ display: block;
+ padding: 10px 16px 14px;
+ background: #fafafa;
+}
+
+.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/assets/src/types/global.d.ts b/assets/src/types/global.d.ts
index b1ce5f3..a7b6939 100644
--- a/assets/src/types/global.d.ts
+++ b/assets/src/types/global.d.ts
@@ -31,6 +31,8 @@ export interface OneSearchOnboarding {
setup_url: string;
}
+export type StatusUIType = 'badge' | 'text' | 'icon';
+
declare global {
interface Window {
OneSearchSettings: OneSearchSettings;
diff --git a/composer.lock b/composer.lock
index 2da6e8a..98c8adf 100644
--- a/composer.lock
+++ b/composer.lock
@@ -233,6 +233,49 @@
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+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": [
@@ -3399,5 +3442,5 @@
"platform-overrides": {
"php": "8.2"
},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/inc/Main.php b/inc/Main.php
index b43f59b..83b23d9 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 0000000..e28ec6c
--- /dev/null
+++ b/inc/Modules/Jobs/Abstract_Job.php
@@ -0,0 +1,741 @@
+
+ */
+ 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.
+ * When all children complete, if this is > 0 the parent is marked FAILED.
+ *
+ * @var int
+ */
+ protected int $children_failed = 0;
+
+ /**
+ * Number of child jobs that were cancelled.
+ * When all children finish, if this is > 0 the parent is marked CANCELLED.
+ *
+ * @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 number of child jobs that have finished (completed or failed).
+ *
+ * @return int Number of finished children.
+ */
+ public function get_children_completed(): int {
+ return $this->children_completed;
+ }
+
+ /**
+ * Get the number of child jobs that have failed.
+ *
+ * @return int Number of failed children.
+ */
+ public function get_children_failed(): int {
+ return $this->children_failed;
+ }
+
+ /**
+ * Get the number of child jobs that were cancelled.
+ *
+ * @return int Number of cancelled children.
+ */
+ public function get_children_cancelled(): int {
+ return $this->children_cancelled;
+ }
+
+ /**
+ * Get the Unix timestamp when the job was created.
+ *
+ * @return int Unix timestamp.
+ */
+ public function get_created_at(): int {
+ return $this->created_at;
+ }
+
+ /**
+ * Get the Unix timestamp when the job was last updated.
+ *
+ * @return int Unix timestamp.
+ */
+ public function get_updated_at(): int {
+ return $this->updated_at;
+ }
+
+ /**
+ * Get the Unix timestamp when the job reached a terminal state.
+ *
+ * @return int|null Unix timestamp, or null if the job is still active.
+ */
+ public function get_finished_at(): ?int {
+ return $this->finished_at;
+ }
+
+ /**
+ * Get the job's data payload.
+ *
+ * @return array The data payload.
+ */
+ public function get_data(): array {
+ return $this->data;
+ }
+
+ /**
+ * Set the job ID. Used when reconstructing a job from storage.
+ *
+ * @param string $id The job identifier.
+ * @return $this
+ */
+ public function set_id( string $id ): self {
+ $this->id = $id;
+ return $this;
+ }
+
+ /**
+ * Set the lifecycle status and touch the updated_at timestamp.
+ *
+ * Called by Job_Scheduler during scheduling (→ pending) and retry
+ * setup (→ pending). For the running state, Job_Scheduler calls
+ * mark_running() instead. For completed/failed states, use
+ * mark_completed()/fail() instead. Cancellation operates directly
+ * on the stored array rather than calling this method.
+ *
+ * @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;
+ }
+
+ /**
+ * Increment the count of completed child jobs.
+ *
+ * @return $this
+ */
+ public function increment_children_completed(): self {
+ ++$this->children_completed;
+ $this->updated_at = time();
+ return $this;
+ }
+
+ /**
+ * Increment the count of failed child jobs.
+ *
+ * @return $this
+ */
+ public function increment_children_failed(): self {
+ ++$this->children_failed;
+ $this->updated_at = time();
+ 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;
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * Mark the job as CANCELLED.
+ */
+ public function mark_cancelled(): void {
+ $this->status = self::STATUS_CANCELLED;
+ $this->cancelled_at = time();
+ $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 to an associative array for wp_options 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 0000000..e6b9265
--- /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 0000000..68189ac
--- /dev/null
+++ b/inc/Modules/Jobs/Reindex_Job.php
@@ -0,0 +1,173 @@
+data['post_types'] ?? [];
+
+ if ( empty( $post_types ) ) {
+ throw new \InvalidArgumentException( 'Reindex_Job requires post_types in data payload.' );
+ }
+
+ $allowed_statuses = \OneSearch\Modules\Search\Post_Record::get_allowed_statuses( $post_types );
+
+ // Clear existing records before reindexing.
+ $indexer = new Index();
+ $indexer->delete_by(
+ [
+ 'filters' => sprintf( 'site_url:"%s"', Utils::normalize_url( get_site_url() ) ),
+ ]
+ );
+
+ $batch_size = $this->data['batch_size'] ?? 100;
+ $batch_size = max( 1, min( $batch_size, 500 ) );
+ $scheduler = new Job_Scheduler();
+ $group = 'reindex_' . $this->get_id();
+ $scheduled = 0;
+ $page = 1;
+
+ while ( true ) {
+ $query = new \WP_Query(
+ [
+ 'post_type' => $post_types,
+ 'post_status' => $allowed_statuses,
+ 'posts_per_page' => $batch_size,
+ 'paged' => $page,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ ]
+ );
+
+ $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 );
+
+ try {
+ $scheduler->schedule( $child );
+ $this->add_child_id( $child->get_id() );
+ ++$scheduled;
+ } catch ( \Throwable $e ) {
+ // If scheduling fails, cancel all already-scheduled children.
+ 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() ) )
+ );
+ }
+
+ if ( count( $posts ) < $batch_size ) {
+ break;
+ }
+
+ ++$page;
+ }
+
+ if ( 0 === $scheduled ) {
+ $this->mark_completed();
+ return;
+ }
+
+ $this->set_progress_total( $scheduled );
+ $this->update_progress( 0 );
+
+ // Persist the parent job with child IDs so we can track completion.
+ $scheduler->persist_job( $this );
+
+ // REST-triggered reindexes do not run in wp-admin, so Action Scheduler's
+ // normal shutdown-based async dispatch may not fire. Dispatch the async
+ // runner directly when available, and fall back to cron nudging otherwise.
+ if (
+ class_exists( '\\ActionScheduler_AsyncRequest_QueueRunner' )
+ && class_exists( '\\ActionScheduler_Store' )
+ ) {
+ $runner = new \ActionScheduler_AsyncRequest_QueueRunner( \ActionScheduler_Store::instance() );
+ $runner->maybe_dispatch();
+ return;
+ }
+
+ spawn_cron();
+ }
+}
diff --git a/inc/Modules/Jobs/Sync_Job.php b/inc/Modules/Jobs/Sync_Job.php
new file mode 100644
index 0000000..e436f66
--- /dev/null
+++ b/inc/Modules/Jobs/Sync_Job.php
@@ -0,0 +1,150 @@
+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 );
+
+ $index = new Index();
+ $post_record = new Post_Record();
+ $errors = [];
+ $batch_records = [];
+ $delete_filters = [];
+ $site_url = Utils::normalize_url( get_site_url() );
+
+ 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_filters[] = sprintf( 'site_post_id:"%s_%d"', $site_url, $post_id );
+ } else {
+ $records = $post_record->to_records( $post );
+
+ if ( ! empty( $records ) ) {
+ foreach ( $records as $record ) {
+ $batch_records[] = $record;
+ }
+ }
+ }
+
+ $this->update_progress( $i + 1 );
+ }
+
+ // Batch all deletions into a single Algolia call.
+ if ( ! empty( $delete_filters ) ) {
+ $result = $index->delete_by(
+ [
+ 'filters' => implode( ' OR ', $delete_filters ),
+ ]
+ );
+ if ( is_wp_error( $result ) ) {
+ $errors[] = sprintf(
+ 'Failed to delete %d posts from Algolia: %s',
+ count( $delete_filters ),
+ esc_html( $result->get_error_message() )
+ );
+ }
+ }
+
+ if ( ! empty( $batch_records ) ) {
+ $result = $index->save_records( $batch_records );
+ if ( is_wp_error( $result ) ) {
+ $errors[] = sprintf(
+ 'Failed to save %d Algolia records for batch: %s',
+ count( $batch_records ),
+ esc_html( $result->get_error_message() )
+ );
+ }
+ }
+
+ 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 d346d79..189d8c4 100644
--- a/inc/Modules/Rest/Search_Controller.php
+++ b/inc/Modules/Rest/Search_Controller.php
@@ -9,7 +9,8 @@
namespace OneSearch\Modules\Rest;
-use OneSearch\Modules\Search\Index;
+use OneSearch\Modules\Jobs\Reindex_Job;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Utils;
@@ -20,6 +21,25 @@
* 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; // 15 minutes
+
/**
* {@inheritDoc}
*/
@@ -77,7 +97,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;
},
],
],
@@ -96,6 +119,17 @@ public function register_routes(): void {
'permission_callback' => [ $this, 'check_api_permissions' ],
]
);
+
+ // 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' ],
+ ]
+ );
}
/**
@@ -193,60 +227,293 @@ 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.
*/
public function reindex(): \WP_REST_Response|\WP_Error {
+ // Guard: prevent starting a new reindex while one is already running.
+ // Use an option-based mutex (add_option is atomic in MySQL) to prevent
+ // race conditions between concurrent requests.
+ $lock_key = self::REINDEX_STATE_TRANSIENT . '_lock';
+ if ( ! add_option( $lock_key, '1', '', false ) ) {
+ 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 ]
+ );
+ }
+
+ // Auto-expire the lock after 5 minutes in case the process crashes
+ // before cleanup. wp_schedule_single_action is preferred but not
+ // guaranteed to be available during plugin init, so we use a transient
+ // as a safety net.
+ set_transient( $lock_key . '_expiry', '1', 5 * MINUTE_IN_SECONDS );
+
+ $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 ( is_array( $child_errors ) ) {
- $errors = array_merge( $errors, $child_errors );
+ if ( isset( $child_result['jobs'] ) ) {
+ $jobs = array_merge( $jobs, $child_result['jobs'] );
+ }
+
+ if ( isset( $child_result['errors'] ) ) {
+ $errors = array_merge( $errors, $child_result['errors'] );
}
}
$post_types = $this->get_post_types_to_index();
if ( is_wp_error( $post_types ) ) {
+ $this->release_reindex_lock( $lock_key );
return $post_types;
}
- // Index the current site.
- $indexed = ( new Index() )->index_all_posts( $post_types );
+ // Create and execute the Reindex_Job synchronously.
+ // The parent job runs in this request (resolve posts, clear index,
+ // schedule child Sync_Jobs). Only the child Sync_Jobs run async via AS.
+ $job = new Reindex_Job();
+ $job->set_data(
+ [
+ 'post_types' => $post_types,
+ 'batch_size' => 100,
+ ]
+ );
+ $job->set_max_retries( 2 );
+ $job->set_retry_delay_seconds( 60 );
+
+ $scheduler = new Job_Scheduler();
+ $job_id = $job->get_id();
- if ( is_wp_error( $indexed ) ) {
+ try {
+ $job->mark_running();
+ $scheduler->persist_job( $job );
+ $job->handle();
+
+ // Only re-persist when no children were scheduled.
+ // When children exist, notify_parent() owns the parent lifecycle;
+ // re-persisting RUNNING here would clobber a terminal status that a
+ // fast child already wrote during async processing.
+ if ( ! $job->has_pending_children() ) {
+ if ( ! $job->is_finished() ) {
+ $job->mark_completed();
+ }
+ $scheduler->persist_job( $job );
+ }
+ } 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(),
];
}
+ // Add local site to the jobs list.
+ $local_site_name = Settings::is_governing_site() ? __( 'Governing Site', 'onesearch' ) : get_bloginfo( 'name' );
+ $local_site_url = get_site_url();
+ $local_batches = count( $job->get_child_ids() );
+
+ if ( $job_id ) {
+ array_unshift(
+ $jobs,
+ [
+ 'site_name' => $local_site_name,
+ 'site_url' => $local_site_url,
+ 'job_id' => $job_id,
+ 'batch_count' => $local_batches,
+ ]
+ );
+ }
+
+ // Compute combined total across all sites and store in the
+ // governing site's job data so the history table can display it.
+ if ( Settings::is_governing_site() ) {
+ $total_batches = $local_batches;
+ $child_batch_counts = $child_result['batch_counts'] ?? [];
+ foreach ( $child_batch_counts as $count ) {
+ $total_batches += $count;
+ }
+ // Re-read the freshest stored state so we never downgrade a terminal
+ // status (e.g. COMPLETED) that notify_parent() may have already written.
+ $latest = $scheduler->get_status( $job_id );
+ $merge_job = $latest ? Reindex_Job::from_array( $latest ) : $job;
+ $merge_job->set_data(
+ array_merge(
+ $merge_job->get_data() ?: [],
+ [
+ 'total_batches' => $total_batches,
+ 'sites' => $jobs,
+ ]
+ )
+ );
+ $scheduler->persist_job( $merge_job );
+ }
+
+ // 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' ),
+ : implode( "\n", array_column( $errors, 'message' ) ),
+ 'job_id' => $job_id,
+ 'batch_count' => count( $job->get_child_ids() ),
+ '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,
+ ]
+ );
+ }
+
+ /**
+ * Helper to get the active reindex state, or null if none/broken.
+ *
+ * Validates that the stored job IDs still exist in storage
+ * and haven't reached terminal status. If they're terminal,
+ * the state is auto-cleaned up.
+ *
+ * @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 );
+
+ // Missing row = not blocking; live jobs are always persisted before
+ // the reindex lock is released, so absence means the job is gone.
+ if ( ! $job_status ) {
+ continue;
+ }
+
+ // Terminal status = not blocking.
+ if ( in_array( $job_status['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ continue;
+ }
+
+ // Pending/running but no progress for > 15 min = abandoned.
+ // updated_at is bumped by every notify_parent() call, so stale
+ // updated_at reliably means the job is wedged or the 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' );
+ delete_transient( self::REINDEX_STATE_TRANSIENT . '_lock_expiry' );
+ }
+
+ /**
+ * 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 );
+ delete_transient( $lock_key . '_expiry' );
+ }
+
/**
* Validate the Algolia Key before saving.
*
@@ -309,12 +576,14 @@ private function get_post_types_to_index(): array|\WP_Error {
/**
* 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, batch_counts: array }
*/
- private function reindex_child_sites() {
+ private function reindex_child_sites(): array {
$shared_sites = Settings::get_shared_sites();
- $errors = [];
+ $child_jobs = [];
+ $errors = [];
+ $batch_counts = [];
// Build the requests array for each site.
foreach ( $shared_sites as $site_data ) {
if ( empty( $site_data['url'] ) || empty( $site_data['api_key'] ) ) {
@@ -374,8 +643,23 @@ private function reindex_child_sites() {
];
continue;
}
+
+ // Capture the child job ID from the child's response.
+ 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 ),
+ ];
+ $batch_counts[] = (int) ( $response_data['batch_count'] ?? 0 );
+ }
}
- return $errors ?: true;
+ return [
+ 'jobs' => $child_jobs,
+ 'errors' => $errors,
+ 'batch_counts' => $batch_counts,
+ ];
}
}
diff --git a/inc/Modules/Scheduler/Bootstrap.php b/inc/Modules/Scheduler/Bootstrap.php
new file mode 100644
index 0000000..8faf258
--- /dev/null
+++ b/inc/Modules/Scheduler/Bootstrap.php
@@ -0,0 +1,56 @@
+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 0000000..1e4e299
--- /dev/null
+++ b/inc/Modules/Scheduler/Job_REST_Controller.php
@@ -0,0 +1,1287 @@
+ WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'get_jobs' ],
+ 'permission_callback' => [ $this, 'check_job_read_permissions' ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/jobs/history',
+ [
+ [
+ 'methods' => 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/reindex',
+ [
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'create_reindex' ],
+ 'permission_callback' => [ $this, 'check_api_permissions' ],
+ 'args' => [
+ 'post_types' => [
+ 'required' => false,
+ 'type' => 'array',
+ 'default' => [],
+ 'sanitize_callback' => static function ( $value ) {
+ return is_array( $value ) ? array_map( 'sanitize_text_field', $value ) : [];
+ },
+ ],
+ 'batch_size' => [
+ 'required' => false,
+ 'type' => 'integer',
+ 'default' => 100,
+ 'sanitize_callback' => 'absint',
+ ],
+ ],
+ ],
+ ]
+ );
+
+ 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 all active/recent jobs.
+ *
+ * @param \WP_REST_Request> $request Request.
+ */
+ public function get_jobs( $request ): WP_REST_Response {
+ $scheduler = new Job_Scheduler();
+ $group = $request->get_param( 'group' );
+
+ if ( $group ) {
+ $jobs = $scheduler->get_jobs_by_group( sanitize_text_field( $group ) );
+ } else {
+ $repository = new Job_Repository();
+ $active_ids = $scheduler->get_active_job_ids();
+ $terminal = $repository->get_terminal_jobs( 1, 50, false );
+ $jobs = [];
+
+ foreach ( $active_ids as $job_id ) {
+ $status = $scheduler->get_status( $job_id );
+ if ( $status ) {
+ $jobs[] = $this->maybe_finalize_remote_job( $status, $scheduler );
+ }
+ }
+
+ foreach ( $terminal as $row ) {
+ $jobs[] = $row;
+ }
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'jobs' => $jobs,
+ ]
+ );
+ }
+
+ /**
+ * 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 ]
+ );
+ }
+
+ $status = $this->maybe_finalize_remote_job( $status, $scheduler );
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'job' => $status,
+ ]
+ );
+ }
+
+ /**
+ * Finalize a parent job that is waiting on remote site statuses.
+ *
+ * When a governing-site reindex has local children all done but remote
+ * child sites still in flight, notify_parent() stores the parent as
+ * RUNNING with _needs_remote_finalize. This method re-checks remote
+ * statuses and transitions the parent to its true terminal state so
+ * that the progress UI can dismiss.
+ *
+ * @param array $job Job status array.
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler Scheduler instance.
+ * @return array Possibly updated job status.
+ */
+ private function maybe_finalize_remote_job( array $job, Job_Scheduler $scheduler ): array {
+ if ( Abstract_Job::STATUS_RUNNING !== ( $job['status'] ?? '' ) ) {
+ return $job;
+ }
+
+ $needs_finalize = $job['data']['_needs_remote_finalize'] ?? false;
+ if ( ! $needs_finalize || ! Settings::is_governing_site() ) {
+ return $job;
+ }
+
+ $remote_sites = $job['data']['sites'] ?? [];
+ if ( ! is_array( $remote_sites ) || count( $remote_sites ) <= 1 ) {
+ return $job;
+ }
+
+ $r = $scheduler->check_remote_job_statuses( $remote_sites );
+ if ( $r['running'] > 0 ) {
+ return $job;
+ }
+
+ if ( $r['cancelled'] > 0 ) {
+ $job['status'] = Abstract_Job::STATUS_CANCELLED;
+ } elseif ( $r['failed'] > 0 ) {
+ $job['status'] = Abstract_Job::STATUS_FAILED;
+ $job['error'] = sprintf(
+ /* translators: 1: failed remote sites */
+ __( '%1$d remote site(s) failed', 'onesearch' ),
+ $r['failed']
+ );
+ } else {
+ $job['status'] = Abstract_Job::STATUS_COMPLETED;
+ }
+
+ $job['children_failed'] = max(
+ (int) ( $job['children_failed'] ?? 0 ),
+ $r['failed'] + $r['cancelled']
+ );
+ $job['progress'] = $job['progress_total'] ?? 0;
+ $job['finished_at'] = time();
+ $job['updated_at'] = time();
+
+ $repository = new Job_Repository();
+ $repository->upsert( $job );
+
+ $parent_key = Job_Scheduler::OPTION_PREFIX . ( $job['id'] ?? '' );
+ delete_transient( $parent_key );
+
+ Search_Controller::clear_reindex_state();
+
+ return $job;
+ }
+
+ /**
+ * Create and schedule a Reindex_Job.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function create_reindex( $request ) {
+ $post_types = $request->get_param( 'post_types' ) ?: [];
+ // Falls back to the route's registered default when absint() yields 0.
+ $batch_size = $request->get_param( 'batch_size' ) ?: 100;
+
+ // If no post_types specified, resolve from settings.
+ if ( empty( $post_types ) ) {
+ $post_types = $this->get_post_types_to_index();
+
+ if ( empty( $post_types ) ) {
+ return new \WP_Error(
+ 'onesearch_no_post_types',
+ __( 'No post types configured for indexing.', 'onesearch' ),
+ [ 'status' => 400 ]
+ );
+ }
+ }
+
+ $job = new Reindex_Job();
+ $job->set_data(
+ [
+ 'post_types' => $post_types,
+ 'batch_size' => $batch_size,
+ ]
+ );
+ $job->set_max_retries( 2 );
+ $job->set_retry_delay_seconds( 60 );
+
+ $scheduler = new Job_Scheduler();
+
+ try {
+ $scheduler->schedule( $job );
+ } catch ( \Throwable $e ) {
+ return new \WP_Error(
+ 'onesearch_schedule_failed',
+ $e->getMessage(),
+ [ 'status' => 500 ]
+ );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Re-indexing scheduled successfully.', 'onesearch' ),
+ 'job_id' => $job->get_id(),
+ ]
+ );
+ }
+
+ /**
+ * 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 );
+
+ // If this is a parent Reindex_Job being cancelled, clear the
+ // reindex state so the frontend knows it can start a new one.
+ 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 Sync_Job batch using the same job ID.
+ *
+ * @param \WP_REST_Request> $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function retry_job( $request ) {
+ global $wpdb;
+
+ $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 );
+ }
+
+ // Unschedule any remaining retry actions for this job.
+ $group = 'onesearch_' . ( $status['group'] ?? 'default' );
+ $repository = new Job_Repository();
+ $action_rec = $repository->get_action( $job_id );
+ if ( $action_rec ) {
+ as_unschedule_action( Job_Scheduler::HOOK, $action_rec['args'], $group );
+ }
+
+ // Reconstruct, reset, and reschedule the same job.
+ $job = Sync_Job::from_array( $status );
+ $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 ]
+ );
+ }
+
+ // If this child had a parent, reset parent tracking.
+ $parent_id = $status['parent_id'] ?? '';
+ if ( $parent_id ) {
+ $this->reset_parent_for_retry( $scheduler, $wpdb, $parent_id );
+ }
+
+ return new WP_REST_Response(
+ [
+ 'success' => true,
+ 'message' => __( 'Batch 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;
+ $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'] ?? '' ) ) {
+ $failed_children[] = $child_status;
+ $terminal_failed_ids[] = $child_id;
+ continue;
+ }
+
+ if ( in_array( $child_status['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ ++$completed_children;
+ }
+ }
+
+ if ( empty( $failed_children ) ) {
+ // Parent is stuck in a failed state but all children have completed —
+ // this is a stale status caused by a race between notify_parent() and
+ // cancel()/timeout. Reconcile the parent to completed and return success.
+ if ( $completed_children > 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( $scheduler, $parent_data, $completed_children );
+
+ $retried = 0;
+ $repository = new Job_Repository();
+ foreach ( $failed_children as $child_status ) {
+ $child_id = (string) ( $child_status['id'] ?? '' );
+ if ( '' === $child_id ) {
+ continue;
+ }
+
+ $group = 'onesearch_' . ( $child_status['group'] ?? 'default' );
+ $action_rec = $repository->get_action( $child_id );
+ if ( $action_rec ) {
+ as_unschedule_action( Job_Scheduler::HOOK, $action_rec['args'], $group );
+ }
+
+ $child = Sync_Job::from_array( $child_status );
+ $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 \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param array $parent_data Stored parent job data.
+ * @param int $completed_children Completed child count to preserve.
+ */
+ private function prepare_parent_for_child_retries( Job_Scheduler $scheduler, array $parent_data, int $completed_children ): void { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ $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['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 );
+ }
+
+ /**
+ * Decrement the parent's children_done counter and set back to RUNNING if terminal.
+ *
+ * @param \OneSearch\Modules\Scheduler\Job_Scheduler $scheduler The job scheduler instance.
+ * @param \wpdb $wpdb WordPress database abstraction.
+ * @param string $parent_id The parent job ID.
+ */
+ private function reset_parent_for_retry( Job_Scheduler $scheduler, $wpdb, string $parent_id ): void { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ $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 parent was terminal, reset to RUNNING.
+ if ( in_array( $parent_data['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ $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 );
+ }
+ }
+
+ /**
+ * 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'] ?? '';
+
+ // Count only successfully completed children for progress display.
+ if ( Abstract_Job::STATUS_COMPLETED === $status ) {
+ ++$children_completed;
+ }
+
+ // Count all terminal children for status determination.
+ 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 DB-stored aggregate totals before overwriting with local counts.
+ // When _remote_aggregated is set these values 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
+ );
+
+ // Aggregate remote job progress for governing sites.
+ // Use batch_count from stored sites array for totals (reliable, no API call),
+ // and fetch completed counts from remote APIs.
+ if ( $has_remote ) {
+ $current_site = \OneSearch\Utils::normalize_url( get_site_url() );
+
+ // Calculate remote totals from stored batch_count (always accurate).
+ $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'] ) ) {
+ // Already finalized — use the DB-stored aggregate (includes remote).
+ // Cannot read from $job['children_completed']/['children_total'] here
+ // because those were just overwritten with local-only counts above.
+ $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 {
+ // Fetch remote completed counts via API (cached after finalization).
+ $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 '';
+ };
+
+ $derived_status = $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
+ );
+ }
+ }
+
+ // Skip all remote finalization work once the row has been cached.
+ if ( $has_remote && empty( $job['data']['_remote_aggregated'] ) ) {
+ $resolved = true;
+ $needs_finalize = ! empty( $job['data']['_needs_remote_finalize'] );
+
+ if ( $needs_finalize
+ && Abstract_Job::STATUS_RUNNING === ( $job['status'] ?? '' )
+ && 0 === $local_failed && 0 === $local_cancelled
+ ) {
+ $r = $scheduler->check_remote_job_statuses( $remote_sites );
+
+ if ( $r['running'] > 0 ) {
+ // Remote sites still in flight — leave uncached so the next
+ // history load re-polls.
+ $resolved = false;
+ } else {
+ if ( $r['cancelled'] > 0 ) {
+ $job['status'] = Abstract_Job::STATUS_CANCELLED;
+ } elseif ( $r['failed'] > 0 ) {
+ $job['status'] = Abstract_Job::STATUS_FAILED;
+ $job['error'] = sprintf(
+ /* translators: 1: failed remote sites */
+ __( '%1$d remote site(s) failed', 'onesearch' ),
+ $r['failed']
+ );
+ } else {
+ $job['status'] = Abstract_Job::STATUS_COMPLETED;
+ }
+
+ $job['children_failed'] = max(
+ (int) ( $job['children_failed'] ?? 0 ),
+ $r['failed'] + $r['cancelled']
+ );
+ }
+ }
+
+ // Once the job is in a stable terminal state, persist the aggregated
+ // totals and mark the row so future history loads skip remote polling.
+ if ( $resolved && in_array( $job['status'] ?? '', Job_Scheduler::TERMINAL_STATUSES, true ) ) {
+ unset( $job['data']['_needs_remote_finalize'] );
+ $job['data']['_remote_aggregated'] = true;
+ $job['finished_at'] = $job['finished_at'] ?? time();
+ $job['updated_at'] = time();
+
+ $repository = new Job_Repository();
+ $repository->upsert( $job );
+ }
+ }
+
+ return $job;
+ }
+
+ /**
+ * Get the post types to index for the current site.
+ *
+ * @return string[]
+ */
+ private function get_post_types_to_index(): array {
+ if ( \OneSearch\Modules\Settings\Settings::is_governing_site() ) {
+ $opt = \OneSearch\Modules\Search\Settings::get_indexable_entities();
+ $site_url = \OneSearch\Utils::normalize_url( get_site_url() );
+ $post_types = $opt['entities'][ $site_url ] ?? null;
+
+ return is_array( $post_types ) ? array_values( array_unique( array_map( 'strval', $post_types ) ) ) : [];
+ }
+
+ $parent_url = \OneSearch\Modules\Settings\Settings::get_parent_site_url();
+ if ( empty( $parent_url ) ) {
+ return [];
+ }
+
+ $brand_config = \OneSearch\Modules\Rest\Governing_Data_Handler::get_brand_config();
+ if ( is_wp_error( $brand_config ) ) {
+ return [];
+ }
+
+ return $brand_config['indexable_entities'] ?? [];
+ }
+
+ /**
+ * 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 );
+
+ // Fetch job status.
+ $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
+ ]
+ );
+
+ // Fetch children.
+ $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 = [];
+
+ // Parse job response.
+ 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;
+ }
+ }
+
+ // Parse children response.
+ 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.
+ *
+ * Used internally to propagate reindex cancellations from the governing
+ * site down to child sites, as well as by the cancel_remote_job endpoint.
+ *
+ * @param string $site_url The child site URL.
+ * @param string $job_id The job ID to cancel on the child site.
+ * @return bool True if the cancel was accepted by the child site.
+ */
+ private function send_remote_cancel( string $site_url, string $job_id ): bool {
+ $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 the remote site is unreachable the child's jobs will time out.
+ return ! is_wp_error( $response );
+ }
+
+ /**
+ * 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.
+ *
+ * @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 ( ! $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 0000000..b97f72e
--- /dev/null
+++ b/inc/Modules/Scheduler/Job_Scheduler.php
@@ -0,0 +1,1002 @@
+repository = new Job_Repository();
+ $this->register_hooks();
+ }
+
+ /**
+ * Set a callback to be invoked on every job status/progress change.
+ *
+ * @param callable $callback Signature: function(string $jobId, int $progress, string $status): void
+ * @return $this
+ */
+ public function set_progress_callback( callable $callback ): self {
+ $this->progress_callback = $callback;
+ return $this;
+ }
+
+ /**
+ * Register WordPress action hooks for job execution and AS lifecycle events.
+ *
+ * Protected by static $hooks_registered to prevent double-registration.
+ */
+ 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( 'action_scheduler_failed_execution', [ $this, 'on_action_failed' ], 10, 3 );
+ add_action( 'action_scheduler_failed_schedule_new_action', [ $this, 'on_schedule_failed' ], 10, 3 );
+ add_action( 'action_scheduler_begin_execute', [ $this, 'on_action_begin' ], 10, 1 );
+ add_action( 'action_scheduler_after_execute', [ $this, 'on_action_after' ], 10, 2 );
+ }
+
+ /**
+ * 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 );
+
+ $args = [
+ 'job_class' => get_class( $job ),
+ 'job_id' => $job->get_id(),
+ ];
+
+ $action_id = as_enqueue_async_action(
+ self::HOOK,
+ $args,
+ 'onesearch_' . $job->get_group()
+ );
+
+ if ( ! $action_id ) {
+ $job->fail( 'Failed to enqueue action: Action Scheduler returned 0' );
+ $this->persist_job( $job );
+ throw new \RuntimeException(
+ sprintf( 'Failed to schedule job %s: Action Scheduler returned invalid action ID.', esc_html( $job->get_id() ) )
+ );
+ }
+
+ $this->repository->set_action( $job->get_id(), $action_id, $args );
+
+ return $action_id;
+ }
+
+ /**
+ * Schedule a recurring job that re-enqueues at a fixed interval.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The job to schedule repeatedly.
+ * @param int $interval_seconds Seconds between each execution.
+ * @return int The Action Scheduler action ID.
+ *
+ * @throws \RuntimeException If Action Scheduler rejects the recurring action.
+ */
+ public function schedule_recurring( Abstract_Job $job, int $interval_seconds ): int {
+ if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
+ throw new \RuntimeException( 'Action Scheduler dependency missing.' );
+ }
+
+ $job->set_status( Abstract_Job::STATUS_PENDING );
+ $this->persist_job( $job );
+
+ $args = [
+ 'job_class' => get_class( $job ),
+ 'job_id' => $job->get_id(),
+ ];
+
+ $action_id = as_schedule_recurring_action(
+ time() + $interval_seconds,
+ $interval_seconds,
+ self::HOOK,
+ $args,
+ 'onesearch_' . $job->get_group()
+ );
+
+ if ( ! $action_id ) {
+ $job->fail( 'Failed to schedule recurring action' );
+ $this->persist_job( $job );
+ throw new \RuntimeException(
+ sprintf( 'Failed to schedule recurring job %s.', esc_html( $job->get_id() ) )
+ );
+ }
+
+ $this->repository->set_action( $job->get_id(), $action_id, $args );
+
+ return $action_id;
+ }
+
+ /**
+ * 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();
+
+ // Always reconcile against the atomic DB counters: a snapshot
+ // (whether from the transient or a stale read) can lag behind
+ // what notify_parent() incremented directly, so the counters are
+ // the source of truth and prevent the upsert from regressing them.
+ $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.
+ *
+ * Reindex_Job children all share a single Action Scheduler group
+ * ("onesearch_reindex_{parent_id}"), so one as_unschedule_all_actions()
+ * call replaces N individual as_unschedule_action() calls. The DB update
+ * is also batched into a single UPDATE … WHERE id IN (…) statement.
+ * Completed or already-cancelled children are left untouched.
+ *
+ * @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;
+ }
+
+ // Children share one AS group: onesearch_reindex_{parent_id}.
+ $child_as_group = 'onesearch_reindex_' . $parent_id;
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( self::HOOK, [], $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 );
+ }
+
+ /**
+ * Get all jobs in a given Action Scheduler group.
+ *
+ * @param string $group The group name (without "onesearch_" prefix).
+ * @return array> Array of job state arrays.
+ */
+ public function get_jobs_by_group( string $group ): array {
+ $actions = as_get_scheduled_actions(
+ [
+ 'group' => 'onesearch_' . $group,
+ 'status' => [ \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ],
+ 'per_page' => -1,
+ ]
+ );
+
+ $jobs = [];
+ foreach ( $actions as $action ) {
+ $args = $action->get_args();
+ if ( isset( $args['job_id'] ) ) {
+ $job_status = $this->get_status( $args['job_id'] );
+ if ( $job_status ) {
+ $jobs[] = $job_status;
+ }
+ }
+ }
+
+ return $jobs;
+ }
+
+ /**
+ * 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 );
+ $this->notify_progress( $job );
+
+ try {
+ $job->handle();
+
+ // Re-check DB status: cancel() may have set this job to cancelled
+ // while handle() was running. If so, respect the cancellation and
+ // don't overwrite it with completed/running status.
+ $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->has_pending_children() ) {
+ $job->set_status( Abstract_Job::STATUS_RUNNING );
+ } else {
+ $job->mark_completed();
+ }
+ $this->persist_job( $job );
+ $this->notify_progress( $job );
+ $this->notify_parent( $job );
+ } catch ( \Throwable $e ) {
+ $job->set_retry_count( $retry + 1 );
+
+ // Re-check DB status: cancel() may have set this job to cancelled
+ // while handle() was running. If so, respect 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;
+ }
+
+ 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_progress( $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()
+ )
+ );
+ }
+
+ /**
+ * Called just before Action Scheduler begins processing an action.
+ *
+ * @param int $action_id The AS action ID about to execute.
+ */
+ public function on_action_begin( int $action_id ): void { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ // No-op. Available for future logging/monitoring hooks.
+ }
+
+ /**
+ * Called after Action Scheduler finishes executing an action.
+ *
+ * @param int $action_id The AS action ID that just executed.
+ * @param \ActionScheduler_Action $action The action that was executed.
+ */
+ public function on_action_after( int $action_id, $action ): void { // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ // No-op. Available for future logging/monitoring hooks.
+ }
+
+ /**
+ * Persist the full job state.
+ *
+ * Every call writes to the custom table (so active jobs are enumerable).
+ * Active jobs additionally get a transient for fast reads; terminal jobs
+ * have their transient deleted since the table row is now the source of truth.
+ *
+ * @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. A spinlock with retries guards the parent data
+ * write to prevent concurrent overwrites while ensuring no notifications
+ * are silently dropped.
+ *
+ * Remote site status checks are performed OUTSIDE the lock to prevent
+ * blocking cancel() operations during slow API calls.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The child job that just completed or failed.
+ */
+ private function notify_parent( Abstract_Job $job ): void {
+ global $wpdb;
+
+ $parent_id = $job->get_parent_id();
+ if ( ! $parent_id ) {
+ return;
+ }
+
+ $parent_data = $this->get_status( $parent_id );
+ if ( ! $parent_data ) {
+ 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' );
+ }
+
+ $counters = $this->repository->get_counters( $parent_id );
+ $done = $counters['done'];
+ $total_failed = $counters['failed'];
+ $total_cancelled = $counters['cancelled'];
+ $child_total = count( $parent_data['child_ids'] ?? [] );
+
+ $parent_key = self::OPTION_PREFIX . $parent_id;
+ $lock_key = $parent_key . '_lock';
+
+ // Check if all local children are done (fast check, no lock needed yet).
+ $all_local_done = $child_total > 0 && $done >= $child_total;
+
+ // If all local children are done and this is a governing site with remote
+ // sites, check remote statuses BEFORE acquiring the lock. This prevents
+ // blocking cancel() during slow remote API calls.
+ $remote_status = null;
+ if ( $all_local_done && Settings::is_governing_site() ) {
+ $remote_sites = $parent_data['data']['sites'] ?? [];
+ if ( is_array( $remote_sites ) && count( $remote_sites ) > 1 ) {
+ $remote_status = $this->check_remote_job_statuses( $remote_sites );
+ }
+ }
+
+ // Acquire the lock for the parent status update (with expiry so a crashed
+ // process cannot orphan the lock indefinitely).
+ if ( ! $this->acquire_lock( $lock_key ) ) {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ '[OneSearch] notify_parent: Could not acquire lock for parent %s. Skipping notification.',
+ esc_html( $parent_id )
+ )
+ );
+ 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'] = $total_failed + $total_cancelled;
+ $parent_data['progress'] = min( $done, $parent_data['progress_total'] ?? $done );
+ $parent_data['updated_at'] = time();
+
+ if ( $all_local_done ) {
+ // Incorporate remote status if we checked it earlier.
+ if ( null !== $remote_status ) {
+ $total_failed += $remote_status['failed'];
+ $total_cancelled += $remote_status['cancelled'];
+ $parent_data['children_failed'] = $total_failed + $total_cancelled;
+
+ // If any remote site is still running, defer finalization.
+ if ( $remote_status['running'] > 0 ) {
+ $parent_data['data']['_needs_remote_finalize'] = true;
+ $parent_data['status'] = Abstract_Job::STATUS_RUNNING;
+ $this->repository->upsert( $parent_data );
+ set_transient( $parent_key, $parent_data, self::TRANSIENT_EXPIRATION );
+ return;
+ }
+ }
+
+ // Cancelled beats failed beats completed.
+ if ( $total_cancelled > 0 ) {
+ $parent_data['status'] = Abstract_Job::STATUS_CANCELLED;
+ } elseif ( $total_failed > 0 ) {
+ $parent_data['status'] = Abstract_Job::STATUS_FAILED;
+ $parent_data['error'] = sprintf( '%d/%d child batches failed', $total_failed, $child_total );
+ } else {
+ $parent_data['status'] = Abstract_Job::STATUS_COMPLETED;
+ }
+ $parent_data['progress'] = $parent_data['progress_total'];
+ $parent_data['finished_at'] = time();
+ Search_Controller::clear_reindex_state();
+ }
+
+ $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' );
+ }
+ }
+
+ /**
+ * 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 notify_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}
+ */
+ public function check_remote_job_statuses( array $remote_sites ): array {
+ $failed = 0;
+ $cancelled = 0;
+ $running = 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 );
+
+ // An unreachable/unauthorized site can't be confirmed successful, so
+ // it counts as failed (matching the prior behaviour).
+ if ( null === $job_data ) {
+ ++$failed;
+ continue;
+ }
+
+ $remote_status = $job_data['status'] ?? '';
+
+ if ( 'failed' === $remote_status ) {
+ ++$failed;
+ } elseif ( 'cancelled' === $remote_status ) {
+ ++$cancelled;
+ } elseif ( in_array( $remote_status, [ 'pending', 'running' ], true ) ) {
+ ++$running;
+ }
+ }
+
+ return [
+ 'failed' => $failed,
+ 'cancelled' => $cancelled,
+ 'running' => $running,
+ ];
+ }
+
+ /**
+ * 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;
+ }
+
+ $job_completed = (int) ( $job_data['children_completed'] ?? $job_data['progress'] ?? 0 );
+ $job_failed = (int) ( $job_data['children_failed'] ?? 0 );
+ $job_cancelled = (int) ( $job_data['children_cancelled'] ?? 0 );
+
+ $completed += $job_completed;
+ $terminal += $job_completed + $job_failed + $job_cancelled;
+ $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 ); // 100ms base, +50ms per attempt.
+ }
+
+ return false;
+ }
+
+ /**
+ * Fire the registered progress callback, if any.
+ *
+ * @param \OneSearch\Modules\Jobs\Abstract_Job $job The job whose progress changed.
+ */
+ private function notify_progress( Abstract_Job $job ): void {
+ if ( $this->progress_callback ) {
+ call_user_func( $this->progress_callback, $job->get_id(), $job->get_progress(), $job->get_status() );
+ }
+ }
+
+ /**
+ * Get all active job IDs (pending or running) from the custom table.
+ *
+ * @return string[] Array of job IDs currently in pending or running state.
+ */
+ public function get_active_job_ids(): array {
+ return $this->repository->get_active_job_ids();
+ }
+}
diff --git a/inc/Modules/Schema/Job_Repository.php b/inc/Modules/Schema/Job_Repository.php
new file mode 100644
index 0000000..7d9c613
--- /dev/null
+++ b/inc/Modules/Schema/Job_Repository.php
@@ -0,0 +1,527 @@
+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 );
+
+ // Build placeholders handling NULLs explicitly — $wpdb->prepare('%s', null)
+ // produces '' not NULL, which breaks IS NULL queries on parent_id etc.
+ $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;
+ }
+
+ /**
+ * Fetch multiple job rows by IDs.
+ *
+ * @param string[] $ids List of job IDs.
+ * @return array> Keyed by job ID.
+ */
+ public function get_by_ids( array $ids ): array {
+ if ( empty( $ids ) ) {
+ return [];
+ }
+
+ global $wpdb;
+
+ $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,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ $rows = $wpdb->get_results(
+ $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id IN ({$placeholders})", ...$ids ),
+ ARRAY_A
+ );
+ // phpcs:enable
+
+ $result = [];
+ foreach ( ( $rows ?: [] ) as $row ) {
+ $hydrated = $this->hydrate_row( $row );
+ $result[ $hydrated['id'] ] = $hydrated;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Delete a job row by ID.
+ *
+ * @param string $id Job ID.
+ */
+ public function delete_by_id( string $id ): bool {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ return false !== $wpdb->delete( $this->table, [ 'id' => $id ] );
+ }
+
+ /**
+ * 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'],
+ ];
+ }
+
+ /**
+ * Return job IDs for all active (pending or running) jobs.
+ *
+ * @return string[]
+ */
+ public function get_active_job_ids(): array {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_col(
+ "SELECT id FROM {$this->table} WHERE status IN ('pending', 'running') ORDER BY created_at ASC"
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return $rows ?: [];
+ }
+
+ /**
+ * 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();
+
+ // Fields without dedicated columns go into the data blob.
+ $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 blob-only fields from the 'data' key so the domain model sees
+ // only the user-provided metadata, not the retry/child_ids we folded in.
+ $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 0000000..468a04f
--- /dev/null
+++ b/inc/Modules/Schema/Job_Schema.php
@@ -0,0 +1,86 @@
+= 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 );
+ }
+
+ /**
+ * Drop the table entirely. Called from uninstall.php.
+ */
+ public static function drop_table(): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE_NAME;
+ // 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}" );
+ }
+}
diff --git a/inc/Modules/Search/Index.php b/inc/Modules/Search/Index.php
index e5b40e4..b1b7776 100644
--- a/inc/Modules/Search/Index.php
+++ b/inc/Modules/Search/Index.php
@@ -26,17 +26,26 @@ final class Index {
/**
* Flag to check whether settings were already set on this instance.
*
+ * Uses a static variable so that settings are only pushed to Algolia
+ * once per PHP process, regardless of how many Index instances are created.
+ *
* @var bool
*/
- private bool $index_settings_initialized = false;
+ private static bool $settings_initialized = false;
/**
- * The instance of the AlgoliaClient SearchIndex
+ * Per-instance cache of the SearchIndex object.
*
* @var \OneSearch\Vendor\Algolia\AlgoliaSearch\SearchIndex|null
*/
private ?\OneSearch\Vendor\Algolia\AlgoliaSearch\SearchIndex $index = null;
+ /**
+ * Transient key used to persist index-settings-initialization across
+ * separate PHP requests (e.g. individual Action Scheduler runs).
+ */
+ private const SETTINGS_INITIALIZED_KEY = 'onesearch_index_settings_initialized';
+
/**
* Get the index, instantiating it if it doesn't exist.
*/
@@ -185,6 +194,8 @@ public function search( string $s, array $args = [] ): array|\WP_Error {
/**
* Index the post types objects into Algolia.
*
+ * @deprecated Use Reindex_Job for async batch processing instead.
+ *
* @param string[] $post_types The post types to index.
*
* @return true|\WP_Error
@@ -236,8 +247,13 @@ public function index_all_posts( array $post_types ) {
* @return true|\WP_Error
*/
private function set_settings(): bool|\WP_Error {
- // Only initialize once per instance.
- if ( $this->index_settings_initialized ) {
+ if ( self::$settings_initialized ) {
+ return true;
+ }
+
+ // Check if settings were already applied in a previous request.
+ if ( 'yes' === get_transient( self::SETTINGS_INITIALIZED_KEY ) ) {
+ self::$settings_initialized = true;
return true;
}
@@ -250,7 +266,8 @@ private function set_settings(): bool|\WP_Error {
try {
$index->setSettings( Post_Record::get_index_settings() )->wait();
- $this->index_settings_initialized = true;
+ self::$settings_initialized = true;
+ set_transient( self::SETTINGS_INITIALIZED_KEY, 'yes', 5 * MINUTE_IN_SECONDS );
return true;
} catch ( \Throwable $e ) {
return new \WP_Error(
diff --git a/inc/Modules/Search/Watcher.php b/inc/Modules/Search/Watcher.php
index eb3c295..068250a 100644
--- a/inc/Modules/Search/Watcher.php
+++ b/inc/Modules/Search/Watcher.php
@@ -10,7 +10,9 @@
namespace OneSearch\Modules\Search;
use OneSearch\Contracts\Interfaces\Registrable;
+use OneSearch\Modules\Jobs\Sync_Job;
use OneSearch\Modules\Rest\Governing_Data_Handler;
+use OneSearch\Modules\Scheduler\Job_Scheduler;
use OneSearch\Modules\Search\Settings as Search_Settings;
use OneSearch\Modules\Settings\Settings;
use OneSearch\Utils;
@@ -29,6 +31,9 @@ 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.
@@ -40,27 +45,49 @@ public function on_post_transition( $new_status, $old_status, $post ): void { //
return;
}
- // First delete the old post.
- // @see Post_Record::prepare_record_object_name .
- $site_post_id = sprintf( '%s_%d', Utils::normalize_url( get_site_url() ), (int) $post->ID );
- $indexer = new Index();
- $delete_success = $indexer->delete_by(
+ $job = new Sync_Job();
+ $job->set_data(
[
- 'filters' => sprintf( 'site_post_id:"%s"', $site_post_id ),
+ 'post_ids' => [ (int) $post->ID ],
]
);
+ $job->set_group( 'watcher' );
+ $job->set_max_retries( 2 );
+ $job->set_retry_delay_seconds( 30 );
- if ( is_wp_error( $delete_success ) ) {
- return;
+ $scheduler = new Job_Scheduler();
+
+ try {
+ $scheduler->schedule( $job );
+ } catch ( \Throwable $e ) {
+ // Fallback to synchronous indexing if scheduling fails.
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( sprintf( '[OneSearch] Failed to schedule Sync_Job: %s', $e->getMessage() ) );
+
+ $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 {
+ $site_post_id = sprintf( '%s_%d', Utils::normalize_url( get_site_url() ), (int) $post->ID );
+ $indexer = new Index();
+
+ $indexer->delete_by(
+ [
+ 'filters' => sprintf( 'site_post_id:"%s"', $site_post_id ),
+ ]
+ );
- // Check if the new status is allowed before reindexing.
- if ( ! in_array( $new_status, Post_Record::get_allowed_statuses( [ $post->post_type ] ), true ) ) {
+ if ( ! in_array( $post->post_status, Post_Record::get_allowed_statuses( [ $post->post_type ] ), true ) ) {
return;
}
$records = ( new Post_Record() )->to_records( $post );
-
$indexer->save_records( $records );
}
diff --git a/onesearch.php b/onesearch.php
index 6042825..00ababc 100644
--- a/onesearch.php
+++ b/onesearch.php
@@ -67,6 +67,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 4e3dd73..202988c 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -23,6 +23,8 @@ parameters:
- onesearch.php
stubFiles:
- phpstan/stubs/wordpress-extended.php
+ scanFiles:
+ - phpstan/stubs/action-scheduler.php
paths:
- onesearch.php
- uninstall.php
diff --git a/phpstan/stubs/action-scheduler.php b/phpstan/stubs/action-scheduler.php
new file mode 100644
index 0000000..de51e6e
--- /dev/null
+++ b/phpstan/stubs/action-scheduler.php
@@ -0,0 +1,72 @@
+
+ */
+ public function get_args(): array {}
+ }
+
+ /**
+ * @param string $hook
+ * @param array $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
+ * @return int
+ */
+ function as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '' ): int {}
+
+ /**
+ * @param int $timestamp
+ * @param int $interval_in_seconds
+ * @param string $hook
+ * @param array $args
+ * @param string $group
+ * @return int
+ */
+ function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '' ): 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 {}
+
+ /**
+ * @param array $args
+ * @return ActionScheduler_Action[]
+ */
+ function as_get_scheduled_actions( array $args = [] ): array {}
+}
\ No newline at end of file
diff --git a/tests/js/SiteIndexableEntities.test.tsx b/tests/js/SiteIndexableEntities.test.tsx
index 5f46c14..1add409 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,12 +294,41 @@ describe( 'SiteIndexableEntities', () => {
} );
} );
- global.fetch = jest.fn().mockResolvedValueOnce(
- okJson( {
- success: false,
- message: 'failed',
- } )
- ) as typeof fetch;
+ global.fetch = jest
+ .fn()
+ .mockResolvedValueOnce(
+ okJson( {
+ jobs: [],
+ total: 0,
+ page: 1,
+ per_page: 5,
+ total_pages: 0,
+ } )
+ )
+ .mockResolvedValueOnce(
+ okJson( {
+ jobs: [],
+ total: 0,
+ page: 1,
+ per_page: 5,
+ total_pages: 0,
+ } )
+ )
+ .mockResolvedValueOnce(
+ okJson( {
+ jobs: [],
+ total: 0,
+ page: 1,
+ per_page: 5,
+ total_pages: 0,
+ } )
+ )
+ .mockResolvedValueOnce(
+ okJson( {
+ success: false,
+ message: 'failed',
+ } )
+ ) as typeof fetch;
fireEvent.click( screen.getByRole( 'button', { name: 'Re-index' } ) );
@@ -288,10 +336,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/php/Unit/Modules/Jobs/Registry_Test.php b/tests/php/Unit/Modules/Jobs/Registry_Test.php
new file mode 100644
index 0000000..dc25db8
--- /dev/null
+++ b/tests/php/Unit/Modules/Jobs/Registry_Test.php
@@ -0,0 +1,242 @@
+reset_registry();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tear_down(): void {
+ $this->reset_registry();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Reset the Registry singleton via reflection.
+ */
+ private function reset_registry(): void {
+ $ref_instance = new \ReflectionProperty( Registry::class, 'instance' );
+ $ref_instance->setAccessible( true );
+ $ref_instance->setValue( null, null );
+
+ $ref_jobs = new \ReflectionProperty( Registry::class, 'jobs' );
+ $ref_jobs->setAccessible( true );
+
+ $instance = Registry::instance();
+ $ref_jobs->setValue( $instance, [] );
+ }
+
+ /**
+ * Test that instance returns the singleton registry object.
+ */
+ public function test_instance_returns_same_object(): void {
+ $a = Registry::instance();
+ $b = Registry::instance();
+
+ $this->assertSame( $a, $b );
+ }
+
+ /**
+ * Test that register adds a job type to the registry.
+ */
+ public function test_register_adds_job_type(): void {
+ Registry::instance()->register( 'test', TestConcreteJob::class );
+
+ $this->assertTrue( Registry::instance()->has( 'test' ) );
+ }
+
+ /**
+ * Test that register rejects a class name that does not exist.
+ */
+ public function test_register_throws_for_nonexistent_class(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'does not exist' );
+
+ Registry::instance()->register( 'bad', 'NonExistentClass' );
+ }
+
+ /**
+ * Test that register rejects classes that do not extend Abstract_Job.
+ */
+ public function test_register_throws_for_class_not_extending_abstract_job(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'must extend' );
+
+ Registry::instance()->register( 'bad', \stdClass::class );
+ }
+
+ /**
+ * Test that register overwrites an existing job name.
+ */
+ public function test_register_overwrites_existing_name(): void {
+ $registry = Registry::instance();
+ $registry->register( 'job', TestConcreteJob::class );
+
+ // Create another concrete class for overwriting.
+ $registry->register( 'job', AnotherTestConcreteJob::class );
+
+ $resolved = $registry->resolve( 'job' );
+ $this->assertInstanceOf( AnotherTestConcreteJob::class, $resolved );
+ }
+
+ /**
+ * Test that resolve creates an instance of the registered job class.
+ */
+ public function test_resolve_creates_fresh_instance(): void {
+ Registry::instance()->register( 'test', TestConcreteJob::class );
+
+ $job = Registry::instance()->resolve( 'test' );
+
+ $this->assertInstanceOf( Abstract_Job::class, $job );
+ $this->assertInstanceOf( TestConcreteJob::class, $job );
+ }
+
+ /**
+ * Test that resolve creates a new job instance each time.
+ */
+ public function test_resolve_creates_new_instance_each_time(): void {
+ Registry::instance()->register( 'test', TestConcreteJob::class );
+
+ $a = Registry::instance()->resolve( 'test' );
+ $b = Registry::instance()->resolve( 'test' );
+
+ $this->assertNotSame( $a, $b );
+ }
+
+ /**
+ * Test that resolve rejects an unregistered job name.
+ */
+ public function test_resolve_throws_for_unregistered_name(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'is not registered' );
+
+ Registry::instance()->resolve( 'nonexistent' );
+ }
+
+ /**
+ * Test that has returns true for a registered job name.
+ */
+ public function test_has_returns_true_for_registered_name(): void {
+ Registry::instance()->register( 'sync', TestConcreteJob::class );
+ $this->assertTrue( Registry::instance()->has( 'sync' ) );
+ }
+
+ /**
+ * Test that has returns false for an unregistered job name.
+ */
+ public function test_has_returns_false_for_unregistered_name(): void {
+ $this->assertFalse( Registry::instance()->has( 'unknown' ) );
+ }
+
+ /**
+ * Test that all returns the registered job map.
+ */
+ public function test_all_returns_registered_map(): void {
+ $registry = Registry::instance();
+ $registry->register( 'alpha', TestConcreteJob::class );
+ $registry->register( 'beta', AnotherTestConcreteJob::class );
+
+ $all = $registry->all();
+ $this->assertArrayHasKey( 'alpha', $all );
+ $this->assertArrayHasKey( 'beta', $all );
+ $this->assertSame( TestConcreteJob::class, $all['alpha'] );
+ $this->assertSame( AnotherTestConcreteJob::class, $all['beta'] );
+ }
+
+ /**
+ * Test that names returns the registered job names.
+ */
+ public function test_names_returns_registered_names(): void {
+ $registry = Registry::instance();
+ $registry->register( 'alpha', TestConcreteJob::class );
+ $registry->register( 'beta', AnotherTestConcreteJob::class );
+
+ $names = $registry->names();
+ $this->assertSame( [ 'alpha', 'beta' ], $names );
+ }
+
+ /**
+ * Test that count returns the number of registered job types.
+ */
+ public function test_count_returns_number_of_registered_types(): void {
+ $registry = Registry::instance();
+ $this->assertSame( 0, $registry->count() );
+
+ $registry->register( 'alpha', TestConcreteJob::class );
+ $this->assertSame( 1, $registry->count() );
+
+ $registry->register( 'beta', AnotherTestConcreteJob::class );
+ $this->assertSame( 2, $registry->count() );
+ }
+}
+
+if ( ! class_exists( TestConcreteJob::class ) ) {
+ /**
+ * Concrete job for registry tests.
+ */
+ class TestConcreteJob extends Abstract_Job {
+ /**
+ * Mark the test job as completed.
+ */
+ public function handle(): void {
+ $this->mark_completed();
+ }
+
+ /**
+ * Return the job type identifier.
+ */
+ public static function get_type(): string {
+ return 'test';
+ }
+ }
+}
+
+if ( ! class_exists( AnotherTestConcreteJob::class ) ) {
+ /**
+ * Second concrete job for testing overwrites.
+ */
+ class AnotherTestConcreteJob extends Abstract_Job {
+ /**
+ * Mark the test job as completed.
+ */
+ public function handle(): void {
+ $this->mark_completed();
+ }
+
+ /**
+ * Return the job type identifier.
+ */
+ public static function get_type(): string {
+ return 'another_test';
+ }
+ }
+}
diff --git a/tests/php/Unit/Modules/Jobs/Reindex_Job_Test.php b/tests/php/Unit/Modules/Jobs/Reindex_Job_Test.php
new file mode 100644
index 0000000..4a7eaba
--- /dev/null
+++ b/tests/php/Unit/Modules/Jobs/Reindex_Job_Test.php
@@ -0,0 +1,89 @@
+assertSame( 'reindex', Reindex_Job::get_type() );
+ }
+
+ /**
+ * Verifies Reindex_Job extends Abstract_Job.
+ */
+ public function test_extends_abstract_job(): void {
+ $this->assertInstanceOf( Abstract_Job::class, new Reindex_Job() );
+ }
+
+ /**
+ * Verifies the default job group is 'reindex'.
+ */
+ public function test_default_group(): void {
+ $this->assertSame( 'reindex', ( new Reindex_Job() )->get_group() );
+ }
+
+ /**
+ * Verifies the default progress total is 1.
+ */
+ public function test_default_progress_total(): void {
+ $this->assertSame( 1, ( new Reindex_Job() )->get_progress_total() );
+ }
+
+ /**
+ * Verifies the default max retries is 2.
+ */
+ public function test_default_max_retries(): void {
+ $this->assertSame( 2, ( new Reindex_Job() )->get_max_retries() );
+ }
+
+ /**
+ * Verifies the default retry delay is 60 seconds.
+ */
+ public function test_default_retry_delay(): void {
+ $this->assertSame( 60, ( new Reindex_Job() )->get_retry_delay_seconds() );
+ }
+
+ /**
+ * Verifies the job ID is a non-empty UUID string.
+ */
+ public function test_id_prefix(): void {
+ $this->assertMatchesRegularExpression(
+ '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
+ ( new Reindex_Job() )->get_id()
+ );
+ }
+
+ /**
+ * Verifies set_data() stores post_types and batch_size correctly.
+ */
+ public function test_set_data_post_types(): void {
+ $j = new Reindex_Job();
+ $j->set_data(
+ [
+ 'post_types' => [ 'post', 'page' ],
+ 'batch_size' => 50,
+ ]
+ );
+ $d = $j->get_data();
+ $this->assertSame( [ 'post', 'page' ], $d['post_types'] );
+ $this->assertSame( 50, $d['batch_size'] );
+ }
+}
diff --git a/tests/php/Unit/Modules/Jobs/Sync_Job_Test.php b/tests/php/Unit/Modules/Jobs/Sync_Job_Test.php
new file mode 100644
index 0000000..055e30f
--- /dev/null
+++ b/tests/php/Unit/Modules/Jobs/Sync_Job_Test.php
@@ -0,0 +1,98 @@
+assertSame( 'sync', Sync_Job::get_type() );
+ }
+
+ /**
+ * Verifies Sync_Job extends Abstract_Job.
+ */
+ public function test_extends_abstract_job(): void {
+ $this->assertInstanceOf( Abstract_Job::class, new Sync_Job() );
+ }
+
+ /**
+ * Verifies the default job group is 'sync'.
+ */
+ public function test_default_group(): void {
+ $this->assertSame( 'sync', ( new Sync_Job() )->get_group() );
+ }
+
+ /**
+ * Verifies the default progress total is 1.
+ */
+ public function test_default_progress_total(): void {
+ $this->assertSame( 1, ( new Sync_Job() )->get_progress_total() );
+ }
+
+ /**
+ * Verifies the default max retries is 3.
+ */
+ public function test_default_max_retries(): void {
+ $this->assertSame( 3, ( new Sync_Job() )->get_max_retries() );
+ }
+
+ /**
+ * Verifies the default retry delay is 30 seconds.
+ */
+ public function test_default_retry_delay(): void {
+ $this->assertSame( 30, ( new Sync_Job() )->get_retry_delay_seconds() );
+ }
+
+ /**
+ * Verifies the initial status is 'pending'.
+ */
+ public function test_initial_status_is_pending(): void {
+ $this->assertSame( Abstract_Job::STATUS_PENDING, ( new Sync_Job() )->get_status() );
+ }
+
+ /**
+ * Verifies the job ID is a non-empty UUID string.
+ */
+ public function test_id_prefix(): void {
+ $this->assertMatchesRegularExpression(
+ '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
+ ( new Sync_Job() )->get_id()
+ );
+ }
+
+ /**
+ * Verifies set_data() stores post_ids correctly.
+ */
+ public function test_set_data_post_ids(): void {
+ $j = new Sync_Job();
+ $j->set_data( [ 'post_ids' => [ 1, 2, 3 ] ] );
+ $this->assertSame( [ 1, 2, 3 ], $j->get_data()['post_ids'] );
+ }
+
+ /**
+ * Verifies handle() throws without post_ids.
+ */
+ public function test_handle_throws_without_post_ids(): void {
+ $j = new Sync_Job();
+ $this->expectException( \InvalidArgumentException::class );
+ $j->handle();
+ }
+}
diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
index a6da1f0..1ff579b 100644
--- a/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
+++ b/tests/php/Unit/Modules/Rest/Governing_Data_Controller_GoverningSiteTest.php
@@ -57,6 +57,9 @@ public function tear_down(): void {
global $wp_rest_server;
$wp_rest_server = null;
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+
parent::tear_down();
}
diff --git a/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php b/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php
index 1f9bb01..17857e2 100644
--- a/tests/php/Unit/Modules/Rest/Governing_Data_HandlerTest.php
+++ b/tests/php/Unit/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/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php b/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php
index 74bcb97..1a9daf9 100644
--- a/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php
+++ b/tests/php/Unit/Modules/Rest/Search_Controller_ConsumerSiteTest.php
@@ -169,4 +169,43 @@ public function test_reindex_proceeds_when_parent_returns_valid_brand_config():
$this->assertArrayHasKey( 'success', $data );
$this->assertArrayHasKey( 'message', $data );
}
+
+ /**
+ * GET /re-index/status returns inactive when no reindex is running.
+ */
+ public function test_reindex_status_returns_inactive_when_no_state(): void {
+ delete_transient( Search_Controller::REINDEX_STATE_TRANSIENT );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertFalse( $data['active'] );
+ }
+
+ /**
+ * POST /re-index returns 409 when a reindex is already active.
+ */
+ public function test_reindex_returns_409_when_active_reindex_exists(): void {
+ $jobs = [
+ [
+ 'site_name' => 'Test Site',
+ 'site_url' => get_site_url(),
+ 'job_id' => 'test_job_789',
+ 'batch_count' => 2,
+ ],
+ ];
+ 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();
+
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'onesearch_reindex_active', $data['code'] );
+
+ delete_transient( Search_Controller::REINDEX_STATE_TRANSIENT );
+ }
}
diff --git a/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php b/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php
index 066113e..877f28e 100644
--- a/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php
+++ b/tests/php/Unit/Modules/Rest/Search_Controller_GoverningSiteTest.php
@@ -9,8 +9,10 @@
namespace OneSearch\Tests\Unit\Modules\Rest;
+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;
@@ -37,6 +39,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;
@@ -57,6 +61,9 @@ public function tear_down(): void {
global $wp_rest_server;
$wp_rest_server = null;
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ delete_option( Settings::OPTION_GOVERNING_SHARED_SITES );
+
parent::tear_down();
}
@@ -406,4 +413,76 @@ public function test_reindex_records_child_wp_error_as_failure(): void {
$this->assertFalse( $data['success'] );
}
+
+ /**
+ * GET /re-index/status returns inactive when no reindex is running.
+ */
+ public function test_reindex_status_returns_inactive_when_no_state(): void {
+ delete_transient( Search_Controller::REINDEX_STATE_TRANSIENT );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertFalse( $data['active'] );
+ $this->assertNull( $data['jobs'] );
+ }
+
+ /**
+ * GET /re-index/status returns active when a reindex state transient exists.
+ */
+ public function test_reindex_status_returns_active_with_state(): void {
+ $scheduler = new Job_Scheduler();
+ $job = new Reindex_Job();
+ $job->mark_running();
+ $scheduler->persist_job( $job );
+
+ $jobs = [
+ [
+ '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 );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/re-index/status' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertTrue( $data['active'] );
+ $this->assertEquals( $jobs, $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();
+
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'onesearch_reindex_active', $data['code'] );
+ }
}
diff --git a/tests/php/Unit/Modules/Scheduler/Job_REST_Controller_Test.php b/tests/php/Unit/Modules/Scheduler/Job_REST_Controller_Test.php
new file mode 100644
index 0000000..7b6b99f
--- /dev/null
+++ b/tests/php/Unit/Modules/Scheduler/Job_REST_Controller_Test.php
@@ -0,0 +1,559 @@
+server = $wp_rest_server;
+
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ $this->scheduler = new Job_Scheduler();
+
+ ( 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;
+
+ parent::tear_down();
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * The /jobs endpoint returns an array of active jobs.
+ */
+ public function test_list_jobs_returns_array(): void {
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertIsArray( $data['jobs'] );
+ }
+
+ /**
+ * The /jobs endpoint includes a job after scheduling.
+ */
+ public function test_list_jobs_includes_scheduled_job(): void {
+ $job = $this->create_scheduled_job();
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $ids = array_column( $data['jobs'], 'id' );
+ $this->assertContains( $job->get_id(), $ids );
+ }
+
+ /**
+ * GET /jobs/{id} returns the job state.
+ */
+ public function test_get_job_returns_status(): void {
+ $job = $this->create_scheduled_job();
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $job->get_id() );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( $job->get_id(), $data['job']['id'] );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $data['job']['status'] );
+ }
+
+ /**
+ * GET /jobs/{id} returns 404 for an unknown job ID.
+ */
+ public function test_get_job_returns_404_for_unknown_id(): void {
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/nonexistent_id_12345' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ /**
+ * POST /jobs/{id}/retry is only allowed for failed jobs.
+ */
+ 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'] );
+ }
+
+ /**
+ * POST /jobs/{id}/retry returns 404 for an unknown job.
+ */
+ public function test_retry_job_returns_404_for_unknown_id(): void {
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/nonexistent_id_12345/retry' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ /**
+ * POST /jobs/{id}/retry retries only failed child batches for a parent job.
+ */
+ public function test_retry_parent_job_retries_failed_children_only(): void {
+ $parent = new Reindex_Job();
+ $completed_child = new Sync_Job();
+ $failed_child = new Sync_Job();
+
+ $completed_child->set_parent_id( $parent->get_id() );
+ $completed_child->set_data( [ 'post_ids' => [ 1 ] ] );
+ $completed_child->mark_completed();
+ $this->scheduler->persist_job( $completed_child );
+
+ $failed_child->set_parent_id( $parent->get_id() );
+ $failed_child->set_data( [ 'post_ids' => [ 2 ] ] );
+ $failed_child->fail( 'Permanent batch failure.' );
+ $this->scheduler->persist_job( $failed_child );
+
+ $parent->set_child_ids( [ $completed_child->get_id(), $failed_child->get_id() ] );
+ $parent->set_progress_total( 2 );
+ $parent->fail( '1/2 child batches failed' );
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $parent->get_id() . '/retry' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( 1, $data['retried'] );
+ $this->assertSame( [ $failed_child->get_id() ], $data['child_ids'] );
+
+ $parent_status = $this->scheduler->get_status( $parent->get_id() );
+ $completed_child_status = $this->scheduler->get_status( $completed_child->get_id() );
+ $failed_child_status = $this->scheduler->get_status( $failed_child->get_id() );
+
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $parent_status['status'] );
+ $this->assertSame( 1, $parent_status['children_completed'] );
+ $this->assertSame( 0, $parent_status['children_failed'] );
+ $this->assertNull( $parent_status['finished_at'] );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $completed_child_status['status'] );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $failed_child_status['status'] );
+ }
+
+ /**
+ * DELETE /jobs/{id} cancels a running job.
+ */
+ public function test_cancel_job_marks_it_as_cancelled(): void {
+ $job = $this->create_scheduled_job();
+
+ $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/' . $job->get_id() );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+
+ // Verify the job is now cancelled.
+ $status = $this->scheduler->get_status( $job->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $status['status'] );
+ }
+
+ /**
+ * DELETE /jobs/{id} returns 404 for an unknown job.
+ */
+ public function test_cancel_job_returns_404_for_unknown_id(): void {
+ $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/nonexistent_id_12345' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ /**
+ * The /jobs/history endpoint returns paginated results.
+ */
+ public function test_history_returns_paginated_response(): void {
+ $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() );
+ $this->assertTrue( $data['success'] );
+ $this->assertIsArray( $data['jobs'] );
+ $this->assertArrayHasKey( 'total', $data );
+ $this->assertArrayHasKey( 'page', $data );
+ $this->assertArrayHasKey( 'total_pages', $data );
+ }
+
+ /**
+ * The /jobs history endpoint respects per_page parameter.
+ */
+ public function test_history_respects_per_page(): void {
+ // Create a few jobs to populate history.
+ for ( $i = 0; $i < 3; $i++ ) {
+ $job = $this->create_scheduled_job();
+ // Mark them as completed so they appear in history.
+ $job->mark_completed();
+ $this->scheduler->persist_job( $job );
+ }
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/history' );
+ $request->set_param( 'per_page', 2 );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 2, count( $data['jobs'] ) );
+ }
+
+ /**
+ * 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 /jobs/history endpoint includes parent jobs stored as failed.
+ */
+ public function test_history_includes_failed_parent_jobs(): void {
+ $job = new Reindex_Job();
+ $job->set_data( [ 'post_types' => [ 'post' ] ] );
+ $job->fail( 'Permanent parent failure.' );
+ $this->scheduler->persist_job( $job );
+
+ $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( $job->get_id(), $jobs );
+ $this->assertSame( Abstract_Job::STATUS_FAILED, $jobs[ $job->get_id() ]['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' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 403, $response->get_status() );
+ }
+
+ /**
+ * The /jobs/{id}/children endpoint returns child jobs.
+ */
+ public function test_get_children_returns_array(): void {
+ $job = $this->create_scheduled_job();
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $job->get_id() . '/children' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertIsArray( $data['children'] );
+ }
+
+ /**
+ * POST /jobs/reindex creates and schedules a Reindex_Job.
+ */
+ public function test_create_reindex_schedules_a_job(): void {
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/reindex' );
+ $request->set_param( 'post_types', [ 'post' ] );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertArrayHasKey( 'job_id', $data );
+ $this->assertNotEmpty( $data['job_id'] );
+ }
+
+ /**
+ * POST /jobs/reindex returns 400 when no post types are configured.
+ */
+ public function test_create_reindex_fails_when_no_post_types_configured(): void {
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/reindex' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_no_post_types', $data['code'] );
+ }
+
+ /**
+ * DELETE /jobs/{id} returns 400 when the job is already in a terminal state.
+ */
+ public function test_cancel_terminal_job_returns_400(): void {
+ $job = new Sync_Job();
+ $job->set_data( [ 'post_ids' => [ 1 ] ] );
+ $job->mark_completed();
+ $this->scheduler->persist_job( $job );
+
+ $request = new WP_REST_Request( 'DELETE', '/onesearch/v1/jobs/' . $job->get_id() );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_job_terminal', $data['code'] );
+ }
+
+ /**
+ * GET /jobs/{id}/children returns 404 for an unknown job.
+ */
+ public function test_get_children_returns_404_for_unknown_id(): void {
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/nonexistent_id_12345/children' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ /**
+ * GET /jobs/{id}/children includes persisted child job statuses.
+ */
+ public function test_get_children_includes_child_jobs(): void {
+ $parent = new Reindex_Job();
+ $child = new Sync_Job();
+
+ $child->set_parent_id( $parent->get_id() );
+ $child->set_data( [ 'post_ids' => [ 1 ] ] );
+ $this->scheduler->schedule( $child );
+
+ $parent->set_child_ids( [ $child->get_id() ] );
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $parent->get_id() . '/children' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertContains( $child->get_id(), array_column( $data['children'], 'id' ) );
+ }
+
+ /**
+ * POST /jobs/{id}/retry on a failed child batch resets the parent job to running.
+ */
+ public function test_retry_single_failed_child_resets_parent_to_running(): 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->fail( '1/1 child batches failed' );
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $child->get_id() . '/retry' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+
+ $parent_status = $this->scheduler->get_status( $parent->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $parent_status['status'] );
+ }
+
+ /**
+ * POST /jobs/{id}/retry reconciles a parent stuck as failed when all children actually completed.
+ */
+ public function test_retry_parent_reconciles_stale_failed_status(): void {
+ $parent = new Reindex_Job();
+ $child = new Sync_Job();
+
+ $child->set_parent_id( $parent->get_id() );
+ $child->set_data( [ 'post_ids' => [ 1 ] ] );
+ $child->mark_completed();
+ $this->scheduler->persist_job( $child );
+
+ $parent->set_child_ids( [ $child->get_id() ] );
+ $parent->set_progress_total( 1 );
+ $parent->fail( 'Stale failure.' );
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $parent->get_id() . '/retry' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( 0, $data['retried'] );
+
+ $parent_status = $this->scheduler->get_status( $parent->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $parent_status['status'] );
+ }
+
+ /**
+ * POST /jobs/{id}/retry returns 400 when the parent has no retryable or completed children.
+ */
+ public function test_retry_parent_returns_400_when_no_retryable_children(): void {
+ $parent = new Reindex_Job();
+ $parent->set_child_ids( [ 'ghost_child_aaa', 'ghost_child_bbb' ] );
+ $parent->set_progress_total( 2 );
+ $parent->fail( 'All children gone.' );
+ $this->scheduler->persist_job( $parent );
+
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/' . $parent->get_id() . '/retry' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'onesearch_retry_no_failed_children', $data['code'] );
+ }
+
+ /**
+ * GET /jobs/{id} allows token-authenticated remote requests.
+ */
+ public function test_get_job_allows_token_authenticated_read(): void {
+ $api_key = Settings::regenerate_api_key();
+ $job = new Sync_Job();
+ $job->set_data( [ 'post_ids' => [ 1 ] ] );
+ $job->mark_running();
+ $this->scheduler->persist_job( $job );
+
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/' . $job->get_id() );
+ $request->set_header( 'X-OneSearch-Token', $api_key );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( $job->get_id(), $data['job']['id'] );
+ }
+
+ /**
+ * GET /jobs/remote-status returns 404 when the site_url is not in shared sites.
+ */
+ public function test_remote_status_returns_404_for_unknown_site(): void {
+ $request = new WP_REST_Request( 'GET', '/onesearch/v1/jobs/remote-status' );
+ $request->set_param( 'site_url', 'https://unknown.example.com' );
+ $request->set_param( 'job_id', 'some_job_id' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'onesearch_unknown_site', $data['code'] );
+ }
+
+ /**
+ * POST /jobs/remote-retry returns 404 when the site_url is not in shared sites.
+ */
+ public function test_remote_retry_returns_404_for_unknown_site(): void {
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/remote-retry' );
+ $request->set_param( 'site_url', 'https://unknown.example.com' );
+ $request->set_param( 'job_id', 'some_job_id' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'onesearch_unknown_site', $data['code'] );
+ }
+
+ /**
+ * POST /jobs/remote-cancel returns success with a warning when the site is unreachable.
+ */
+ public function test_remote_cancel_returns_success_with_warning_for_unknown_site(): void {
+ $request = new WP_REST_Request( 'POST', '/onesearch/v1/jobs/remote-cancel' );
+ $request->set_param( 'site_url', 'https://unknown.example.com' );
+ $request->set_param( 'job_id', 'some_job_id' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $data['success'] );
+ $this->assertArrayHasKey( 'warning', $data );
+ }
+}
diff --git a/tests/php/Unit/Modules/Scheduler/Job_Scheduler_Test.php b/tests/php/Unit/Modules/Scheduler/Job_Scheduler_Test.php
new file mode 100644
index 0000000..21310b9
--- /dev/null
+++ b/tests/php/Unit/Modules/Scheduler/Job_Scheduler_Test.php
@@ -0,0 +1,237 @@
+scheduler = new Job_Scheduler();
+ $this->repository = new Job_Repository();
+ }
+
+ /**
+ * Verifies persist_job() and get_status() for an active (running) job.
+ */
+ public function test_persist_and_get_status_for_active_job(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j );
+ $s = $this->scheduler->get_status( $j->get_id() );
+ $this->assertNotNull( $s );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $s['status'] );
+ }
+
+ /**
+ * Verifies a completed job is stored in the custom table (not wp_options) and
+ * that its transient is removed.
+ */
+ public function test_persist_terminal_job_uses_table(): void {
+ $j = $this->make_job();
+ $j->mark_completed();
+ $this->scheduler->persist_job( $j );
+
+ $key = Job_Scheduler::OPTION_PREFIX . $j->get_id();
+ $this->assertFalse( get_transient( $key ) );
+
+ $row = $this->repository->get_by_id( $j->get_id() );
+ $this->assertIsArray( $row );
+ $this->assertSame( Abstract_Job::STATUS_COMPLETED, $row['status'] );
+ }
+
+ /**
+ * Verifies get_status() returns null for unknown job IDs.
+ */
+ public function test_get_status_null_for_unknown(): void {
+ $this->assertNull( $this->scheduler->get_status( 'nonexistent' ) );
+ }
+
+ /**
+ * Verifies persist_job() updates an existing job's state.
+ */
+ public function test_persist_updates_existing(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j );
+ $j->set_progress_total( 10 );
+ $j->set_progress( 5 );
+ $this->scheduler->persist_job( $j );
+ $this->assertSame( 5, $this->scheduler->get_status( $j->get_id() )['progress'] );
+ }
+
+ /**
+ * Verifies active jobs appear in get_active_job_ids() after persist.
+ */
+ public function test_active_job_appears_in_active_ids(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j );
+ $this->assertContains( $j->get_id(), $this->scheduler->get_active_job_ids() );
+ }
+
+ /**
+ * Verifies a terminal job is removed from get_active_job_ids() after persist.
+ */
+ public function test_terminal_persist_removes_from_active(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j );
+ $this->assertContains( $j->get_id(), $this->scheduler->get_active_job_ids() );
+ $j->mark_completed();
+ $this->scheduler->persist_job( $j );
+ $this->assertNotContains( $j->get_id(), $this->scheduler->get_active_job_ids() );
+ }
+
+ /**
+ * Verifies completed jobs appear in the repository's terminal list.
+ */
+ public function test_terminal_jobs_include_completed(): void {
+ $j = $this->make_job();
+ $j->mark_completed();
+ $this->scheduler->persist_job( $j );
+ $rows = $this->repository->get_terminal_jobs( 1, 50 );
+ $this->assertContains( $j->get_id(), array_column( $rows, 'id' ) );
+ }
+
+ /**
+ * Verifies active (running) jobs are excluded from the terminal list.
+ */
+ public function test_terminal_jobs_exclude_active(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j );
+ $rows = $this->repository->get_terminal_jobs( 1, 50 );
+ $this->assertNotContains( $j->get_id(), array_column( $rows, 'id' ) );
+ }
+
+ /**
+ * Verifies schedule() returns a positive Action Scheduler action ID.
+ */
+ public function test_schedule_returns_positive_action_id(): void {
+ $j = $this->make_job();
+ $this->assertGreaterThan( 0, $this->scheduler->schedule( $j ) );
+ }
+
+ /**
+ * Verifies a scheduled job starts in 'pending' status.
+ */
+ public function test_schedule_sets_pending(): void {
+ $j = $this->make_job();
+ $this->scheduler->schedule( $j );
+ $this->assertSame( Abstract_Job::STATUS_PENDING, $this->scheduler->get_status( $j->get_id() )['status'] );
+ }
+
+ /**
+ * Verifies schedule() stores the Action Scheduler action ID in the custom table.
+ */
+ public function test_schedule_stores_action_id_in_table(): void {
+ $j = $this->make_job();
+ $this->scheduler->schedule( $j );
+ $action = $this->repository->get_action( $j->get_id() );
+ $this->assertNotNull( $action );
+ $this->assertGreaterThan( 0, $action['action_id'] );
+ }
+
+ /**
+ * Verifies cancel() sets the job status to 'cancelled'.
+ */
+ public function test_cancel_marks_cancelled(): void {
+ $j = $this->make_job();
+ $this->scheduler->schedule( $j );
+ $this->scheduler->cancel( $j->get_id() );
+ $this->assertSame( Abstract_Job::STATUS_CANCELLED, $this->scheduler->get_status( $j->get_id() )['status'] );
+ }
+
+ /**
+ * Verifies cancel() removes the job from the active list.
+ */
+ public function test_cancel_removes_from_active(): void {
+ $j = $this->make_job();
+ $this->scheduler->schedule( $j );
+ $this->scheduler->cancel( $j->get_id() );
+ $this->assertNotContains( $j->get_id(), $this->scheduler->get_active_job_ids() );
+ }
+
+ /**
+ * Verifies get_jobs_by_group() returns jobs in the requested group.
+ */
+ public function test_get_jobs_by_group_returns_jobs(): void {
+ $j = $this->make_job();
+ $j->set_group( 'grp_test' );
+ $this->scheduler->schedule( $j );
+ $jobs = $this->scheduler->get_jobs_by_group( 'grp_test' );
+ $this->assertNotEmpty( $jobs );
+ $this->assertContains( $j->get_id(), array_column( $jobs, 'id' ) );
+ }
+
+ /**
+ * Verifies get_jobs_by_group() returns an empty array for unknown groups.
+ */
+ public function test_get_jobs_by_group_unknown_empty(): void {
+ $this->assertSame( [], $this->scheduler->get_jobs_by_group( 'nonexistent_grp' ) );
+ }
+
+ /**
+ * Verifies persist_job() with skip_active_index still writes to the table.
+ */
+ public function test_skip_active_index_still_persists_to_table(): void {
+ $j = $this->make_job();
+ $j->mark_running();
+ $this->scheduler->persist_job( $j, true );
+ $row = $this->repository->get_by_id( $j->get_id() );
+ $this->assertNotNull( $row );
+ $this->assertSame( Abstract_Job::STATUS_RUNNING, $row['status'] );
+ }
+
+ /**
+ * Verifies set_progress_callback() returns the scheduler for chaining.
+ */
+ public function test_progress_callback_is_chainable(): void {
+ $result = $this->scheduler->set_progress_callback( static function (): void {} );
+ $this->assertSame( $this->scheduler, $result );
+ }
+
+ /**
+ * Creates a Sync_Job with a single post ID for testing.
+ */
+ private function make_job(): Sync_Job {
+ $j = new Sync_Job();
+ $j->set_data( [ 'post_ids' => [ 42 ] ] );
+ return $j;
+ }
+}
diff --git a/tests/php/Unit/Modules/Search/AlgoliaTest.php b/tests/php/Unit/Modules/Search/AlgoliaTest.php
index 7eabae5..d438341 100644
--- a/tests/php/Unit/Modules/Search/AlgoliaTest.php
+++ b/tests/php/Unit/Modules/Search/AlgoliaTest.php
@@ -21,6 +21,19 @@
*/
#[CoversClass( \OneSearch\Modules\Search\Algolia::class )]
final class AlgoliaTest extends TestCase {
+ /**
+ * Ensures a clean state before each test.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ delete_option( Search_Settings::OPTION_GOVERNING_ALGOLIA_CREDENTIALS );
+ delete_option( Settings::OPTION_SITE_TYPE );
+ delete_option( Settings::OPTION_CONSUMER_API_KEY );
+ delete_option( Settings::OPTION_CONSUMER_PARENT_SITE_URL );
+ delete_transient( Governing_Data_Handler::TRANSIENT_KEY );
+ }
+
/**
* Cleans up Algolia test state.
*/
diff --git a/tests/php/Unit/Modules/Search/IndexTest.php b/tests/php/Unit/Modules/Search/IndexTest.php
index 0fd31bc..b2456bc 100644
--- a/tests/php/Unit/Modules/Search/IndexTest.php
+++ b/tests/php/Unit/Modules/Search/IndexTest.php
@@ -26,6 +26,12 @@ final class IndexTest extends TestCase {
* {@inheritDoc}
*/
protected function tearDown(): void {
+ // Reset private static flag so earlier tests don't short-circuit set_settings() calls.
+ $prop = new \ReflectionProperty( Index::class, 'settings_initialized' );
+ $prop->setAccessible( true );
+ $prop->setValue( null, false );
+ delete_transient( 'onesearch_index_settings_initialized' );
+
AlgoliaSDK::resetHttpClient();
parent::tearDown();
diff --git a/tests/php/Unit/Modules/Search/SettingsTest.php b/tests/php/Unit/Modules/Search/SettingsTest.php
index 466a278..2cae09c 100644
--- a/tests/php/Unit/Modules/Search/SettingsTest.php
+++ b/tests/php/Unit/Modules/Search/SettingsTest.php
@@ -21,10 +21,23 @@
*/
#[CoversClass( \OneSearch\Modules\Search\Settings::class )]
final class SettingsTest extends TestCase {
+ /**
+ * {@inheritDoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ // Ensure no stale site-type option from a previous test class causes
+ // is_governing_site() to return true during purge-cache assertions.
+ 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/php/Unit/Modules/Search/WatcherTest.php b/tests/php/Unit/Modules/Search/WatcherTest.php
index 01f1679..53bec53 100644
--- a/tests/php/Unit/Modules/Search/WatcherTest.php
+++ b/tests/php/Unit/Modules/Search/WatcherTest.php
@@ -10,6 +10,7 @@
namespace OneSearch\Tests\Unit\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;
@@ -27,6 +28,11 @@ final class WatcherTest extends TestCase {
* {@inheritDoc}
*/
protected function tearDown(): void {
+ // Prevent OPTION_SITE_TYPE from leaking into other test classes.
+ delete_option( Settings::OPTION_SITE_TYPE );
+ // Clean up any AS actions enqueued by the Watcher during this test.
+ as_unschedule_all_actions( Job_Scheduler::HOOK );
+
AlgoliaSDK::resetHttpClient();
parent::tearDown();
@@ -213,8 +219,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 is no longer called synchronously — 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 );
@@ -234,24 +240,23 @@ 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).
+ // Transition from 'draft' to 'publish' (should schedule async Sync_Job).
$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).' );
+ // Assert that an async action was enqueued for the job executor hook.
+ $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.
+ * Indexable post on a consumer site schedules an async Sync_Job.
+ * Credentials and indexable entities are resolved from the brand config cache,
+ * but Algolia is no longer called synchronously — 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 );
@@ -259,17 +264,16 @@ 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).
+ // Transition from 'draft' to 'publish' (should schedule async Sync_Job).
$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).' );
+ // Assert that an async action was enqueued for the job executor hook.
+ $this->assertNotFalse(
+ as_next_scheduled_action( Job_Scheduler::HOOK ),
+ 'Consumer site happy path should schedule an async Sync_Job via Action Scheduler.'
+ );
}
}
diff --git a/tests/php/Unit/Modules/Settings/SettingsTest.php b/tests/php/Unit/Modules/Settings/SettingsTest.php
index c133cb7..dc6b051 100644
--- a/tests/php/Unit/Modules/Settings/SettingsTest.php
+++ b/tests/php/Unit/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/uninstall.php b/uninstall.php
index 7ee610b..c664459 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -72,17 +72,45 @@ function delete_options(): void {
// Brand site options.
PLUGIN_PREFIX . 'parent_site_url',
PLUGIN_PREFIX . 'consumer_api_key',
+
+ // 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
+
+ // Drop the custom jobs table.
+ $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',
@@ -91,6 +119,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%'
+ )
+ );
}
/**