diff --git a/.prettierignore b/.prettierignore index 6feed212..bd8c8fc6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,6 @@ vendor vendor-prefixed package-lock.json composer.lock +# Stylelint formats styles; its WordPress rules contradict Prettier's (blank lines, wrapped values). +*.css +*.scss diff --git a/assets/src/components/SiteIndexableEntities.tsx b/assets/src/components/SiteIndexableEntities.tsx deleted file mode 100644 index 5d2083ad..00000000 --- a/assets/src/components/SiteIndexableEntities.tsx +++ /dev/null @@ -1,453 +0,0 @@ -/** - * WordPress dependencies - */ -import { - Card, - CardHeader, - CardBody, - Button, - __experimentalText as Text, - Modal, -} from '@wordpress/components'; - -/** - * External dependencies - */ -import { useState, useEffect, useCallback } from 'react'; -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import MultiSelectChips from './MultiSelectChips'; -import { API_NAMESPACE, NONCE, withTrailingSlash } from '@/js/utils'; -import type { NoticeType } from '@/admin/settings/page'; -import type { OneSearchSharedSite } from '@/types/global'; -import type { PostTypeOption } from './SiteSearchSettings'; - -interface EntitiesMap { - [ siteUrl: string ]: string[]; -} - -interface SiteIndexableEntitiesProps { - sites: OneSearchSharedSite[]; - allPostTypes: Record< string, PostTypeOption[] >; - currentSiteUrl: string; - setNotice: ( notice: NoticeType | null ) => void; - onEntitiesSaved?: () => void; - saving: boolean; - setSaving: ( saving: boolean ) => void; -} - -interface IndexableEntitiesResponse { - indexableEntities: { - entities: EntitiesMap; - }; -} - -interface SaveEntitiesResponse { - success: boolean; - message?: string; - data?: { - status?: number; - }; -} - -interface ReIndexResponse { - success: boolean; - message?: string; - data?: { - status?: number; - }; -} - -const SiteIndexableEntities = ( { - sites, - allPostTypes, - currentSiteUrl, - setNotice, - onEntitiesSaved, - saving, - setSaving, -}: SiteIndexableEntitiesProps ) => { - const [ selectedEntities, setSelectedEntities ] = useState< EntitiesMap >( - {} - ); - const [ savedEntities, setSavedEntities ] = useState< EntitiesMap >( {} ); - const [ reindexing, setReindexing ] = useState( false ); - const [ showReindexingModal, setShowReindexingModal ] = useState( false ); - - const controlsDisabled = saving || reindexing; - - const normalizeEntities = ( map: EntitiesMap = {} ): EntitiesMap => { - const results: EntitiesMap = {}; - Object.keys( map || {} ) - .sort() - .forEach( ( site ) => { - const arr = Array.isArray( map[ site ] ) ? map[ site ] : []; - const clean = Array.from( new Set( arr.map( String ) ) ).sort(); - results[ site ] = clean; - } ); - - return results; - }; - - const isEmptySavedEntities = (): boolean => { - if ( ! savedEntities || typeof savedEntities !== 'object' ) { - return true; - } - - const keys = Object.keys( savedEntities ); - if ( keys.length === 0 ) { - return true; - } - - return keys.every( ( key ) => { - const value = savedEntities[ key ]; - return ! Array.isArray( value ) || value.length === 0; - } ); - }; - - const getIndexableEntities = useCallback( async () => { - try { - const response = await fetch( - `${ API_NAMESPACE }/indexable-entities`, - { - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': NONCE, - }, - } - ); - - const data: IndexableEntitiesResponse = await response.json(); - const incoming: EntitiesMap = - data.indexableEntities?.entities || {}; - setSelectedEntities( incoming ); - setSavedEntities( normalizeEntities( incoming ) ); - } catch { - setNotice( { - type: 'error', - message: __( - 'Error fetching indexable entities.', - 'onesearch' - ), - } ); - } - }, [ setNotice ] ); - - useEffect( () => { - getIndexableEntities(); - }, [ getIndexableEntities ] ); - - const handleSelectedEntitiesChange = ( - selected: string[], - url: string - ) => { - if ( controlsDisabled ) { - return; - } - setSelectedEntities( ( prev: EntitiesMap ) => ( { - ...prev, - [ url ]: selected, - } ) ); - }; - - const handleSelectedEntitiesSave = async ( - entities: EntitiesMap - ): Promise< boolean > => { - try { - setSaving( true ); - const response = await fetch( - `${ API_NAMESPACE }/indexable-entities`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': NONCE, - }, - body: JSON.stringify( { entities } ), - } - ); - - if ( ! response.ok ) { - throw new Error( - __( 'Network response was not ok.', 'onesearch' ) - ); - } - - const data: SaveEntitiesResponse = await response.json(); - - if ( data.success ) { - setSavedEntities( normalizeEntities( entities ) ); - onEntitiesSaved?.(); - // Re-index selected entities. - await handleReIndex(); - return true; - } else if ( data.data?.status === 500 ) { - setNotice( { - message: __( 'Internal server error.', 'onesearch' ), - type: 'error', - } ); - } else { - setNotice( { - message: - data.message || __( 'Unknown error.', 'onesearch' ), - type: 'error', - } ); - } - } catch ( error: unknown ) { - const message = - error instanceof Error ? error.message : String( error ); - setNotice( { - message, - type: 'error', - } ); - } finally { - setSaving( false ); - } - return false; - }; - - const handleReIndex = async (): Promise< boolean > => { - try { - setReindexing( true ); - // @todo use @wordpress/api-fetch everywhere internal. - const response = await fetch( `${ API_NAMESPACE }/re-index`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': NONCE, - }, - } ); - - const data: ReIndexResponse = await response.json(); - if ( data.success ) { - setNotice( { - message: - data.message || - __( 'Re-indexing complete.', 'onesearch' ), - type: 'success', - } ); - return true; - } else if ( data.data?.status === 500 ) { - setNotice( { - message: __( 'Internal server error.', 'onesearch' ), - type: 'error', - } ); - } else { - setNotice( { - message: - data.message || __( 'Unknown error.', 'onesearch' ), - type: 'error', - } ); - } - } catch ( error: unknown ) { - const message = - error instanceof Error ? error.message : String( error ); - setNotice( { - message, - type: 'error', - } ); - } finally { - setReindexing( false ); - setShowReindexingModal( false ); - } - return false; - }; - - const isDirty = - JSON.stringify( normalizeEntities( selectedEntities ) ) !== - JSON.stringify( savedEntities ); - - // Convert PostTypeOption to the format expected by MultiSelectChips - const toMultiSelectOptions = ( - options: PostTypeOption[] - ): Array< { slug: string; label: string; restBase: string } > => { - return options.map( ( opt ) => ( { - slug: opt.slug, - label: opt.label ?? opt.slug, - restBase: opt.restBase ?? opt.slug, - } ) ); - }; - - return ( - <> - - -

