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' ) } -

-
-
- - -
-

- { __( - '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' - ) } -

-
- - -
-
- ) } - - ); -}; - -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 ( +
+
+ + { ( hasFailedHistoryDetails || retryingHistoryJob ) && ( + + ) } +
+ +
+
+ { __( '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 ( +
+ + { pages.map( ( p, idx ) => { + if ( typeof p === 'string' ) { + return ( + + … + + ); + } + return ( + + ); + } ) } + +
+ ); +}; + +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 ( + + + + + + + + + + + + + { 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 } + > + + + + + + + + ); + } ) } + +
{ __( 'ID', 'onesearch' ) }{ __( 'Type', 'onesearch' ) }{ __( 'Created at', 'onesearch' ) }{ __( 'Duration', 'onesearch' ) }{ __( 'Status', 'onesearch' ) }{ __( 'Batches', 'onesearch' ) }
+ + { 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' + ) } +

+
+ +
+ + ) } + + { 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 ''; + } )() } + + +
+ +
+ { 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' ) } +

+
+
+ + +
+

+ { __( + '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%' + ) + ); } /**