- { __( 'Select Entities to Index', 'onesearch' ) } -

-
-
- - -
-

- { __( - '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/EntitySiteCard.tsx b/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx new file mode 100644 index 00000000..579cf1a4 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/EntitySiteCard.tsx @@ -0,0 +1,64 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { __experimentalText as Text } from '@wordpress/components'; +/** + * Internal dependencies + */ +import MultiSelectChips from '../MultiSelectChips'; +import type { PostTypeOption } from '../SiteSearchSettings'; +import { toMultiSelectOptions } from './utils'; + +interface EntitySiteCardProps { + siteName: string; + siteUrl: string; + options: PostTypeOption[] | undefined; + selectedValues: string[]; + disabled: boolean; + isBrand?: boolean; + onChange: ( values: string[] ) => void; +} + +const EntitySiteCard = ( { + siteName, + siteUrl, + options, + selectedValues, + disabled, + isBrand = false, + onChange, +}: EntitySiteCardProps ) => ( +
+
+

{ siteName }

+

{ siteUrl }

+
+ { ! options ? ( + + { __( + 'No entities to select. Please check site configuration', + 'onesearch' + ) } + + ) : ( +
+ +
+ ) } +
+); + +export default EntitySiteCard; diff --git a/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx new file mode 100644 index 00000000..d3df944f --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/HistoryPagination.tsx @@ -0,0 +1,100 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; + +interface HistoryPaginationProps { + historyPage: number; + historyTotalPages: number; + onPageChange: ( page: number ) => void; +} + +const HistoryPagination = ( { + historyPage, + historyTotalPages, + onPageChange, +}: HistoryPaginationProps ) => { + if ( historyTotalPages <= 1 ) { + return null; + } + + const pages: ( number | string )[] = []; + const delta = 2; + const last = historyTotalPages; + + pages.push( 1 ); + + const rangeStart = Math.max( 2, historyPage - delta ); + const rangeEnd = Math.min( last - 1, historyPage + delta ); + + if ( rangeStart > 2 ) { + pages.push( '…' ); + } + for ( let i = rangeStart; i <= rangeEnd; i++ ) { + pages.push( i ); + } + if ( rangeEnd < last - 1 ) { + pages.push( '…' ); + } + pages.push( last ); + + const handlePageClick = ( page: number ) => { + if ( page === historyPage || page < 1 || page > last ) { + return; + } + onPageChange( page ); + }; + + return ( +
+ + { 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 00000000..e67aa415 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/HistoryTable.tsx @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import { Button, __experimentalText as Text } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import JobStatusBadge from './JobStatusBadge'; +import type { JobStatus } from './types'; +import { formatDuration, formatTimestamp } from './utils'; + +interface HistoryTableProps { + history: JobStatus[]; + retryingJobId: string | null; + onRetry: ( job: JobStatus ) => void; +} + +/** + * A job is worth retrying when it failed outright, or when it finished with + * some batches still in a failed state. A cancelled job never is: cancelling + * was the user's decision, not a failure. + * + * @param {JobStatus} job The job to check. + * @return {boolean} Whether a retry should be offered. + */ +const isRetryable = ( job: JobStatus ): boolean => + job.status !== 'cancelled' && + ( job.status === 'failed' || ( job.children_failed ?? 0 ) > 0 ); + +/** + * Formats a job's batch progress as "done/total". + * + * A top-level sync job (e.g. from the Watcher) has no child batches because it + * is itself the one batch, and its progress counters track posts, not batches. + * + * @param {JobStatus} job The job to format. + * @return {string} The batch progress, or an em dash when the job has no batches. + */ +const formatBatches = ( job: JobStatus ): string => { + if ( job.type === 'sync' ) { + return `${ job.status === 'completed' ? 1 : 0 }/1`; + } + + const totalBatches = + ( job.data?.[ 'total_batches' ] as number ) || + job.children_total || + job.progress_total; + const completedBatches = job.children_completed ?? job.progress; + + return totalBatches > 0 ? `${ completedBatches }/${ totalBatches }` : '—'; +}; + +const HistoryTable = ( { + history, + retryingJobId, + onRetry, +}: HistoryTableProps ) => { + if ( history.length === 0 ) { + return ( + { __( 'No past jobs.', 'onesearch' ) } + ); + } + + return ( + + + + + + + + + + + + + + { history.map( ( job ) => { + const duration = + job.finished_at && job.created_at + ? job.finished_at - job.created_at + : null; + + const retrying = retryingJobId === job.id; + + return ( + + + + + + + + + + ); + } ) } + +
{ __( 'ID', 'onesearch' ) }{ __( 'Type', 'onesearch' ) }{ __( 'Created at', 'onesearch' ) }{ __( 'Duration', 'onesearch' ) }{ __( 'Status', 'onesearch' ) }{ __( 'Batches', 'onesearch' ) } + + { __( 'Actions', 'onesearch' ) } + +
+ + { job.id.substring( 0, 16 ) }… + + + + { job.group || 'reindex' } + + + + { formatTimestamp( job.created_at ) } + + + + { duration !== null + ? formatDuration( duration ) + : '—' } + + + + + + { formatBatches( job ) } + + + { isRetryable( job ) && ( + + ) } +
+ ); +}; + +export default HistoryTable; diff --git a/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx new file mode 100644 index 00000000..2a2ff5b3 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/JobStatusBadge.tsx @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { STATUS_LABELS } from './constants'; + +interface JobStatusBadgeProps { + status: string; + size?: 'normal' | 'small'; +} + +const JobStatusBadge = ( { status, size = 'normal' }: JobStatusBadgeProps ) => { + return ( + + { STATUS_LABELS[ status ] || status } + + ); +}; + +export default JobStatusBadge; diff --git a/assets/src/components/SiteIndexableEntities/ReindexModal.tsx b/assets/src/components/SiteIndexableEntities/ReindexModal.tsx new file mode 100644 index 00000000..06244a74 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/ReindexModal.tsx @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { Modal } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { JobStatus, SiteJobState } from './types'; +import ReindexModalContent from './ReindexModalContent'; + +interface ReindexModalProps { + reindexing: boolean; + siteStates: SiteJobState[]; + cancelling: boolean; + history: JobStatus[]; + historyPage: number; + historyTotalPages: number; + retryingJobId: string | null; + onClose: () => void; + onReIndex: () => void; + onCancelJob: () => void; + onRetryHistoryJob: ( job: JobStatus ) => void; + onPageChange: ( page: number ) => void; +} + +const ReindexModal = ( { + reindexing, + siteStates, + cancelling, + history, + historyPage, + historyTotalPages, + retryingJobId, + onClose, + onReIndex, + onCancelJob, + onRetryHistoryJob, + onPageChange, +}: ReindexModalProps ) => ( + + + +); + +export default ReindexModal; diff --git a/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx new file mode 100644 index 00000000..e9425fd8 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/ReindexModalContent.tsx @@ -0,0 +1,157 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +/** + * Internal dependencies + */ +import type { JobStatus, SiteJobState } from './types'; +import HistoryPagination from './HistoryPagination'; +import HistoryTable from './HistoryTable'; +import SiteRow from './SiteRow'; + +interface ReindexModalContentProps { + reindexing: boolean; + siteStates: SiteJobState[]; + cancelling: boolean; + history: JobStatus[]; + historyPage: number; + historyTotalPages: number; + onReIndex: () => void; + onCancelJob: () => void; + retryingJobId: string | null; + onRetryHistoryJob: ( job: JobStatus ) => void; + onPageChange: ( page: number ) => void; +} + +const ReindexModalContent = ( { + reindexing, + siteStates, + cancelling, + history, + historyPage, + historyTotalPages, + onReIndex, + onCancelJob, + retryingJobId, + onRetryHistoryJob, + onPageChange, +}: ReindexModalContentProps ) => ( + <> + { ! reindexing && siteStates.length === 0 && ( +
+

+ { __( + 'Re-indexing will only index the entities you have previously saved. To re-index modified entities, please make sure you have saved them.', + 'onesearch' + ) } +

+
+ +
+
+ ) } + + { 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 00000000..e6fa82e0 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/SiteRow.tsx @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { __experimentalText as Text } from '@wordpress/components'; +/** + * Internal dependencies + */ +import type { SiteJobState } from './types'; +import JobStatusBadge from './JobStatusBadge'; + +interface SiteProgressProps { + state: SiteJobState; +} + +const SiteProgress = ( { state }: SiteProgressProps ) => { + const j = state.reindexJob; + if ( ! j ) { + return …; + } + const childTotal = state.children.length; + if ( childTotal > 0 ) { + const done = state.children.filter( ( c ) => + [ 'completed', 'failed' ].includes( c.status ) + ).length; + return ( + + { done } / { childTotal } { __( 'batches', 'onesearch' ) } + + ); + } + return ; +}; + +interface SiteRowProps { + state: SiteJobState; +} + +const SiteRow = ( { state }: SiteRowProps ) => ( +
+
+ + { state.site.site_name } + + + { state.site.site_url } + +
+ +
+
+
+); + +export default SiteRow; diff --git a/assets/src/components/SiteIndexableEntities/constants.ts b/assets/src/components/SiteIndexableEntities/constants.ts new file mode 100644 index 00000000..67e7761b --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/constants.ts @@ -0,0 +1,9 @@ +export const STATUS_LABELS: Record< string, string > = { + pending: 'Pending', + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +export const TERMINAL_JOB_STATUSES = [ 'completed', 'failed', 'cancelled' ]; diff --git a/assets/src/components/SiteIndexableEntities/index.tsx b/assets/src/components/SiteIndexableEntities/index.tsx new file mode 100644 index 00000000..0a7deac7 --- /dev/null +++ b/assets/src/components/SiteIndexableEntities/index.tsx @@ -0,0 +1,275 @@ +/** + * WordPress dependencies + */ +import { Button, Card, CardBody, CardHeader } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +/** + * External dependencies + */ +import { useCallback, useEffect, useState } from 'react'; +import type { NoticeType } from '@/admin/settings/page'; +import { API_NAMESPACE, NONCE, withTrailingSlash } from '@/js/utils'; +import type { OneSearchSharedSite } from '@/types/global'; +/** + * Internal dependencies + */ +import type { PostTypeOption } from '../SiteSearchSettings'; +import EntitySiteCard from './EntitySiteCard'; +import ReindexModal from './ReindexModal'; +import type { + EntitiesMap, + IndexableEntitiesResponse, + SaveEntitiesResponse, +} from './types'; +import { normalizeEntities } from './utils'; +import { useReindexJob } from './useReindexJob'; + +interface SiteIndexableEntitiesProps { + sites: OneSearchSharedSite[]; + allPostTypes: Record< string, PostTypeOption[] >; + currentSiteUrl: string; + setNotice: ( notice: NoticeType | null ) => void; + onEntitiesSaved?: () => void; + saving: boolean; + setSaving: ( saving: boolean ) => void; +} + +const SiteIndexableEntities = ( { + sites, + allPostTypes, + currentSiteUrl, + setNotice, + onEntitiesSaved, + saving, + setSaving, +}: SiteIndexableEntitiesProps ) => { + const [ selectedEntities, setSelectedEntities ] = useState< EntitiesMap >( + {} + ); + const [ savedEntities, setSavedEntities ] = useState< EntitiesMap >( {} ); + + const { + reindexing, + showReindexingModal, + setShowReindexingModal, + siteStates, + cancelling, + history, + historyPage, + historyTotalPages, + retryingJobId, + handleReIndex, + handleCancelJob, + handleModalClose, + handleRetryHistoryJob, + fetchHistory, + } = useReindexJob( { currentSiteUrl, setNotice } ); + + const entitySelectorsDisabled = saving || reindexing; + + const getIndexableEntities = useCallback( async () => { + try { + const response = await fetch( + `${ API_NAMESPACE }/indexable-entities`, + { + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + }, + } + ); + const data: IndexableEntitiesResponse = await response.json(); + const incoming: EntitiesMap = + data.indexableEntities?.entities || {}; + setSelectedEntities( incoming ); + setSavedEntities( normalizeEntities( incoming ) ); + } catch { + setNotice( { + type: 'error', + message: __( + 'Error fetching indexable entities.', + 'onesearch' + ), + } ); + } + }, [ setNotice ] ); + + useEffect( () => { + getIndexableEntities(); + }, [ getIndexableEntities ] ); + + const handleSelectedEntitiesChange = ( + selected: string[], + url: string + ) => { + if ( entitySelectorsDisabled ) { + return; + } + setSelectedEntities( ( prev: EntitiesMap ) => ( { + ...prev, + [ url ]: selected, + } ) ); + }; + + const handleSelectedEntitiesSave = async ( + entities: EntitiesMap + ): Promise< boolean > => { + try { + setSaving( true ); + const response = await fetch( + `${ API_NAMESPACE }/indexable-entities`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + }, + body: JSON.stringify( { entities } ), + } + ); + + if ( ! response.ok ) { + throw new Error( + __( 'Network response was not ok.', 'onesearch' ) + ); + } + + const data: SaveEntitiesResponse = await response.json(); + + if ( data.success ) { + setSavedEntities( normalizeEntities( entities ) ); + onEntitiesSaved?.(); + await handleReIndex(); + return true; + } else if ( data.data?.status === 500 ) { + setNotice( { + message: __( 'Internal server error.', 'onesearch' ), + type: 'error', + } ); + } else { + setNotice( { + message: + data.message || __( 'Unknown error.', 'onesearch' ), + type: 'error', + } ); + } + } catch ( error: unknown ) { + const message = + error instanceof Error ? error.message : String( error ); + setNotice( { message, type: 'error' } ); + } finally { + setSaving( false ); + } + return false; + }; + + const isDirty = + JSON.stringify( normalizeEntities( selectedEntities ) ) !== + JSON.stringify( savedEntities ); + + return ( + <> + + +

+ { __( 'Select Entities to Index', 'onesearch' ) } +

+
+
+ + +
+

+ { __( + 'Saving changes will automatically re-index the data.', + 'onesearch' + ) } +

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