diff --git a/.distignore b/.distignore index ac1b7ed..418c1fc 100644 --- a/.distignore +++ b/.distignore @@ -8,6 +8,7 @@ /package-lock.json /phpunit.xml.dist /playwright.config.ts +/playwright.scaling.config.ts /README.md /.DS_Store /.phpunit.result.cache diff --git a/assets/css/admin.css b/assets/css/admin.css index c289f8b..d5f7fba 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -730,6 +730,16 @@ body.toplevel_page_progress-agentic-rag #wpcontent { color: var(--progress-agentic-rag-color-danger-regular); } +.progress-agentic-rag__sync-cancel { + display: flex; + margin: 1rem 0 0 auto; + width: fit-content; +} + +.progress-agentic-rag__sync-cancel[hidden] { + display: none; +} + .progress-agentic-rag__button:disabled { background: hsl(220, 18%, 92%); border-color: hsl(220, 18%, 88%); @@ -920,6 +930,82 @@ body.toplevel_page_progress-agentic-rag #wpcontent { width: 0; } +.progress-agentic-rag__spinner, +.progress-agentic-rag__button--busy::before { + animation: progress-agentic-rag-spin 800ms linear infinite; + border: 2px solid currentcolor; + border-radius: 50%; + border-right-color: transparent; + box-sizing: border-box; + flex: none; + height: 1em; + width: 1em; +} + +.progress-agentic-rag__button--busy::before { + content: ""; + margin-right: var(--progress-agentic-rag-space-075); +} + +.progress-agentic-rag__sync-banner { + align-items: center; + background: var(--progress-agentic-rag-color-light-stronger); + border: 1px solid var(--progress-agentic-rag-surface-border); + border-left: 4px solid var(--progress-agentic-rag-color-primary-regular); + border-radius: var(--progress-agentic-rag-card-border-radius); + color: var(--progress-agentic-rag-color-primary-regular); + display: flex; + gap: var(--progress-agentic-rag-space-2); + margin-bottom: var(--progress-agentic-rag-space-2); + padding: var(--progress-agentic-rag-space-2); +} + +.progress-agentic-rag__sync-banner[hidden] { + display: none; +} + +.progress-agentic-rag__sync-banner--done { + border-left-color: var(--progress-agentic-rag-color-success-regular); + color: var(--progress-agentic-rag-color-success-regular); +} + +.progress-agentic-rag__sync-banner--done .progress-agentic-rag__spinner { + display: none; +} + +.progress-agentic-rag__sync-banner-body { + flex: 1; + min-width: 0; +} + +.progress-agentic-rag__sync-banner-text { + color: var(--progress-agentic-rag-color-dark-stronger); + display: flex; + flex-wrap: wrap; + gap: var(--progress-agentic-rag-space-1); +} + +.progress-agentic-rag__sync-banner-text span { + color: var(--progress-agentic-rag-color-neutral-regular); +} + +.progress-agentic-rag__sync-banner-percent { + font-size: var(--progress-agentic-rag-font-size-display-s); +} + +@keyframes progress-agentic-rag-spin { + to { + transform: rotate( 360deg ); + } +} + +@media (prefers-reduced-motion: reduce) { + .progress-agentic-rag__spinner, + .progress-agentic-rag__button--busy::before { + animation-duration: 3s; + } +} + .progress-agentic-rag__sync-stats { display: grid; gap: var(--progress-agentic-rag-space-1); diff --git a/assets/js/admin.js b/assets/js/admin.js index 842de8b..eccab1b 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -19,6 +19,9 @@ const mappingContainer = document.querySelector( '[data-progress-agentic-rag-mapping-container]' ); const addMappingButton = document.querySelector( '[data-progress-agentic-rag-add-mapping]' ); const modal = document.querySelector( '[data-progress-agentic-rag-sync-modal]' ); + const syncBanner = document.querySelector( '[data-progress-agentic-rag-sync-banner]' ); + const syncDetailsButton = document.querySelector( '[data-progress-agentic-rag-sync-details]' ); + const cancelSyncButton = document.querySelector( '[data-progress-agentic-rag-sync-cancel]' ); const appearanceForm = document.querySelector( '[data-progress-agentic-rag-widget-appearance-form]' ); const appearancePreview = document.querySelector( '[data-progress-agentic-rag-widget-preview]' ); const defaultStartButtonText = config.strings && config.strings.sync ? config.strings.sync : startButton ? startButton.textContent : ''; @@ -85,6 +88,7 @@ let deletePollTimer = null; let backgroundPollTimer = null; let syncStatus = config.initialSyncStatus || {}; + let syncRunObserved = false; let deleteStatus = config.initialDeleteStatus || {}; let backgroundSyncStatus = config.initialBackgroundSyncStatus || {}; const mappingConfig = config.mapping || { taxonomies: {}, labelsets: [] }; @@ -153,6 +157,9 @@ const setModalMode = ( mode ) => { const isDelete = mode === 'delete'; + if ( cancelSyncButton ) { + cancelSyncButton.hidden = isDelete || ! syncIsRunning() || ! syncStatus.cancellable; + } const closeButton = field( selectors.closeButton ); setText( selectors.label, isDelete ? config.strings.deleteModalLabel : config.strings.syncModalLabel ); @@ -236,6 +243,12 @@ }; const closeModal = () => { + // The counters on this page are rendered by PHP, so show the result of a run that finished. + if ( syncRunObserved && ! syncIsRunning() ) { + window.location.reload(); + return; + } + modal.hidden = true; document.body.classList.remove( 'progress-agentic-rag-sync-open' ); if ( pollTimer && ! syncIsRunning() ) { @@ -246,11 +259,42 @@ window.clearTimeout( deletePollTimer ); deletePollTimer = null; } - startButton.disabled = deleteIsRunning() ? true : startButtonInitiallyDisabled; + if ( startButton ) { + startButton.disabled = deleteIsRunning() ? true : startButtonInitiallyDisabled; + } + }; + + const renderBanner = ( status ) => { + if ( ! syncBanner ) { + return; + } + + const running = syncIsRunning(); + const percent = Math.max( 0, Math.min( 100, statusNumber( status, 'percent' ) ) ); + const bar = syncBanner.querySelector( '[data-progress-agentic-rag-banner-bar]' ); + const set = ( selector, value ) => { + const element = syncBanner.querySelector( selector ); + if ( element ) { + element.textContent = value; + } + }; + + // A finished run stays visible until the page is reloaded so its outcome is not lost. + syncBanner.hidden = ! running && ! syncRunObserved; + syncBanner.classList.toggle( 'progress-agentic-rag__sync-banner--done', ! running ); + if ( bar ) { + bar.style.width = percent + '%'; + } + set( '[data-progress-agentic-rag-banner-title]', running ? config.strings.running : config.strings.syncFinished ); + set( '[data-progress-agentic-rag-banner-message]', status.message || '' ); + set( '[data-progress-agentic-rag-banner-percent]', percent + '%' ); }; const renderStatus = ( status ) => { syncStatus = status || {}; + if ( cancelSyncButton ) { + cancelSyncButton.hidden = ! syncIsRunning() || ! status.cancellable; + } if ( pollTimer ) { window.clearTimeout( pollTimer ); pollTimer = null; @@ -271,15 +315,22 @@ setText( selectors.current, status.current || '' ); if ( syncIsRunning() ) { - startButton.textContent = runningButtonText( status ); - startButton.disabled = false; + syncRunObserved = true; + renderBanner( status ); + if ( startButton ) { + startButton.textContent = runningButtonText( status ); + startButton.disabled = false; + startButton.classList.add( 'progress-agentic-rag__button--busy' ); + } pollTimer = window.setTimeout( pollStatus, 2000 ); return; } - if ( status.status !== 'starting' ) { + renderBanner( status ); + if ( startButton && status.status !== 'starting' ) { startButton.textContent = defaultStartButtonText; startButton.disabled = startButtonInitiallyDisabled; + startButton.classList.remove( 'progress-agentic-rag__button--busy' ); } }; @@ -299,8 +350,8 @@ try { renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) ); } catch ( error ) { - startButton.disabled = false; - renderError( error.message ); + setText( selectors.message, config.strings.pollInterrupted ); + pollTimer = window.setTimeout( pollStatus, 10000 ); } }; @@ -332,7 +383,9 @@ if ( deleteIsRunning() ) { deleteSyncedButton.textContent = deleteButtonText( status ); deleteSyncedButton.disabled = false; - startButton.disabled = true; + if ( startButton ) { + startButton.disabled = true; + } deletePollTimer = window.setTimeout( pollDeleteStatus, 2000 ); return; } @@ -348,7 +401,7 @@ if ( status.status !== 'starting' ) { deleteSyncedButton.textContent = defaultDeleteButtonText; deleteSyncedButton.disabled = deleteButtonInitiallyDisabled; - if ( ! syncIsRunning() ) { + if ( startButton && ! syncIsRunning() ) { startButton.disabled = startButtonInitiallyDisabled; } } @@ -809,40 +862,65 @@ } ); } - if ( startButton && modal ) { - startButton.addEventListener( 'click', async () => { - setModalMode( 'sync' ); - openModal(); - - if ( syncIsRunning() ) { + if ( modal ) { + if ( cancelSyncButton ) { + cancelSyncButton.addEventListener( 'click', async () => { + cancelSyncButton.disabled = true; try { - renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) ); + renderStatus( await request( 'progress_agentic_rag_manual_sync_cancel' ) ); } catch ( error ) { - renderError( error.message ); + window.alert( error.message ); + } finally { + cancelSyncButton.disabled = false; } - return; - } + } ); + } - const postTypes = selectedPostTypes(); - startButton.disabled = true; + const showSyncProgress = async () => { + setModalMode( 'sync' ); + openModal(); try { - renderStatus( { - status: 'starting', - total: 0, - completed: 0, - failed: 0, - current: '', - message: config.strings.starting, - percent: 0, - } ); - renderStatus( await request( 'progress_agentic_rag_manual_sync_start', { post_types: postTypes } ) ); + renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) ); } catch ( error ) { - startButton.disabled = false; renderError( error.message ); } - } ); + }; + + if ( syncDetailsButton ) { + syncDetailsButton.addEventListener( 'click', showSyncProgress ); + } + + if ( startButton ) { + startButton.addEventListener( 'click', async () => { + if ( syncIsRunning() ) { + showSyncProgress(); + return; + } + + setModalMode( 'sync' ); + openModal(); + + const postTypes = selectedPostTypes(); + startButton.disabled = true; + try { + renderStatus( { + status: 'starting', + total: 0, + completed: 0, + failed: 0, + current: '', + message: config.strings.starting, + percent: 0, + } ); + renderStatus( await request( 'progress_agentic_rag_manual_sync_start', { post_types: postTypes } ) ); + } catch ( error ) { + startButton.disabled = false; + renderError( error.message ); + } + } ); + } if ( syncIsRunning() ) { renderStatus( syncStatus ); } diff --git a/docker/docker-compose.scaling.yml b/docker/docker-compose.scaling.yml new file mode 100644 index 0000000..bab79b7 --- /dev/null +++ b/docker/docker-compose.scaling.yml @@ -0,0 +1,63 @@ +name: progress-agentic-rag-scaling + +services: + db: + image: mysql:8.0 + environment: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: scaling-only + MYSQL_ROOT_PASSWORD: scaling-root-only + volumes: + - scaling-db:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"] + interval: 2s + timeout: 5s + retries: 60 + + wordpress: + image: wordpress:latest + depends_on: + db: + condition: service_healthy + environment: &wp-environment + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: scaling-only + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: | + define('DISABLE_WP_CRON', true); + define('PROGRESS_AGENTIC_RAG_SCALING_TESTS', true); + volumes: &wp-volumes + - scaling-wp:/var/www/html + - ..:/var/www/html/wp-content/plugins/progress-agentic-rag:ro + - ../tests/scaling/fixtures/scaling.php:/var/www/html/wp-content/mu-plugins/scaling.php:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost/wp-login.php"] + interval: 2s + timeout: 5s + retries: 60 + + setup: + image: wordpress:cli + user: "0" + depends_on: + wordpress: + condition: service_healthy + environment: *wp-environment + volumes: *wp-volumes + entrypoint: ["sh", "/var/www/html/wp-content/plugins/progress-agentic-rag/docker/scripts/setup-scaling.sh"] + + tests: + image: mcr.microsoft.com/playwright:v1.61.0-noble + working_dir: /plugin + environment: + WP_BASE_URL: http://wordpress + volumes: + - ..:/plugin + command: ["sh", "-c", "npx --no-install playwright test -c playwright.scaling.config.ts"] + +volumes: + scaling-db: + scaling-wp: diff --git a/docker/scripts/check-release.sh b/docker/scripts/check-release.sh index f049974..d45bbd4 100644 --- a/docker/scripts/check-release.sh +++ b/docker/scripts/check-release.sh @@ -11,7 +11,7 @@ fi sh "$script_dir/check-release-metadata.sh" -for path in .git .github .distignore .gitignore .DS_Store docker tests node_modules package.json package-lock.json phpunit.xml.dist playwright.config.ts README.md; do +for path in .git .github .distignore .gitignore .DS_Store docker tests node_modules package.json package-lock.json phpunit.xml.dist playwright.config.ts playwright.scaling.config.ts README.md; do if [ -e "$release_dir/$path" ]; then echo "Release artifact includes dev-only path: $path" exit 1 diff --git a/docker/scripts/run-scaling.sh b/docker/scripts/run-scaling.sh new file mode 100644 index 0000000..915237b --- /dev/null +++ b/docker/scripts/run-scaling.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/../.." +compose_file=docker/docker-compose.scaling.yml + +npm ci --quiet --ignore-scripts + +# These services and volumes belong only to the isolated scaling project. +trap 'docker compose -f "$compose_file" stop wordpress db >/dev/null' EXIT +docker compose -f "$compose_file" up -d --wait db wordpress +docker compose -f "$compose_file" run --rm setup +docker compose -f "$compose_file" run --rm tests diff --git a/docker/scripts/setup-scaling.sh b/docker/scripts/setup-scaling.sh new file mode 100644 index 0000000..f44cfd5 --- /dev/null +++ b/docker/scripts/setup-scaling.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +cd /var/www/html +if ! wp core is-installed --allow-root; then + wp core install --allow-root --url=http://wordpress --title="RAG scaling tests" \ + --admin_user=admin --admin_password=scaling-only --admin_email=scaling@example.test --skip-email +fi +wp plugin activate progress-agentic-rag --allow-root --url=http://wordpress +wp option update progress_agentic_rag_e2e_key scaling-only --allow-root --url=http://wordpress diff --git a/package.json b/package.json index 8d353a6..571443d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { - "test:e2e": "playwright test -c playwright.config.ts" + "test:e2e": "playwright test -c playwright.config.ts", + "test:scaling": "sh docker/scripts/run-scaling.sh" }, "devDependencies": { "@playwright/test": "1.61.0", diff --git a/playwright.scaling.config.ts b/playwright.scaling.config.ts new file mode 100644 index 0000000..15c86d5 --- /dev/null +++ b/playwright.scaling.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/scaling', + testMatch: '**/*.spec.ts', + timeout: 120000, + expect: { timeout: 10000 }, + retries: 0, + workers: 1, + reporter: [['list'], ['html', { outputFolder: 'build/scaling-report', open: 'never' }]], + outputDir: 'build/scaling-results', + use: { + baseURL: process.env.WP_BASE_URL || 'http://wordpress', + trace: 'retain-on-failure', + screenshot: 'only-on-failure' + }, + projects: [{ name: 'scaling', use: { ...devices['Desktop Chrome'] } }] +}); diff --git a/readme.txt b/readme.txt index ed30e68..f4aba6e 100644 --- a/readme.txt +++ b/readme.txt @@ -74,6 +74,14 @@ value is passed as the second argument. * Breaking: minimum PHP raised from 8.1 to 8.2. PHP 8.1 no longer receives security fixes, including for the critical "php2shell" RCE vulnerabilities (CVE-2026-109618, CVE-2026-109619). Update to PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later (8.3+ recommended). +* Fix: Sync no longer times out (504) or hits the Progress API rate limit (429) on sites with many + resources. The resource list is now paginated correctly, the sync status poll is read-only, and + saving settings or clicking Sync starts a resumable background run instead of scanning the whole + knowledge base inside one web request. A conflict on an existing resource is resolved with a + single lookup by slug. +* Improved: A progress banner on every plugin tab shows the sync state. The button is now labelled + "Sync", shows a spinner while a run is active, and opens details with a Cancel option. +* Improved: Rate-limit responses now show "Rate limit reached; retry shortly" instead of a generic lookup error. * Dev: PHP lint and PHPUnit in CI now run on PHP 8.3. = 1.0.3 = diff --git a/src/Admin/AdminPage.php b/src/Admin/AdminPage.php index c7299b1..0a0fa07 100644 --- a/src/Admin/AdminPage.php +++ b/src/Admin/AdminPage.php @@ -43,6 +43,7 @@ public function register(): void { add_action( 'admin_post_progress_agentic_rag_save_widget_appearance', [ $this, 'save_widget_appearance_settings' ] ); add_action( 'wp_ajax_progress_agentic_rag_manual_sync_start', [ $this, 'start_manual_sync' ] ); add_action( 'wp_ajax_progress_agentic_rag_manual_sync_status', [ $this, 'manual_sync_status' ] ); + add_action( 'wp_ajax_progress_agentic_rag_manual_sync_cancel', [ $this, 'cancel_manual_sync' ] ); add_action( 'wp_ajax_progress_agentic_rag_background_sync_status', [ $this, 'background_sync_status' ] ); add_action( 'wp_ajax_progress_agentic_rag_delete_synced_resources', [ $this, 'delete_synced_resources' ] ); add_action( 'wp_ajax_progress_agentic_rag_delete_synced_resources_status', [ $this, 'delete_synced_resources_status' ] ); @@ -115,17 +116,19 @@ public function enqueue_assets( string $hook_suffix ): void { 'deleteCurrentLabel' => __( 'Current resource', 'progress-agentic-rag-connector' ), 'deleteRunning' => __( 'Delete in progress', 'progress-agentic-rag-connector' ), 'deleting' => __( 'Preparing synced resource deletion.', 'progress-agentic-rag-connector' ), - 'failed' => __( 'Manual sync could not start.', 'progress-agentic-rag-connector' ), + 'failed' => __( 'Sync could not start.', 'progress-agentic-rag-connector' ), 'running' => __( 'Sync in progress', 'progress-agentic-rag-connector' ), - 'syncModalLabel' => __( 'Manual sync', 'progress-agentic-rag-connector' ), + 'syncModalLabel' => __( 'Sync', 'progress-agentic-rag-connector' ), 'syncModalTitle' => __( 'Syncing selected content', 'progress-agentic-rag-connector' ), 'syncTotalLabel' => __( 'Total entities', 'progress-agentic-rag-connector' ), 'syncDoneLabel' => __( 'Synced', 'progress-agentic-rag-connector' ), 'syncCurrentLabel' => __( 'Current entity', 'progress-agentic-rag-connector' ), - 'sync' => __( 'Sync manually', 'progress-agentic-rag-connector' ), + 'sync' => __( 'Sync', 'progress-agentic-rag-connector' ), + 'syncFinished' => __( 'Sync finished', 'progress-agentic-rag-connector' ), 'reprocessFailed' => __( 'Label reprocessing could not start.', 'progress-agentic-rag-connector' ), - 'starting' => __( 'Preparing manual sync.', 'progress-agentic-rag-connector' ), - 'complete' => __( 'Manual sync complete.', 'progress-agentic-rag-connector' ), + 'starting' => __( 'Preparing sync.', 'progress-agentic-rag-connector' ), + 'pollInterrupted' => __( 'Connection interrupted. Background sync continues; reconnecting…', 'progress-agentic-rag-connector' ), + 'complete' => __( 'Sync complete.', 'progress-agentic-rag-connector' ), 'mappingSelectLabelset' => __( 'Select a labelset', 'progress-agentic-rag-connector' ), 'mappingRemove' => __( 'Remove', 'progress-agentic-rag-connector' ), 'mappingLabelset' => __( 'Labelset', 'progress-agentic-rag-connector' ), @@ -164,7 +167,10 @@ public function render(): void { $token_status_label = $this->settings->get_api_is_reachable() ? __( 'Saved', 'progress-agentic-rag-connector' ) : __( 'Stored, unverified', 'progress-agentic-rag-connector' ); } + $manual_sync_status = $this->manual_sync->status(); + require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/page-open.php'; + require PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/sync-banner.php'; $this->render_tabs( $active_tab ); if ( self::TAB_CONNECTION === $active_tab ) { $this->render_connection_settings(); @@ -182,6 +188,7 @@ public function render(): void { $this->render_indexation(); } $this->render_updated_notice(); + require PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/manual-sync-modal.php'; require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/page-close.php'; } @@ -200,10 +207,11 @@ public function save_connection_settings(): void { $this->settings->update_connection_settings( $zone, $kbid, $account_id, $token ); $connected = $this->validate_connection_settings(); $this->settings->set_api_is_reachable( $connected ); + $sync_started = false; if ( $connected ) { - $this->manual_sync->ensure_automatic_sync( true ); + $sync_started = $this->start_sync_after_save(); } - $this->redirect_to_tab( self::TAB_CONNECTION, $connected ? 'connected' : 'failed' ); + $this->redirect_to_tab( self::TAB_CONNECTION, $connected ? 'connected' : 'failed', $sync_started ); } public function save_indexation_settings(): void { @@ -230,8 +238,7 @@ public function save_indexation_settings(): void { $this->settings->update_indexable_post_types( $selected ); $this->manual_sync->cancel_post_type_sync( array_values( array_diff( $previous, $selected ) ) ); - $this->manual_sync->ensure_automatic_sync( true ); - $this->redirect_to_tab( self::TAB_INDEXATION ); + $this->redirect_to_tab( self::TAB_INDEXATION, '', $this->start_sync_after_save() ); } public function save_taxonomy_labeling_settings(): void { @@ -244,8 +251,7 @@ public function save_taxonomy_labeling_settings(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked above; SettingsRepository sanitizes the nested map. $raw_taxonomy_map = isset( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) && is_array( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) ? wp_unslash( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) : []; $this->settings->update_taxonomy_label_map( $raw_taxonomy_map ); - $this->manual_sync->ensure_automatic_sync( true ); - $this->redirect_to_tab( self::TAB_TAXONOMY_LABELING ); + $this->redirect_to_tab( self::TAB_TAXONOMY_LABELING, '', $this->start_sync_after_save() ); } public function save_widget_appearance_settings(): void { @@ -304,6 +310,18 @@ public function manual_sync_status(): void { wp_send_json_success( $this->manual_sync->status() ); } + public function cancel_manual_sync(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'You do not have permission to cancel sync.', 'progress-agentic-rag-connector' ) ], 403 ); + } + check_ajax_referer( 'progress_agentic_rag_manual_sync' ); + $result = $this->manual_sync->cancel_manual_sync(); + if ( is_wp_error( $result ) ) { + wp_send_json_error( [ 'message' => sanitize_text_field( $result->get_error_message() ) ], 400 ); + } + wp_send_json_success( $result ); + } + public function background_sync_status(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'You do not have permission to view automatic sync status.', 'progress-agentic-rag-connector' ) ], 403 ); @@ -311,7 +329,7 @@ public function background_sync_status(): void { check_ajax_referer( 'progress_agentic_rag_manual_sync' ); - wp_send_json_success( $this->manual_sync->ensure_automatic_sync() ); + wp_send_json_success( $this->manual_sync->background_sync_status() ); } public function delete_synced_resources(): void { @@ -528,11 +546,13 @@ private function render_updated_notice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Connection status is read-only redirect state. $connection_status = isset( $_GET['connection-status'] ) ? sanitize_key( wp_unslash( $_GET['connection-status'] ) ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice flag is read-only redirect state. + $sync_note = isset( $_GET['sync-started'] ) ? ' ' . __( 'A content sync has started in the background. You can follow its progress at the top of this page.', 'progress-agentic-rag-connector' ) : ''; if ( 'connected' === $connection_status ) { $notice_type = 'success'; $notice_role = 'status'; - $notice_message = __( 'Connection settings saved. Progress Agentic RAG validated the connection successfully.', 'progress-agentic-rag-connector' ); + $notice_message = __( 'Connection settings saved. Progress Agentic RAG validated the connection successfully.', 'progress-agentic-rag-connector' ) . $sync_note; require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/notice.php'; return; } @@ -547,7 +567,7 @@ private function render_updated_notice(): void { $notice_type = 'success'; $notice_role = 'status'; - $notice_message = __( 'Settings saved.', 'progress-agentic-rag-connector' ); + $notice_message = __( 'Settings saved.', 'progress-agentic-rag-connector' ) . $sync_note; require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/notice.php'; } @@ -603,7 +623,7 @@ private function render_indexation(): void { $delete_status = $this->manual_sync->delete_status(); $delete_running = 'running' === ( $delete_status['status'] ?? '' ); $manual_sync_disabled = 'running' !== ( $manual_sync_status['status'] ?? '' ) && ( ! $api_connected || ! $scheduler_available || $delete_running ); - $manual_sync_button_text = __( 'Sync manually', 'progress-agentic-rag-connector' ); + $manual_sync_button_text = __( 'Sync', 'progress-agentic-rag-connector' ); if ( 'running' === ( $manual_sync_status['status'] ?? '' ) ) { $manual_sync_button_text = sprintf( /* translators: 1: processed entity count, 2: total entity count, 3: completion percent. */ @@ -1176,7 +1196,14 @@ private function tab_url( string $tab ): string { ); } - private function redirect_to_tab( string $tab, string $connection_status = '' ): void { + private function start_sync_after_save(): bool { + $was_running = 'running' === ( $this->manual_sync->status()['status'] ?? '' ); + $this->manual_sync->ensure_automatic_sync( true ); + + return ! $was_running && 'running' === ( $this->manual_sync->status()['status'] ?? '' ); + } + + private function redirect_to_tab( string $tab, string $connection_status = '', bool $sync_started = false ): void { $args = [ 'progress-agentic-rag-updated' => 'true', ]; @@ -1185,6 +1212,10 @@ private function redirect_to_tab( string $tab, string $connection_status = '' ): $args['connection-status'] = $connection_status; } + if ( $sync_started ) { + $args['sync-started'] = '1'; + } + wp_safe_redirect( add_query_arg( $args, diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php index 35264ba..87a8eb0 100644 --- a/src/Api/ApiClient.php +++ b/src/Api/ApiClient.php @@ -362,97 +362,67 @@ public function get_synced_resources(): array { } /** - * @param list $post_ids + * Fetch exactly one page for checkpointed background preparation. + * + * @return array{resources:list,last:bool,fingerprint:string}|WP_Error */ - public function recover_existing_resources( array $post_ids ): int|WP_Error { - $slugs = []; - - foreach ( $post_ids as $post_id ) { - $post_id = (int) $post_id; - if ( $post_id > 0 ) { - $slugs[ (string) $post_id ] = $post_id; - } - } - - if ( empty( $slugs ) ) { - return 0; + public function resource_page( int $page ): array|WP_Error { + $endpoint = $this->endpoint(); + $token = $this->settings->get_string( SettingsRepository::OPTION_TOKEN ); + if ( '' === $endpoint || '' === $token ) { + return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] ); } - - $resources = $this->find_resources_by_slugs( array_keys( $slugs ) ); - if ( is_wp_error( $resources ) ) { - return $resources; + $response = wp_remote_request( $endpoint . 'resources?page=' . $page . '&size=250', [ + 'method' => 'GET', + 'headers' => [ 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token ], + 'timeout' => 30, + ] ); + if ( is_wp_error( $response ) ) { + return $response; } - - $recovered = 0; - foreach ( $resources as $slug => $resource ) { - if ( '' === $resource['rid'] ) { - continue; - } - - $this->upsert_index( $slugs[ $slug ], $resource['rid'], $resource['seqid'] ); - $recovered++; + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 200 !== $code ) { + $retry_after = (string) wp_remote_retrieve_header( $response, 'retry-after' ); + $delay = ctype_digit( $retry_after ) ? (int) $retry_after : max( 0, (int) strtotime( $retry_after ) - time() ); + return new WP_Error( + 429 === $code ? 'progress_agentic_rag_rate_limited' : 'progress_agentic_rag_resource_page_failed', + 429 === $code + ? __( 'Progress Agentic RAG rate limit reached. Preparation will retry after a delay.', 'progress-agentic-rag-connector' ) + : $this->response_error_message( $response, __( 'Could not retrieve a resource page.', 'progress-agentic-rag-connector' ) ), + [ 'retryable' => 429 === $code || $code >= 500, 'retry_after' => max( 30, min( 3600, $delay ) ) ] + ); } - - return $recovered; - } - - /** - * @return array{checked:int,removed:int,updated:int}|WP_Error - */ - public function reconcile_synced_resources(): array|WP_Error { - $mapped = []; - - foreach ( $this->get_synced_resources() as $resource ) { - $post_id = isset( $resource->post_id ) ? (int) $resource->post_id : 0; - $rid = isset( $resource->nuclia_rid ) ? sanitize_text_field( (string) $resource->nuclia_rid ) : ''; - if ( $post_id <= 0 || '' === $rid ) { - continue; + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $pagination = is_array( $body['pagination'] ?? null ) ? $body['pagination'] : []; + if ( ! is_array( $body['resources'] ?? null ) || ! array_is_list( $body['resources'] ) + || ( isset( $body['pagination'] ) && ! is_array( $body['pagination'] ) ) + || ( array_key_exists( 'last', $pagination ) && ! is_bool( $pagination['last'] ) ) + || ( isset( $pagination['page'] ) && ( ( ! is_int( $pagination['page'] ) && ! is_string( $pagination['page'] ) ) + || ! ctype_digit( (string) $pagination['page'] ) || $page !== (int) $pagination['page'] ) ) ) { + return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an invalid resource page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] ); + } + $resources = []; + foreach ( $body['resources'] as $resource ) { + $rid = is_array( $resource ) ? ( $resource['id'] ?? $resource['uuid'] ?? '' ) : ''; + $slug = $resource['slug'] ?? ''; + $seqid = $resource['last_seqid'] ?? $resource['seqid'] ?? ''; + if ( ! is_string( $rid ) || '' === $rid || ! is_scalar( $slug ) || ! is_scalar( $seqid ) ) { + return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an invalid resource. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] ); } - - $mapped[ (string) $post_id ] = [ - 'post_id' => $post_id, - 'rid' => $rid, - 'seqid' => isset( $resource->nuclia_seqid ) ? sanitize_text_field( (string) $resource->nuclia_seqid ) : '', - ]; - } - - if ( empty( $mapped ) ) { - return [ - 'checked' => 0, - 'removed' => 0, - 'updated' => 0, + $resources[] = [ + 'slug' => (string) $slug, + 'rid' => sanitize_text_field( $rid ), + 'seqid' => sanitize_text_field( (string) $seqid ), ]; } - - $resources = $this->find_resources_by_slugs( array_keys( $mapped ) ); - if ( is_wp_error( $resources ) ) { - return $resources; - } - - $removed = 0; - $updated = 0; - foreach ( $mapped as $slug => $local ) { - $resource = $resources[ $slug ] ?? [ - 'rid' => '', - 'seqid' => '', - ]; - - if ( '' === $resource['rid'] ) { - $this->delete_index( $local['post_id'] ); - $removed++; - continue; - } - - if ( $resource['rid'] !== $local['rid'] || ( '' !== $resource['seqid'] && $resource['seqid'] !== $local['seqid'] ) ) { - $this->upsert_index( $local['post_id'], $resource['rid'], '' !== $resource['seqid'] ? $resource['seqid'] : $local['seqid'] ); - $updated++; - } + $last = array_key_exists( 'last', $pagination ) ? (bool) $pagination['last'] : count( $resources ) < 250; + if ( empty( $resources ) && ! $last ) { + return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an empty non-final page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] ); } - return [ - 'checked' => count( $mapped ), - 'removed' => $removed, - 'updated' => $updated, + 'resources' => $resources, + 'last' => $last, + 'fingerprint' => hash( 'sha256', wp_json_encode( array_column( $resources, 'rid' ) ) ), ]; } @@ -474,94 +444,58 @@ private function endpoint(): string { /** * @return array{rid:string,seqid:string}|WP_Error */ - private function find_resource_by_slug( string $slug ): array|WP_Error { - if ( '' === $slug ) { - return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) ); - } - - $resources = $this->find_resources_by_slugs( [ $slug ] ); - if ( is_wp_error( $resources ) ) { - return $resources; - } - - return $resources[ $slug ] ?? [ - 'rid' => '', - 'seqid' => '', - ]; - } - - /** - * @param list $slugs - * - * @return array|WP_Error - */ - private function find_resources_by_slugs( array $slugs ): array|WP_Error { + public function find_resource_by_slug( string $slug ): array|WP_Error { $endpoint = $this->endpoint(); $token = $this->settings->get_string( SettingsRepository::OPTION_TOKEN ); - $lookup = []; - - foreach ( $slugs as $slug ) { - $slug = trim( (string) $slug ); - if ( '' !== $slug ) { - $lookup[ $slug ] = true; - } + if ( '' === $slug || '' === $endpoint || '' === $token ) { + return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) ); } - if ( empty( $lookup ) ) { - return []; - } + $response = wp_remote_request( + $endpoint . 'slug/' . rawurlencode( $slug ), + [ + 'method' => 'GET', + 'headers' => [ + 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token, + ], + 'timeout' => 30, + ] + ); - if ( '' === $endpoint || '' === $token ) { - return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) ); + if ( is_wp_error( $response ) ) { + return $response; } - $page_size = 250; - $offset = 0; - $matches = []; + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 404 === $code ) { + return [ + 'rid' => '', + 'seqid' => '', + ]; + } - do { - $response = wp_remote_request( - $endpoint . 'resources?from=' . $offset . '&size=' . $page_size, - [ - 'method' => 'GET', - 'headers' => [ - 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token, - ], - 'timeout' => 30, - ] + if ( 200 !== $code ) { + return new WP_Error( + 429 === $code ? 'progress_agentic_rag_rate_limited' : 'progress_agentic_rag_existing_resource_lookup_failed', + 429 === $code + ? __( 'Progress Agentic RAG rate limit reached. Please retry shortly.', 'progress-agentic-rag-connector' ) + : $this->response_error_message( $response, __( 'Progress Agentic RAG could not look up the existing resource.', 'progress-agentic-rag-connector' ) ) ); + } - if ( is_wp_error( $response ) ) { - return $response; - } - - if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { - return new WP_Error( 'progress_agentic_rag_existing_resource_lookup_failed', $this->response_error_message( $response, __( 'Progress Agentic RAG could not look up the existing resource.', 'progress-agentic-rag-connector' ) ) ); - } - - $api_response = json_decode( wp_remote_retrieve_body( $response ), true ); - $resources = is_array( $api_response['resources'] ?? null ) ? $api_response['resources'] : []; - - foreach ( $resources as $resource ) { - $slug = is_array( $resource ) ? (string) ( $resource['slug'] ?? '' ) : ''; - if ( ! isset( $lookup[ $slug ] ) ) { - continue; - } - - $matches[ $slug ] = [ - 'rid' => sanitize_text_field( (string) ( $resource['id'] ?? $resource['uuid'] ?? '' ) ), - 'seqid' => sanitize_text_field( (string) ( $resource['last_seqid'] ?? $resource['seqid'] ?? '' ) ), - ]; - - if ( count( $matches ) === count( $lookup ) ) { - break 2; - } - } + $resource = json_decode( wp_remote_retrieve_body( $response ), true ); + $resource = is_array( $resource ) ? $resource : []; + $rid = sanitize_text_field( (string) ( $resource['id'] ?? $resource['uuid'] ?? '' ) ); - $offset += $page_size; - } while ( count( $resources ) === $page_size ); + // Only a 404 means "missing"; a 200 without an id (e.g. a proxy error page) must not look like one. + if ( '' === $rid ) { + return new WP_Error( 'progress_agentic_rag_existing_resource_lookup_failed', __( 'Progress Agentic RAG returned an unexpected response while looking up the existing resource.', 'progress-agentic-rag-connector' ) ); + } - return $matches; + return [ + 'rid' => $rid, + 'seqid' => sanitize_text_field( (string) ( $resource['last_seqid'] ?? $resource['seqid'] ?? '' ) ), + ]; } /** diff --git a/src/Indexing/ManualPreparation.php b/src/Indexing/ManualPreparation.php new file mode 100644 index 0000000..821c8e5 --- /dev/null +++ b/src/Indexing/ManualPreparation.php @@ -0,0 +1,459 @@ +yield_runner; + } + + public function begin( array $types, string $trigger = 'manual' ): array|WP_Error { + if ( ! $this->store->lock() ) { + return new WP_Error( 'progress_agentic_rag_sync_busy', __( 'Sync is busy. Please retry shortly.', 'progress-agentic-rag-connector' ) ); + } + try { + $current = $this->state(); + if ( 'running' === ( $current['status'] ?? '' ) ) { + return $current; + } + // A persisted recovery event closes the crash window before the first action is queued. + if ( ! $this->arm_recovery() ) { + return new WP_Error( 'progress_agentic_rag_recovery_unavailable', __( 'Could not schedule sync recovery. Check WordPress cron.', 'progress-agentic-rag-connector' ) ); + } + $state = [ + 'id' => wp_generate_uuid4(), 'status' => 'running', 'orchestrated' => true, + 'phase' => 'mappings', 'step' => 0, 'cursor' => 0, 'page' => 0, 'retry_count' => 0, + 'post_types' => $types, 'source' => $this->source(), 'trigger' => $trigger, + 'total' => 0, 'completed' => 0, 'failed' => 0, 'recovered' => 0, + 'current' => __( 'Waiting for background preparation.', 'progress-agentic-rag-connector' ), + 'message' => __( 'Preparing sync…', 'progress-agentic-rag-connector' ), + 'started_at' => time(), 'updated_at' => time(), + ]; + $this->save( $state ); + if ( ! $this->schedule( $state, time() ) ) { + $this->fail( $state, __( 'Could not queue sync preparation. Please retry.', 'progress-agentic-rag-connector' ) ); + return new WP_Error( 'progress_agentic_rag_schedule_failed', $state['message'] ); + } + return $state; + } finally { + $this->store->unlock(); + } + } + + public function process( string $run, int $step ): void { + if ( ! $this->store->lock() ) { + $this->scheduler->schedule_single_action( time() + 30, self::HOOK, [ 'sync_id' => $run, 'step' => $step ], self::GROUP ); + return; + } + try { + $state = $this->state(); + if ( ! $this->active( $state, $run ) || empty( $state['phase'] ) || $step !== (int) $state['step'] ) { + return; + } + $this->yield_runner = true; + if ( (int) ( $state['next_attempt'] ?? 0 ) > time() ) { + $this->schedule( $state, $state['next_attempt'] ); + return; + } + if ( $state['source'] !== $this->source() || ! $this->settings->get_api_is_reachable() ) { + $this->fail( $state, __( 'Connection settings changed during preparation. Start a new sync.', 'progress-agentic-rag-connector' ) ); + return; + } + $this->arm_recovery(); + $types = array_values( array_intersect( $state['post_types'], array_keys( array_filter( $this->settings->get_indexable_post_types() ) ) ) ); + if ( empty( $types ) ) { + $this->fail( $state, __( 'Selected content types are no longer enabled. Start a new sync.', 'progress-agentic-rag-connector' ) ); + return; + } + switch ( $state['phase'] ) { + case 'mappings': + if ( empty( $state['table_ready'] ) ) { + $this->store->initialize(); + $state += $this->store->bounds(); + $state['table_ready'] = true; + $this->save( $state ); + } + $chunk = $this->store->snapshot_mappings( $run, $state['cursor'], $state['mapping_limit'] ); + $state['cursor'] = $chunk['cursor']; + if ( $chunk['done'] ) { + $state['phase'] = 'candidates'; + $state['cursor'] = 0; + } + break; + case 'candidates': + $chunk = $this->store->snapshot_candidates( $run, $state['cursor'], $state['post_limit'], $types ); + $state['cursor'] = $chunk['cursor']; + if ( $chunk['done'] ) { + $state['phase'] = 'scan'; + $state['cursor'] = 0; + } + break; + case 'scan': + $remote = $this->remote( $run, $step, fn () => $this->api->resource_page( $state['page'] ) ); + if ( null === $remote ) { + return; + } + [ $state, $page ] = $remote; + if ( is_wp_error( $page ) ) { + $this->retry( $state, $page ); + return; + } + if ( ! empty( $page['resources'] ) && ( $state['fingerprint'] ?? null ) === $page['fingerprint'] ) { + $this->fail( $state, __( 'Resource lookup stopped because the API repeated a page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ) ); + return; + } + $this->store->record_page( $run, $page['resources'] ); + $state['fingerprint'] = $page['fingerprint']; + $state['page']++; + if ( $page['last'] ) { + $state['phase'] = 'verify'; + $state['cursor'] = 0; + } + break; + case 'verify': + // The listing is paginated by offset without a snapshot, so a resource can be skipped while the KB changes. + // Confirm each mapping the scan did not see before apply may delete it. + $rows = $this->store->unseen( $run, $state['cursor'], self::VERIFY_BATCH ); + $remote = empty( $rows ) ? [ $state, [ 'found' => [], 'error' => null ] ] : $this->remote( $run, $step, function () use ( $rows ) { + $found = []; + foreach ( $rows as $row ) { + $resource = $this->api->find_resource_by_slug( (string) $row->post_id ); + if ( is_wp_error( $resource ) ) { + return [ 'found' => $found, 'error' => $resource ]; + } + $found[ (int) $row->post_id ] = $resource; + } + return [ 'found' => $found, 'error' => null ]; + } ); + if ( null === $remote ) { + return; + } + [ $state, $verified ] = $remote; + foreach ( $verified['found'] as $post_id => $resource ) { + if ( '' !== $resource['rid'] ) { + $this->store->record_page( $run, [ [ 'slug' => (string) $post_id, 'rid' => $resource['rid'], 'seqid' => $resource['seqid'] ] ] ); + } + $state['cursor'] = $post_id; + } + if ( null !== $verified['error'] ) { + $this->retry( $state, $verified['error'] ); + return; + } + if ( count( $rows ) < self::VERIFY_BATCH ) { + $state['phase'] = 'apply'; + $state['cursor'] = 0; + } + break; + case 'apply': + $chunk = $this->store->apply_page( $run, $state['cursor'], $types ); + $state['cursor'] = $chunk['cursor']; + if ( $chunk['done'] ) { + $state['phase'] = 'queue'; + $state['cursor'] = 0; + } + break; + case 'queue': + $rows = $this->store->candidates( $run, $state['cursor'], $types ); + foreach ( $rows as $offset => $row ) { + if ( ! $this->schedule_item( $run, (int) $row->post_id, $row->post_type, time() + ( ( $state['total'] + $offset ) * 2 ) ) ) { + throw new \RuntimeException( __( 'Could not queue a resource sync.', 'progress-agentic-rag-connector' ) ); + } + $this->store->mark_queued( $run, (int) $row->post_id ); + $state['cursor'] = (int) $row->post_id; + } + $state = array_replace( $state, $this->store->stats( $run ) ); + if ( count( $rows ) < 100 ) { + $state['phase'] = ''; + $state['current'] = __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' ); + $state['message'] = __( 'Preparation complete. Resources are queued for sync.', 'progress-agentic-rag-connector' ); + if ( $state['completed'] + $state['failed'] >= $state['total'] ) { + $state['status'] = 'complete'; + $state['message'] = 0 === $state['total'] + ? __( 'Everything selected is already synced.', 'progress-agentic-rag-connector' ) + : __( 'Sync complete.', 'progress-agentic-rag-connector' ); + if ( 0 === $state['total'] ) { + $state['current'] = __( 'No eligible entities to sync.', 'progress-agentic-rag-connector' ); + if ( $state['recovered'] > 0 ) { + $state['message'] = sprintf( + /* translators: %d is the number of recovered upstream resources. */ + _n( 'Recovered %d existing upstream resource. Everything selected is already synced.', 'Recovered %d existing upstream resources. Everything selected is already synced.', $state['recovered'], 'progress-agentic-rag-connector' ), + $state['recovered'] + ); + } + } + $this->record_terminal( $state ); + } + } + break; + } + $state['retry_count'] = 0; + $state['next_attempt'] = 0; + $state['step']++; + $state['updated_at'] = time(); + if ( ! empty( $state['phase'] ) ) { + $state['current'] = sprintf( + /* translators: %d is the number of KB pages scanned. */ + __( 'Preparing sync: %d KB pages checked.', 'progress-agentic-rag-connector' ), $state['page'] + ); + $state['message'] = __( 'Preparing sync…', 'progress-agentic-rag-connector' ); + } + $this->save( $state ); + if ( ! empty( $state['phase'] ) && ! $this->schedule( $state, time() + 2 ) ) { + throw new \RuntimeException( __( 'Could not queue the next preparation step.', 'progress-agentic-rag-connector' ) ); + } + } catch ( \Throwable $error ) { + $state = $this->state(); + if ( $this->active( $state, $run ) ) { + $this->retry( $state, new WP_Error( 'progress_agentic_rag_preparation_failed', sanitize_text_field( $error->getMessage() ) ) ); + } + } finally { + $this->store->unlock(); + } + } + + public function process_item( int $id, string $type, string $run, callable $sync, string $label = '' ): void { + if ( ! $this->store->lock() ) { + $this->scheduler->schedule_single_action( time() + 30, self::POST_HOOK, [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ], self::GROUP ); + return; + } + try { + $state = $this->state(); + if ( ! $this->active( $state, $run ) || ( ! empty( $state['phase'] ) && 'queue' !== $state['phase'] ) ) { + return; + } + $outcome = $this->store->outcome( $run, $id ); + if ( null === $outcome ) { + return; + } + $result = null; + // An item that already finished only refreshes the counters, which repairs a lost final update. + if ( '' === $outcome ) { + if ( $state['source'] !== $this->source() ) { + $this->fail( $state, __( 'Connection settings changed. Start a new sync.', 'progress-agentic-rag-connector' ) ); + return; + } + if ( $this->store->begin_item( $run, $id ) ) { + if ( '' !== $label ) { + // Written while locked: saving the state after unlocking could undo a concurrent cancel. + $state['current'] = $label; + $state['updated_at'] = time(); + $this->save( $state ); + } + // The upload is slow, so do not hold the lock (and block cancelling) while it runs. + $this->store->unlock(); + try { + $result = $sync(); + $this->store->finish_item( $run, $id, ! is_wp_error( $result ) ); + } finally { + $relocked = $this->store->lock( self::RELOCK_WAIT ); + } + if ( ! $relocked ) { + $this->scheduler->schedule_single_action( time() + 30, self::POST_HOOK, [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ], self::GROUP ); + return; + } + $state = $this->state(); + if ( ! $this->active( $state, $run ) ) { + return; + } + } else { + $result = new WP_Error( 'progress_agentic_rag_item_interrupted', __( 'Resource sync was repeatedly interrupted. Retry this item after checking background scheduling.', 'progress-agentic-rag-connector' ) ); + $this->settings->add_failed_sync_item( $id, $type, get_the_title( $id ), $result->get_error_message(), 'manual' ); + $this->store->finish_item( $run, $id, false ); + } + } + $state = array_replace( $state, $this->store->stats( $run ) ); + if ( is_wp_error( $result ) ) { + $state['current'] = sanitize_text_field( $result->get_error_message() ); + } + if ( empty( $state['phase'] ) && $state['completed'] + $state['failed'] >= $state['total'] ) { + $state['status'] = 'complete'; + $state['message'] = __( 'Sync complete.', 'progress-agentic-rag-connector' ); + $this->record_terminal( $state ); + } + $state['updated_at'] = time(); + $this->save( $state ); + } finally { + $this->store->unlock(); + } + } + + public function recover(): void { + $state = $this->state(); + if ( empty( $state['orchestrated'] ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + $this->arm_recovery(); + if ( ! $this->store->lock() ) { + return; + } + try { + $state = $this->state(); + if ( ! $this->active( $state, (string) ( $state['id'] ?? '' ) ) ) { + return; + } + if ( ! empty( $state['phase'] ) ) { + $args = [ 'sync_id' => $state['id'], 'step' => $state['step'] ]; + if ( ! $this->scheduler->has_action( self::HOOK, $args, self::GROUP ) ) { + $this->retry( $state, new WP_Error( 'progress_agentic_rag_preparation_interrupted', __( 'Preparation was interrupted. Resuming the saved checkpoint.', 'progress-agentic-rag-connector' ) ) ); + } + } elseif ( ! empty( $state['table_ready'] ) ) { + foreach ( $this->store->unfinished( $state['id'] ) as $row ) { + $this->schedule_item( $state['id'], (int) $row->post_id, $row->post_type, time() ); + } + } + } finally { + $this->store->unlock(); + } + } + + public function cancel(): array|WP_Error { + if ( ! $this->store->lock( self::RELOCK_WAIT ) ) { + return new WP_Error( 'progress_agentic_rag_sync_busy', __( 'A sync step is finishing. Please retry cancellation shortly.', 'progress-agentic-rag-connector' ) ); + } + try { + $state = $this->state(); + if ( empty( $state['orchestrated'] ) || 'running' !== ( $state['status'] ?? '' ) ) { + return $state; + } + $state['status'] = 'cancelled'; + $state['phase'] = ''; + $state['message'] = __( 'Sync cancelled. Completed work was retained.', 'progress-agentic-rag-connector' ); + $this->record_terminal( $state ); + $this->save( $state ); + $this->scheduler->unschedule_all_actions( self::HOOK, null, '' ); + $this->scheduler->unschedule_all_actions( self::POST_HOOK, null, '' ); + return $state; + } finally { + $this->store->unlock(); + } + } + + // Runs slow remote work without the lock so a cancel is never blocked behind it. Returns the fresh state and the + // result, or null when the step is no longer current. + private function remote( string $run, int $step, callable $work ): ?array { + $this->store->unlock(); + try { + $result = $work(); + } finally { + $relocked = $this->store->lock( self::RELOCK_WAIT ); + } + if ( ! $relocked ) { + $this->scheduler->schedule_single_action( time() + 30, self::HOOK, [ 'sync_id' => $run, 'step' => $step ], self::GROUP ); + return null; + } + $state = $this->state(); + if ( ! $this->active( $state, $run ) || $step !== (int) $state['step'] ) { + return null; + } + return [ $state, $result ]; + } + + private function retry( array $state, WP_Error $error ): void { + $data = $error->get_error_data(); + $data = is_array( $data ) ? $data : []; + $state['retry_count'] = (int) ( $state['retry_count'] ?? 0 ) + 1; + if ( false === ( $data['retryable'] ?? true ) || $state['retry_count'] > 3 ) { + $this->fail( $state, sanitize_text_field( $error->get_error_message() ) ); + return; + } + $delay = max( 30 * ( 2 ** ( $state['retry_count'] - 1 ) ), (int) ( $data['retry_after'] ?? 0 ) ); + $state['next_attempt'] = time() + min( 3600, $delay ); + $state['updated_at'] = time(); + $state['message'] = sanitize_text_field( $error->get_error_message() ); + $state['current'] = __( 'Waiting to retry the saved preparation step.', 'progress-agentic-rag-connector' ); + $this->save( $state ); + $this->schedule( $state, $state['next_attempt'] ); + } + + private function fail( array &$state, string $message ): void { + $state['status'] = 'failed'; + $state['message'] = $message; + $state['updated_at'] = time(); + $this->record_terminal( $state ); + $this->save( $state ); + } + + private function schedule( array $state, int $at ): bool { + return $this->scheduler->schedule_single_action( $at, self::HOOK, [ 'sync_id' => $state['id'], 'step' => $state['step'] ], self::GROUP ); + } + + private function schedule_item( string $run, int $id, string $type, int $at ): bool { + $args = [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ]; + return $this->scheduler->has_action( self::POST_HOOK, $args, self::GROUP ) + || $this->scheduler->schedule_single_action( $at, self::POST_HOOK, $args, self::GROUP ); + } + + private function arm_recovery(): bool { + return (bool) wp_next_scheduled( self::RECOVERY_HOOK ) + || (bool) wp_schedule_single_event( time() + 120, self::RECOVERY_HOOK ); + } + + private function active( array $state, string $run ): bool { + return ! empty( $state['orchestrated'] ) && $run === ( $state['id'] ?? '' ) && 'running' === ( $state['status'] ?? '' ); + } + + private function state(): array { + $state = get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, [] ); + return is_array( $state ) ? $state : []; + } + + private function save( array $state ): void { + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state, false ); + } + + private function source(): string { + return hash( 'sha256', $this->settings->get_string( SettingsRepository::OPTION_ZONE ) . "\0" + . $this->settings->get_string( SettingsRepository::OPTION_KBID ) . "\0" . $this->settings->get_string( SettingsRepository::OPTION_TOKEN ) ); + } + + private function record_terminal( array &$state ): void { + if ( ! empty( $state['history_recorded'] ) ) { + return; + } + $this->settings->add_sync_history_entry( [ + 'id' => $state['id'], 'type' => 'automatic' === ( $state['trigger'] ?? '' ) ? 'automatic_sync' : 'manual_sync', + 'status' => 'complete' === $state['status'] && $state['failed'] > 0 ? 'failed' : $state['status'], + 'total' => $state['total'], 'completed' => $state['completed'], 'failed' => $state['failed'], + 'message' => $state['message'], 'current' => $state['current'], + 'started_at' => $state['started_at'], 'finished_at' => time(), + ] ); + $state['history_recorded'] = true; + if ( ! empty( $state['table_ready'] ) ) { + $this->store->purge( $state['id'] ); + } + } +} diff --git a/src/Indexing/ManualSync.php b/src/Indexing/ManualSync.php index 12d9861..32c35be 100644 --- a/src/Indexing/ManualSync.php +++ b/src/Indexing/ManualSync.php @@ -28,14 +28,17 @@ final class ManualSync { private readonly SettingsRepository $settings; private readonly ApiClient $api_client; private readonly Scheduler $scheduler; + private readonly ManualPreparation $preparation; - public function __construct( SettingsRepository $settings, ApiClient $api_client, ?Scheduler $scheduler = null ) { + public function __construct( SettingsRepository $settings, ApiClient $api_client, ?Scheduler $scheduler = null, ?PreparationStore $preparation_store = null ) { $this->settings = $settings; $this->api_client = $api_client; $this->scheduler = $scheduler ?? new Scheduler(); + $this->preparation = new ManualPreparation( $settings, $api_client, $this->scheduler, $preparation_store ?? new PreparationStore() ); } public function register(): void { + $this->preparation->register(); add_action( self::HOOK_PROCESS_SINGLE, [ $this, 'process_single_post' ], 10, 3 ); add_action( self::HOOK_BACKGROUND_SYNC, [ $this, 'process_background_post' ], 10, 3 ); add_action( self::HOOK_BACKGROUND_DELETE, [ $this, 'process_background_delete' ], 10, 3 ); @@ -59,7 +62,7 @@ public function start( array $post_types ): array|WP_Error { } if ( 'running' === ( $this->delete_state()['status'] ?? '' ) ) { - return new WP_Error( 'progress_agentic_rag_delete_running', __( 'Wait for synced resource deletion to finish before starting a manual sync.', 'progress-agentic-rag-connector' ) ); + return new WP_Error( 'progress_agentic_rag_delete_running', __( 'Wait for synced resource deletion to finish before starting a sync.', 'progress-agentic-rag-connector' ) ); } if ( ! $this->scheduler_available() ) { @@ -75,80 +78,13 @@ public function start( array $post_types ): array|WP_Error { return new WP_Error( 'progress_agentic_rag_no_post_types', __( 'Select at least one content type to sync.', 'progress-agentic-rag-connector' ) ); } - $reconcile = $this->api_client->reconcile_synced_resources(); - if ( is_wp_error( $reconcile ) ) { - return $reconcile; - } - - $entities = $this->unindexed_entities( $post_types ); - $recovered = 0; - if ( ! empty( $entities ) ) { - $post_ids = []; - foreach ( $entities as $entity ) { - $post_ids[] = $entity['post_id']; - } - - $recovery = $this->api_client->recover_existing_resources( $post_ids ); - if ( is_wp_error( $recovery ) ) { - return $recovery; - } - - $recovered = $recovery; - if ( $recovered > 0 ) { - $entities = $this->unindexed_entities( $post_types ); - } - } - - $sync_id = wp_generate_uuid4(); - $total = count( $entities ); - $message = 0 === $total ? __( 'Everything selected is already synced.', 'progress-agentic-rag-connector' ) : sprintf( - /* translators: %d is the number of entities queued for manual sync. */ - _n( 'Sync in progress: 0 of %d processed.', 'Sync in progress: 0 of %d processed.', $total, 'progress-agentic-rag-connector' ), - $total - ); - - if ( $recovered > 0 ) { - $message = 0 === $total ? sprintf( - /* translators: %d is the number of upstream resources recovered into the local sync table. */ - _n( 'Recovered %d existing upstream resource. Everything selected is already synced.', 'Recovered %d existing upstream resources. Everything selected is already synced.', $recovered, 'progress-agentic-rag-connector' ), - $recovered - ) : sprintf( - /* translators: 1: recovered upstream resource count, 2: number of entities queued for manual sync. */ - _n( 'Recovered %1$d existing upstream resource. Sync in progress: 0 of %2$d processed.', 'Recovered %1$d existing upstream resources. Sync in progress: 0 of %2$d processed.', $recovered, 'progress-agentic-rag-connector' ), - $recovered, - $total - ); - } - - $state = [ - 'id' => $sync_id, - 'status' => 0 === $total ? 'complete' : 'running', - 'total' => $total, - 'completed' => 0, - 'failed' => 0, - 'recovered' => $recovered, - 'current' => 0 === $total ? __( 'No eligible entities to sync.', 'progress-agentic-rag-connector' ) : __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' ), - 'message' => $message, - 'started_at' => time(), - 'updated_at' => time(), - ]; - - update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); - - foreach ( $entities as $offset => $entity ) { - $this->schedule_single_action( - time() + ( $offset * 2 ), - self::HOOK_PROCESS_SINGLE, - [ - 'post_id' => $entity['post_id'], - 'post_type' => $entity['post_type'], - 'sync_id' => $sync_id, - ], - self::GROUP - ); - } + $result = $this->preparation->begin( $post_types ); + return is_wp_error( $result ) ? $result : $this->status(); + } - return $this->status(); + public function cancel_manual_sync(): array|WP_Error { + $result = $this->preparation->cancel(); + return is_wp_error( $result ) ? $result : $this->status(); } /** @@ -161,9 +97,9 @@ public function status(): array { $done = min( $total, $done ); $recovered = (int) ( $state['recovered'] ?? 0 ); - if ( $total > 0 && $done >= $total && 'running' === ( $state['status'] ?? '' ) ) { + if ( empty( $state['orchestrated'] ) && $total > 0 && $done >= $total && 'running' === ( $state['status'] ?? '' ) ) { $state['status'] = 'complete'; - $state['message'] = __( 'Manual sync complete.', 'progress-agentic-rag-connector' ); + $state['message'] = __( 'Sync complete.', 'progress-agentic-rag-connector' ); $state['updated_at'] = time(); $state = $this->record_history( $state, 'manual_sync', (int) ( $state['completed'] ?? 0 ) ); update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); @@ -172,7 +108,8 @@ public function status(): array { $current = (string) ( $state['current'] ?? __( 'Waiting to sync.', 'progress-agentic-rag-connector' ) ); $message = (string) ( $state['message'] ?? '' ); - if ( 'running' === ( $state['status'] ?? '' ) ) { + $preparing = 'running' === ( $state['status'] ?? '' ) && ! empty( $state['phase'] ); + if ( 'running' === ( $state['status'] ?? '' ) && ! $preparing ) { $message = sprintf( /* translators: 1: processed entity count, 2: total entity count. */ __( 'Sync in progress: %1$d of %2$d processed.', 'progress-agentic-rag-connector' ), @@ -194,10 +131,16 @@ public function status(): array { $current = 0 === $done ? __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' ) : __( 'Waiting for the next entity.', 'progress-agentic-rag-connector' ); } } + if ( ! empty( $state['orchestrated'] ) && 'running' === ( $state['status'] ?? '' ) + && time() - (int) ( $state['updated_at'] ?? time() ) > 300 && (int) ( $state['next_attempt'] ?? 0 ) <= time() ) { + $message .= ' ' . __( 'Waiting for background scheduling. Check WordPress cron if progress does not resume.', 'progress-agentic-rag-connector' ); + } return [ 'id' => (string) ( $state['id'] ?? '' ), 'status' => (string) ( $state['status'] ?? 'idle' ), + 'preparing' => $preparing, + 'cancellable' => ! empty( $state['orchestrated'] ) && 'running' === ( $state['status'] ?? '' ), 'total' => $total, 'completed' => (int) ( $state['completed'] ?? 0 ), 'failed' => (int) ( $state['failed'] ?? 0 ), @@ -205,7 +148,7 @@ public function status(): array { 'processed' => $done, 'current' => $current, 'message' => $message, - 'percent' => $total > 0 ? min( 100, (int) floor( ( $done / $total ) * 100 ) ) : 100, + 'percent' => $preparing ? 0 : ( $total > 0 ? min( 100, (int) floor( ( $done / $total ) * 100 ) ) : ( in_array( $state['status'] ?? '', [ 'running', 'failed' ], true ) ? 0 : 100 ) ), ]; } @@ -314,7 +257,12 @@ public function background_sync_status(): array { $completed = (int) ( $state['completed'] ?? 0 ); $failed = (int) ( $state['failed'] ?? 0 ); $background_id = (string) ( $state['id'] ?? '' ); - if ( '' !== $background_id ) { + $pending = $this->count_background_actions( $this->action_status( 'STATUS_PENDING', 'pending' ) ); + $running = $this->count_background_actions( $this->action_status( 'STATUS_RUNNING', 'in-progress' ) ); + + // Counters are kept by the jobs themselves. Only when the queue is idle but the run is still open can a counter + // update have been lost, so that is the only time the finished jobs are counted from the queue. + if ( '' !== $background_id && 'running' === ( $state['status'] ?? '' ) && 0 === $pending && 0 === $running ) { $action_completed = $this->count_background_actions_for_id( $this->action_status( 'STATUS_COMPLETE', 'complete' ), $background_id ); $action_failed = $this->count_background_actions_for_id( $this->action_status( 'STATUS_FAILED', 'failed' ), $background_id ); $completed = max( $completed, $action_completed ); @@ -327,10 +275,16 @@ public function background_sync_status(): array { } } $processed = min( $total, $completed + $failed ); - $pending = $this->count_background_actions( $this->action_status( 'STATUS_PENDING', 'pending' ) ); - $running = $this->count_background_actions( $this->action_status( 'STATUS_RUNNING', 'in-progress' ) ); $action_failed = $this->count_background_actions( $this->action_status( 'STATUS_FAILED', 'failed' ) ); + // Queued jobs that no longer exist (for example purged) can never finish, so stop reporting the run as active. + $updated_at = (int) ( $state['updated_at'] ?? 0 ); + if ( 'running' === ( $state['status'] ?? '' ) && $processed < $total && 0 === $pending && 0 === $running && $updated_at > 0 && time() - $updated_at > 15 * MINUTE_IN_SECONDS ) { + $failed += $total - $processed; + $processed = $total; + $state['failed'] = $failed; + } + if ( $total > 0 && $processed >= $total && 'running' === ( $state['status'] ?? '' ) ) { $state['status'] = 'complete'; $state['message'] = $failed > 0 @@ -373,7 +327,8 @@ public function background_sync_status(): array { } /** - * Queue unsynced selected content for automatic background sync. + * Start a background preparation run for unsynced selected content. + * No remote calls happen in the caller's request; the run reconciles with the KB in scheduled jobs. * * @return array */ @@ -396,34 +351,14 @@ public function ensure_automatic_sync( bool $retry_failed = false ): array { return $this->background_sync_status(); } - $reconcile = $this->api_client->reconcile_synced_resources(); - if ( is_wp_error( $reconcile ) ) { - return $this->background_sync_status(); - } - - $entities = $this->unindexed_entities( $post_types ); - if ( ! empty( $entities ) ) { - $post_ids = []; - foreach ( $entities as $entity ) { - $post_ids[] = $entity['post_id']; - } - - $recovery = $this->api_client->recover_existing_resources( $post_ids ); - if ( ! is_wp_error( $recovery ) && $recovery > 0 ) { - $entities = $this->unindexed_entities( $post_types ); - } - } - $state = $this->background_state(); if ( ! $retry_failed && 'complete' === ( $state['status'] ?? '' ) && (int) ( $state['failed'] ?? 0 ) > 0 && ! empty( $this->settings->get_failed_sync_items() ) ) { return $status; } - $scheduled = $this->schedule_background_entities( $entities ); - $status = $this->background_sync_status(); - $status['scheduled'] = $scheduled; + $result = $this->preparation->begin( $post_types, 'automatic' ); - return $status; + return is_wp_error( $result ) ? $status : $this->status(); } /** @@ -436,7 +371,7 @@ public function delete_synced_resources(): array|WP_Error { } if ( 'running' === ( $this->state()['status'] ?? '' ) ) { - return new WP_Error( 'progress_agentic_rag_manual_sync_running', __( 'Wait for manual sync to finish before deleting synced resources.', 'progress-agentic-rag-connector' ) ); + return new WP_Error( 'progress_agentic_rag_manual_sync_running', __( 'Wait for the sync to finish before deleting synced resources.', 'progress-agentic-rag-connector' ) ); } if ( ! $this->scheduler_available() ) { @@ -508,7 +443,9 @@ public function delete_status(): array { $deleted = (int) ( $state['deleted'] ?? 0 ); $failed = (int) ( $state['failed'] ?? 0 ); $delete_id = (string) ( $state['id'] ?? '' ); - if ( '' !== $delete_id ) { + $delete_open = $this->scheduler->count_actions( self::HOOK_DELETE_RESOURCE, self::GROUP_DELETE, $this->action_status( 'STATUS_PENDING', 'pending' ) ) + + $this->scheduler->count_actions( self::HOOK_DELETE_RESOURCE, self::GROUP_DELETE, $this->action_status( 'STATUS_RUNNING', 'in-progress' ) ); + if ( '' !== $delete_id && 'running' === ( $state['status'] ?? '' ) && 0 === $delete_open ) { $action_deleted = $this->count_delete_actions_for_id( $this->action_status( 'STATUS_COMPLETE', 'complete' ), $delete_id ); $action_failed = $this->count_delete_actions_for_id( $this->action_status( 'STATUS_FAILED', 'failed' ), $delete_id ); $deleted = max( $deleted, $action_deleted ); @@ -669,10 +606,13 @@ public function cancel_post_type_sync( array $post_types ): void { $manual_cancelled = 0; $background_cancelled = 0; $sync_id = (string) ( $this->state()['id'] ?? '' ); + $orchestrated = ! empty( $this->state()['orchestrated'] ); $background_id = (string) ( $this->background_state()['id'] ?? '' ); foreach ( $post_types as $post_type ) { - $manual_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_PROCESS_SINGLE, self::GROUP, $post_type, 'sync_id', $sync_id ); + if ( ! $orchestrated ) { + $manual_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_PROCESS_SINGLE, self::GROUP, $post_type, 'sync_id', $sync_id ); + } $background_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_BACKGROUND_SYNC, self::GROUP_BACKGROUND, $post_type, 'background_id', $background_id ); } @@ -699,6 +639,28 @@ public function label_reprocess_status(): array { public function process_single_post( int $post_id, string $post_type, string $sync_id ): void { $state = $this->state(); + if ( ! empty( $state['orchestrated'] ) ) { + $entity = get_post( $post_id ); + $label = $entity instanceof WP_Post ? $this->entity_label( $entity ) : ''; + $this->preparation->process_item( $post_id, $post_type, $sync_id, function () use ( $post_id, $post_type ): bool|WP_Error { + $post = get_post( $post_id ); + if ( ! $post instanceof WP_Post ) { + return new WP_Error( 'progress_agentic_rag_missing_entity', __( 'Missing entity.', 'progress-agentic-rag-connector' ) ); + } + if ( ! $this->is_selected_post_type( $post_type ) || ! $this->is_indexable_post( $post, $post_type ) || $this->get_resource_id( $post_id ) ) { + $this->settings->remove_failed_sync_item( $post_id, $post_type ); + return true; + } + $result = $this->api_client->index_post( $post ); + if ( is_wp_error( $result ) ) { + $this->settings->add_failed_sync_item( $post_id, $post_type, $this->entity_label( $post ), $this->error_message( $result ), 'manual' ); + return $result; + } + $this->settings->remove_failed_sync_item( $post_id, $post_type ); + return true; + }, $label ); + return; + } if ( $sync_id !== ( $state['id'] ?? '' ) ) { return; } @@ -924,95 +886,6 @@ public function process_background_delete( int $post_id, string $rid, string $ba $this->mark_background_completed( $background_id ); } - /** - * @param list $post_types - * - * @return list - */ - private function unindexed_entities( array $post_types ): array { - global $wpdb; - - $entities = []; - $sync_table_name = $this->sync_table_name(); - - foreach ( $post_types as $post_type ) { - $post_status = 'attachment' === $post_type ? 'inherit' : 'publish'; - $limit = 500; - $offset = 0; - - do { - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Reads plugin-owned sync table; values are prepared and table names are escaped. - $results = $wpdb->get_results( - $wpdb->prepare( - "SELECT p.ID FROM {$wpdb->posts} AS p - LEFT JOIN {$sync_table_name} AS idx ON ( p.ID = idx.post_id ) - WHERE idx.post_id IS NULL - AND p.post_type = %s - AND p.post_status = %s - AND p.post_password = %s - LIMIT %d OFFSET %d", - $post_type, - $post_status, - '', - $limit, - $offset - ) - ); - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - - foreach ( $results as $result ) { - $entities[] = [ - 'post_id' => (int) $result->ID, - 'post_type' => $post_type, - ]; - } - - $offset += $limit; - } while ( count( $results ) === $limit ); - } - - return $entities; - } - - /** - * @param list $entities - */ - private function schedule_background_entities( array $entities ): int { - $scheduled = 0; - - foreach ( $entities as $entity ) { - $post_id = (int) $entity['post_id']; - $post_type = (string) $entity['post_type']; - - if ( $this->is_background_sync_scheduled( $post_id, $post_type ) ) { - continue; - } - - $post = get_post( $post_id ); - $label = $post instanceof WP_Post ? $this->entity_label( $post ) : sprintf( - /* translators: %d is the WordPress post ID for an entity being automatically synced. */ - __( 'Entity #%d', 'progress-agentic-rag-connector' ), - $post_id - ); - $background_id = $this->queue_background_sync( $label ); - - $this->schedule_single_action( - time() + ( $scheduled * 2 ), - self::HOOK_BACKGROUND_SYNC, - [ - 'post_id' => $post_id, - 'post_type' => $post_type, - 'background_id' => $background_id, - ], - self::GROUP_BACKGROUND - ); - - $scheduled++; - } - - return $scheduled; - } - private function is_background_sync_scheduled( int $post_id, string $post_type ): bool { foreach ( [ $this->action_status( 'STATUS_PENDING', 'pending' ), $this->action_status( 'STATUS_RUNNING', 'in-progress' ) ] as $status ) { foreach ( $this->scheduler->scheduled_actions( self::HOOK_BACKGROUND_SYNC, self::GROUP_BACKGROUND, $status ) as $action ) { diff --git a/src/Indexing/PreparationStore.php b/src/Indexing/PreparationStore.php new file mode 100644 index 0000000..4dfee75 --- /dev/null +++ b/src/Indexing/PreparationStore.php @@ -0,0 +1,316 @@ +prefix ); + $locked = '1' === (string) $this->value( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, max( 0, $wait ) ) ); + if ( $locked && function_exists( 'wp_cache_delete' ) ) { + wp_cache_delete( SettingsRepository::OPTION_MANUAL_SYNC_STATE, 'options' ); + } + return $locked; + } + + public function unlock(): void { + global $wpdb; + $name = 'rag-preparation-' . md5( ( defined( 'DB_NAME' ) ? DB_NAME : '' ) . $wpdb->prefix ); + $this->value( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) ); + } + + public function initialize(): void { + global $wpdb; + $table = $this->table(); + $charset = $wpdb->get_charset_collate(); + $this->query( $wpdb->prepare( "CREATE TABLE IF NOT EXISTS %i ( + run_id varchar(36) NOT NULL, + post_id bigint unsigned NOT NULL, + original_id bigint unsigned NOT NULL DEFAULT 0, + original_rid varchar(255) NOT NULL DEFAULT '', + original_seqid varchar(255) DEFAULT NULL, + upstream_rid varchar(255) NOT NULL DEFAULT '', + upstream_seqid varchar(255) NOT NULL DEFAULT '', + queued tinyint NOT NULL DEFAULT 0, + attempts tinyint unsigned NOT NULL DEFAULT 0, + outcome varchar(16) NOT NULL DEFAULT '', + recovered tinyint NOT NULL DEFAULT 0, + created_at bigint unsigned NOT NULL, + PRIMARY KEY (run_id, post_id), + KEY cleanup (created_at) + ) ", $table ) . $charset ); + $this->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %d LIMIT 1000', $table, time() - WEEK_IN_SECONDS ) ); + } + + // Best effort: a failed purge must not break finishing the run; initialize() trims leftovers. + public function purge( string $run ): void { + global $wpdb; + $table = $this->table(); + for ( $i = 0; $i < 20; $i++ ) { + $deleted = $this->execute( $wpdb->prepare( 'DELETE FROM %i WHERE run_id = %s LIMIT 5000', $table, $run ) ); + if ( ! is_int( $deleted ) || $deleted < 5000 ) { + return; + } + } + } + + public function bounds(): array { + global $wpdb; + return [ + 'mapping_limit' => (int) $this->value( $wpdb->prepare( 'SELECT MAX(id) FROM %i', $this->sync_table() ) ), + 'post_limit' => (int) $this->value( $wpdb->prepare( 'SELECT MAX(ID) FROM %i', $wpdb->posts ) ), + ]; + } + + public function snapshot_mappings( string $run, int $cursor, int $limit ): array { + global $wpdb; + $rows = $this->rows( $wpdb->prepare( + "SELECT id, post_id, nuclia_rid, nuclia_seqid FROM %i + WHERE id > %d AND id <= %d AND post_id > 0 AND nuclia_rid != '' + ORDER BY id LIMIT 1000", $this->sync_table(), $cursor, $limit + ) ); + $values = []; + foreach ( $rows as $row ) { + $values[] = $this->stage_values( $run, (int) $row->post_id, (int) $row->id, $row->nuclia_rid, $row->nuclia_seqid ); + $cursor = (int) $row->id; + } + $this->stage( $values ); + return [ 'cursor' => $cursor, 'done' => count( $rows ) < 1000 ]; + } + + public function snapshot_candidates( string $run, int $cursor, int $limit, array $types ): array { + global $wpdb; + if ( empty( $types ) ) { + return [ 'cursor' => $cursor, 'done' => true ]; + } + $in = $this->placeholders( $types ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is only a list of %s placeholders sized to $types; every value is bound. + $rows = $this->rows( $wpdb->prepare( + "SELECT p.ID FROM %i p LEFT JOIN %i idx ON idx.post_id = p.ID + WHERE p.ID > %d AND p.ID <= %d AND idx.post_id IS NULL + AND p.post_type IN ({$in}) AND p.post_password = '' + AND ((p.post_type = 'attachment' AND p.post_status = 'inherit') + OR (p.post_type != 'attachment' AND p.post_status = 'publish')) + ORDER BY p.ID LIMIT 1000", $wpdb->posts, $this->sync_table(), $cursor, $limit, ...$types + ) ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $values = []; + foreach ( $rows as $row ) { + $values[] = $this->stage_values( $run, (int) $row->ID ); + $cursor = (int) $row->ID; + } + $this->stage( $values ); + return [ 'cursor' => $cursor, 'done' => count( $rows ) < 1000 ]; + } + + private function stage_values( string $run, int $id, int $original = 0, string $rid = '', ?string $seqid = null ): string { + global $wpdb; + return $wpdb->prepare( "(%s, %d, %d, %s, NULLIF(%s, ''), %d)", $run, $id, $original, $rid, $seqid ?? '', time() ); + } + + private function stage( array $values ): void { + global $wpdb; + if ( empty( $values ) ) { + return; + } + $table = $this->table(); + $this->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (run_id, post_id, original_id, original_rid, original_seqid, created_at) VALUES ', $table ) . implode( ',', $values ) ); + } + + public function record_page( string $run, array $resources ): void { + global $wpdb; + $table = $this->table(); + foreach ( $resources as $resource ) { + $slug = (string) $resource['slug']; + if ( (string) (int) $slug !== $slug || (int) $slug <= 0 ) { + continue; + } + $this->query( $wpdb->prepare( + 'UPDATE %i SET upstream_rid = %s, upstream_seqid = %s WHERE run_id = %s AND post_id = %d', + $table, $resource['rid'], $resource['seqid'], $run, (int) $slug + ) ); + } + } + + // Mappings the KB listing did not return; each one is confirmed individually before apply_page() may delete it. + public function unseen( string $run, int $cursor, int $limit ): array { + global $wpdb; + return $this->rows( $wpdb->prepare( + "SELECT post_id FROM %i WHERE run_id = %s AND original_id > 0 AND upstream_rid = '' AND post_id > %d ORDER BY post_id LIMIT %d", + $this->table(), $run, $cursor, $limit + ) ); + } + + public function apply_page( string $run, int $cursor, array $types ): array { + global $wpdb; + $table = $this->table(); + $sync = $this->sync_table(); + $rows = $this->rows( $wpdb->prepare( + 'SELECT post_id FROM %i WHERE run_id = %s AND post_id > %d ORDER BY post_id LIMIT 1000', $table, $run, $cursor + ) ); + if ( empty( $rows ) ) { + return [ 'cursor' => $cursor, 'done' => true ]; + } + $next = (int) end( $rows )->post_id; + // The join compares the entire original mapping so newer background-sync results are never overwritten. + $this->query( $wpdb->prepare( + "DELETE idx FROM %i idx INNER JOIN %i s ON idx.id = s.original_id AND idx.post_id = s.post_id AND idx.nuclia_rid = s.original_rid + AND COALESCE(idx.nuclia_seqid, '') = COALESCE(s.original_seqid, '') + WHERE s.run_id = %s AND s.post_id > %d AND s.post_id <= %d AND s.upstream_rid = ''", + $sync, $table, $run, $cursor, $next + ) ); + $this->query( $wpdb->prepare( + "UPDATE %i idx INNER JOIN %i s ON idx.id = s.original_id AND idx.post_id = s.post_id AND idx.nuclia_rid = s.original_rid + AND COALESCE(idx.nuclia_seqid, '') = COALESCE(s.original_seqid, '') + SET idx.nuclia_rid = s.upstream_rid, idx.nuclia_seqid = IF(s.upstream_seqid != '', s.upstream_seqid, COALESCE(s.original_seqid, '')) + WHERE s.run_id = %s AND s.post_id > %d AND s.post_id <= %d AND s.upstream_rid != ''", + $sync, $table, $run, $cursor, $next + ) ); + if ( ! empty( $types ) ) { + $in = $this->placeholders( $types ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is only a list of %s placeholders sized to $types; every value is bound. + $this->query( $wpdb->prepare( + "INSERT INTO %i (post_id, nuclia_rid, nuclia_seqid) + SELECT s.post_id, s.upstream_rid, s.upstream_seqid FROM %i s + INNER JOIN %i p ON p.ID = s.post_id LEFT JOIN %i idx ON idx.post_id = s.post_id + WHERE s.run_id = %s AND s.post_id > %d AND s.post_id <= %d AND s.original_id = 0 AND s.upstream_rid != '' AND idx.post_id IS NULL + AND p.post_type IN ({$in}) AND p.post_password = '' + AND ((p.post_type = 'attachment' AND p.post_status = 'inherit') + OR (p.post_type != 'attachment' AND p.post_status = 'publish'))", + $sync, $table, $wpdb->posts, $sync, $run, $cursor, $next, ...$types + ) ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $this->query( $wpdb->prepare( + 'UPDATE %i s INNER JOIN %i idx ON idx.post_id = s.post_id AND idx.nuclia_rid = s.upstream_rid + SET s.recovered = 1 WHERE s.run_id = %s AND s.post_id > %d AND s.post_id <= %d AND s.original_id = 0', + $table, $sync, $run, $cursor, $next + ) ); + } + return [ 'cursor' => $next, 'done' => count( $rows ) < 1000 ]; + } + + public function candidates( string $run, int $cursor, array $types ): array { + global $wpdb; + if ( empty( $types ) ) { + return []; + } + $in = $this->placeholders( $types ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is only a list of %s placeholders sized to $types; every value is bound. + return $this->rows( $wpdb->prepare( + "SELECT s.post_id, p.post_type FROM %i s + INNER JOIN %i p ON p.ID = s.post_id + LEFT JOIN %i idx ON idx.post_id = s.post_id + WHERE s.run_id = %s AND s.post_id > %d AND idx.post_id IS NULL + AND p.post_type IN ({$in}) AND p.post_password = '' + AND ((p.post_type = 'attachment' AND p.post_status = 'inherit') + OR (p.post_type != 'attachment' AND p.post_status = 'publish')) + ORDER BY s.post_id LIMIT 100", $this->table(), $wpdb->posts, $this->sync_table(), $run, $cursor, ...$types + ) ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } + + public function mark_queued( string $run, int $id ): void { + global $wpdb; + $this->query( $wpdb->prepare( 'UPDATE %i SET queued = 1 WHERE run_id = %s AND post_id = %d', $this->table(), $run, $id ) ); + } + + public function outcome( string $run, int $id ): ?string { + global $wpdb; + $value = $this->value( $wpdb->prepare( + "SELECT IF(outcome = '', 'pending', outcome) FROM %i WHERE run_id = %s AND post_id = %d AND queued = 1", $this->table(), $run, $id + ) ); + // A missing row and an empty SQL string both yield null. + return 'pending' === $value ? '' : ( null === $value ? null : (string) $value ); + } + + public function finish_item( string $run, int $id, bool $success ): void { + global $wpdb; + $this->query( $wpdb->prepare( + "UPDATE %i SET outcome = %s WHERE run_id = %s AND post_id = %d AND outcome = ''", + $this->table(), $success ? 'complete' : 'failed', $run, $id + ) ); + } + + public function begin_item( string $run, int $id ): bool { + global $wpdb; + return $this->query( $wpdb->prepare( + "UPDATE %i SET attempts = attempts + 1 + WHERE run_id = %s AND post_id = %d AND queued = 1 AND outcome = '' AND attempts < 3", $this->table(), $run, $id + ) ) > 0; + } + + public function stats( string $run ): array { + global $wpdb; + $rows = $this->rows( $wpdb->prepare( + "SELECT COALESCE(SUM(queued),0) total, + COALESCE(SUM(queued = 1 AND outcome = 'complete'),0) completed, + COALESCE(SUM(queued = 1 AND outcome = 'failed'),0) failed, + COALESCE(SUM(recovered),0) recovered + FROM %i WHERE run_id = %s", $this->table(), $run + ) ); + return array_map( 'intval', (array) ( $rows[0] ?? (object) [ 'total' => 0, 'completed' => 0, 'failed' => 0, 'recovered' => 0 ] ) ); + } + + public function unfinished( string $run ): array { + global $wpdb; + return $this->rows( $wpdb->prepare( + "SELECT s.post_id, COALESCE(p.post_type, '') post_type FROM %i s LEFT JOIN %i p ON p.ID = s.post_id + WHERE run_id = %s AND queued = 1 AND outcome = '' ORDER BY s.post_id LIMIT 100", $this->table(), $wpdb->posts, $run + ) ); + } + + private function table(): string { + global $wpdb; + return $wpdb->prefix . self::TABLE; + } + + private function sync_table(): string { + global $wpdb; + return $wpdb->prefix . SettingsRepository::SYNC_TABLE_NAME; + } + + private function placeholders( array $values ): string { + return implode( ',', array_fill( 0, count( $values ), '%s' ) ); + } + + // $sql must already be built with $wpdb->prepare(). + private function execute( string $sql ): int|bool { + global $wpdb; + return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned staging table; caching would return stale run state. + } + + private function value( string $sql ): ?string { + global $wpdb; + return $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned staging table; caching would return stale run state. + } + + private function query( string $sql ): int { + $result = $this->execute( $sql ); + if ( false === $result ) { + throw new RuntimeException( esc_html__( 'Could not save sync preparation. Retry after checking the WordPress database.', 'progress-agentic-rag-connector' ) ); + } + return (int) $result; + } + + private function rows( string $sql ): array { + global $wpdb; + $rows = $wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned staging table; caching would return stale run state. + if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) { + throw new RuntimeException( esc_html__( 'Could not read sync preparation. Retry after checking the WordPress database.', 'progress-agentic-rag-connector' ) ); + } + return $rows; + } +} diff --git a/src/Indexing/Scheduler.php b/src/Indexing/Scheduler.php index ec445af..40a1aff 100644 --- a/src/Indexing/Scheduler.php +++ b/src/Indexing/Scheduler.php @@ -76,13 +76,16 @@ public function backend_message(): string { */ public function schedule_single_action( int $timestamp, string $hook, array $args, string $group ): bool { if ( function_exists( 'as_schedule_single_action' ) ) { - as_schedule_single_action( $timestamp, $hook, $args, $group ); - return true; + return (int) as_schedule_single_action( $timestamp, $hook, $args, $group ) > 0; } return false; } + public function has_action( string $hook, array $args, string $group ): bool { + return function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( $hook, $args, $group ); + } + /** * Unschedule all matching background actions. * diff --git a/templates/admin/indexation.php b/templates/admin/indexation.php index 5fc6405..aaa7108 100644 --- a/templates/admin/indexation.php +++ b/templates/admin/indexation.php @@ -96,7 +96,7 @@
- +
@@ -135,5 +135,4 @@

- diff --git a/templates/admin/manual-sync-modal.php b/templates/admin/manual-sync-modal.php index b1f7c28..56cf17c 100644 --- a/templates/admin/manual-sync-modal.php +++ b/templates/admin/manual-sync-modal.php @@ -12,7 +12,7 @@ diff --git a/templates/admin/sync-banner.php b/templates/admin/sync-banner.php new file mode 100644 index 0000000..5254d30 --- /dev/null +++ b/templates/admin/sync-banner.php @@ -0,0 +1,28 @@ + +
> + +
+
+ + +
+ +
+ % + +
diff --git a/tests/phpunit/AdminPageTest.php b/tests/phpunit/AdminPageTest.php index 7bca5a1..b32d4b4 100644 --- a/tests/phpunit/AdminPageTest.php +++ b/tests/phpunit/AdminPageTest.php @@ -112,7 +112,7 @@ public function test_render_new_admin_tabs_and_synced_content_without_token(): v 'total' => 2, 'completed' => 1, 'failed' => 1, - 'message' => 'Manual sync complete.', + 'message' => 'Sync complete.', 'finished_at' => 20, ] ); @@ -299,7 +299,9 @@ public function test_render_defaults_to_indexation_for_unknown_tab_and_blocks_un $output = ob_get_clean(); self::assertStringContainsString( 'Indexation', $output ); - self::assertStringContainsString( 'Sync manually', $output ); + self::assertStringContainsString( '>Sync', $output ); + self::assertStringContainsString( 'data-progress-agentic-rag-sync-banner hidden', $output ); + self::assertStringContainsString( 'data-progress-agentic-rag-sync-modal', $output ); $GLOBALS['progress_agentic_rag_test_current_user_can'] = false; ob_start(); @@ -513,6 +515,7 @@ public function test_save_indexation_settings_keeps_allowed_unique_post_types(): self::fail( 'Expected redirect.' ); } catch ( ProgressAgenticRagTestRedirect $redirect ) { self::assertStringContainsString( 'tab=indexation', $redirect->location ); + self::assertStringContainsString( 'sync-started=1', $redirect->location ); } self::assertSame( @@ -524,6 +527,26 @@ public function test_save_indexation_settings_keeps_allowed_unique_post_types(): ); } + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_render_shows_sync_banner_and_started_notice_on_every_tab(): void { + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, [ 'status' => 'running', 'message' => 'Scanning the knowledge box.', 'percent' => 30 ] ); + $_GET = [ + 'tab' => 'diagnostics', + 'progress-agentic-rag-updated' => 'true', + 'sync-started' => '1', + ]; + + ob_start(); + $this->admin_page()->render(); + $output = ob_get_clean(); + + self::assertStringNotContainsString( 'data-progress-agentic-rag-sync-banner hidden', $output ); + self::assertStringContainsString( 'data-progress-agentic-rag-banner-message>Sync in progress', $output ); + self::assertStringContainsString( 'data-progress-agentic-rag-sync-modal', $output ); + self::assertStringContainsString( 'A content sync has started in the background.', $output ); + } + public function test_save_indexation_settings_cancels_removed_post_type_jobs(): void { update_option( SettingsRepository::OPTION_INDEXABLE_POST_TYPES, [ 'post' => 1, 'page' => 1 ] ); update_option( @@ -652,6 +675,33 @@ public function test_save_widget_appearance_settings_sanitizes_and_redirects(): self::assertSame( 'system', get_option( SettingsRepository::OPTION_WIDGET_APPEARANCE )['font_family'] ); } + public function test_background_status_polls_do_not_scan_or_schedule_unindexed_posts(): void { + update_option( SettingsRepository::OPTION_INDEXABLE_POST_TYPES, [ 'post' => 1 ] ); + $GLOBALS['wpdb'] = new class() extends ProgressAgenticRagTestWpdb { + public function get_results( string $query ): array { + if ( str_contains( $query, 'SELECT post_id, nuclia_rid' ) ) { + return [ (object) [ 'post_id' => 31, 'nuclia_rid' => 'rid-31' ] ]; + } + + return [ (object) [ 'ID' => 32 ] ]; + } + }; + + for ( $poll = 0; $poll < 3; $poll++ ) { + try { + $this->admin_page()->background_sync_status(); + self::fail( 'Expected JSON success.' ); + } catch ( ProgressAgenticRagTestJsonResponse $response ) { + self::assertTrue( $response->success ); + self::assertSame( 'idle', $response->data['status'] ); + } + } + + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( [], $GLOBALS['wpdb']->deleted ); + } + public function test_ajax_handlers_return_json_success_and_errors(): void { $GLOBALS['progress_agentic_rag_test_current_user_can'] = false; @@ -795,9 +845,11 @@ public function test_ajax_start_manual_sync_success_and_error_paths(): void { self::fail( 'Expected JSON success.' ); } catch ( ProgressAgenticRagTestJsonResponse $response ) { self::assertTrue( $response->success ); - self::assertSame( 'complete', $response->data['status'] ); + self::assertSame( 'running', $response->data['status'] ); + self::assertTrue( $response->data['preparing'] ); } + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, [] ); update_option( SettingsRepository::OPTION_DELETE_SYNC_STATE, [ diff --git a/tests/phpunit/ApiClientTest.php b/tests/phpunit/ApiClientTest.php index 7c8e0de..cb2445c 100644 --- a/tests/phpunit/ApiClientTest.php +++ b/tests/phpunit/ApiClientTest.php @@ -390,7 +390,7 @@ public function test_attachment_create_conflict_recovers_existing_resource_mappi 'response' => [ 'code' => 200, ], - 'body' => '{"resources":[{"id":"rid-existing","slug":"100","title":"Knowledge Base Runbook 1","last_seqid":0}]}', + 'body' => '{"id":"rid-existing","slug":"100","title":"Knowledge Base Runbook 1","last_seqid":0}', ], [ 'response' => [ @@ -415,88 +415,49 @@ public function test_attachment_create_conflict_recovers_existing_resource_mappi self::assertTrue( $result ); self::assertCount( 3, $GLOBALS['progress_agentic_rag_test_http_requests'] ); self::assertSame( 'GET', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['args']['method'] ); - self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources?from=0&size=250', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['url'] ); + self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/slug/100', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['url'] ); self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resource/rid-existing/file/file/upload', $GLOBALS['progress_agentic_rag_test_http_requests'][2]['url'] ); self::assertSame( 'rid-existing', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); } - public function test_recover_existing_resources_repairs_mappings_from_paginated_resource_list(): void { - $first_page = []; - for ( $i = 1; $i <= 250; $i++ ) { - $first_page[] = [ - 'id' => 'rid-other-' . $i, - 'slug' => 'other-' . $i, - ]; - } - + public function test_conflict_lookup_uses_one_request_and_reports_a_missing_resource(): void { $GLOBALS['progress_agentic_rag_test_http_responses'] = [ [ - 'response' => [ - 'code' => 200, - ], - 'body' => json_encode( [ 'resources' => $first_page ] ), + 'response' => [ 'code' => 409 ], + 'body' => '{"detail":"exists"}', ], [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[{"id":"rid-101","slug":"101","last_seqid":0},{"uuid":"rid-203","slug":"203","seqid":"seq-203"}]}', + 'response' => [ 'code' => 404 ], + 'body' => '{"detail":"Resource does not exist"}', ], ]; - $result = ( new ApiClient( new SettingsRepository() ) )->recover_existing_resources( [ 101, 203 ] ); - - self::assertSame( 2, $result ); - self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources?from=0&size=250', $GLOBALS['progress_agentic_rag_test_http_requests'][0]['url'] ); - self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources?from=250&size=250', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['url'] ); - self::assertSame( 101, $GLOBALS['wpdb']->inserted[0]['data']['post_id'] ); - self::assertSame( 'rid-101', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); - self::assertSame( '0', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_seqid'] ); - self::assertSame( 203, $GLOBALS['wpdb']->inserted[1]['data']['post_id'] ); - self::assertSame( 'rid-203', $GLOBALS['wpdb']->inserted[1]['data']['nuclia_rid'] ); - self::assertSame( 'seq-203', $GLOBALS['wpdb']->inserted[1]['data']['nuclia_seqid'] ); + $result = ( new ApiClient( new SettingsRepository() ) )->index_post( new WP_Post( [ 'ID' => 921 ] ) ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'progress_agentic_rag_existing_resource_missing', $result->get_error_code() ); + self::assertCount( 2, $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertSame( [], $GLOBALS['wpdb']->inserted ); } - public function test_reconcile_synced_resources_clears_missing_upstream_mappings(): void { - $GLOBALS['wpdb']->results = [ - (object) [ - 'post_id' => '101', - 'nuclia_rid' => 'rid-old', - 'nuclia_seqid' => 'seq-old', - ], - (object) [ - 'post_id' => '202', - 'nuclia_rid' => 'rid-missing', - 'nuclia_seqid' => 'seq-missing', - ], - (object) [ - 'post_id' => '303', - 'nuclia_rid' => 'rid-303', - 'nuclia_seqid' => 'seq-303', + public function test_conflict_lookup_identifies_rate_limiting(): void { + $GLOBALS['progress_agentic_rag_test_http_responses'] = [ + [ + 'response' => [ 'code' => 409 ], + 'body' => '{"detail":"exists"}', ], - ]; - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 200, + [ + 'response' => [ 'code' => 429 ], + 'body' => '{"detail":{"period":"minute","limit":2400}}', ], - 'body' => '{"resources":[{"id":"rid-new","slug":"101","seqid":"seq-new"},{"id":"rid-303","slug":"303","seqid":"seq-303"}]}', ]; - $result = ( new ApiClient( new SettingsRepository() ) )->reconcile_synced_resources(); + $result = ( new ApiClient( new SettingsRepository() ) )->index_post( new WP_Post( [ 'ID' => 922 ] ) ); - self::assertSame( - [ - 'checked' => 3, - 'removed' => 1, - 'updated' => 1, - ], - $result - ); - self::assertSame( [ 'post_id' => 101 ], $GLOBALS['wpdb']->deleted[0]['where'] ); - self::assertSame( [ 'post_id' => 202 ], $GLOBALS['wpdb']->deleted[1]['where'] ); - self::assertSame( 101, $GLOBALS['wpdb']->inserted[0]['data']['post_id'] ); - self::assertSame( 'rid-new', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); - self::assertSame( 'seq-new', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_seqid'] ); + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'progress_agentic_rag_rate_limited', $result->get_error_code() ); + self::assertStringContainsString( 'rate limit', $result->get_error_message() ); + self::assertCount( 2, $GLOBALS['progress_agentic_rag_test_http_requests'] ); } public function test_get_labelsets_fetches_and_caches_embedded_labels(): void { @@ -691,21 +652,6 @@ public function test_labelset_methods_use_cache_and_fallbacks(): void { self::assertSame( [ 'Stale' ], $client->get_labelsets() ); } - public function test_recover_existing_resources_ignores_invalid_ids_and_empty_matches(): void { - $client = new ApiClient( new SettingsRepository() ); - - self::assertSame( 0, $client->recover_existing_resources( [ 0, 'bad' ] ) ); - - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[{"slug":"905"}]}', - ]; - - self::assertSame( 0, $client->recover_existing_resources( [ 905 ] ) ); - } - public function test_index_conflict_without_existing_resource_and_attachment_upload_failure_return_errors(): void { $GLOBALS['progress_agentic_rag_test_http_responses'] = [ [ @@ -825,17 +771,6 @@ public function test_labelset_normalizers_accept_associative_payloads_and_label_ self::assertSame( [ 'Text', 'Uri', 'Related', 'Name', 'ID' ], $client->get_labelset_labels( 'Variants' ) ); } - public function test_recover_existing_resources_returns_lookup_errors(): void { - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 500, - ], - 'body' => '{"detail":"Lookup failed"}', - ]; - - self::assertInstanceOf( WP_Error::class, ( new ApiClient( new SettingsRepository() ) )->recover_existing_resources( [ 909 ] ) ); - } - public function test_taxonomy_classifications_skip_invalid_maps_and_accept_scalar_labels(): void { $GLOBALS['progress_agentic_rag_test_taxonomies']['category'] = true; $GLOBALS['progress_agentic_rag_test_taxonomies']['post_tag'] = true; @@ -951,7 +886,7 @@ public function test_attachment_upload_branches_update_seqids_and_return_errors( 'response' => [ 'code' => 200, ], - 'body' => '{"resources":[{"id":"rid-existing","slug":"912","last_seqid":"seq-old"}]}', + 'body' => '{"id":"rid-existing","slug":"912","last_seqid":"seq-old"}', ], new WP_Error( 'http_failed', 'Upload network failed' ), ]; @@ -980,7 +915,7 @@ public function test_attachment_upload_branches_update_seqids_and_return_errors( 'response' => [ 'code' => 200, ], - 'body' => '{"resources":[{"id":"rid-existing","slug":"915","last_seqid":"seq-old"}]}', + 'body' => '{"id":"rid-existing","slug":"915","last_seqid":"seq-old"}', ], [ 'response' => [ @@ -1179,18 +1114,9 @@ public function test_api_error_lookup_and_labelset_fallback_branches(): void { ]; self::assertSame( [ 'Identifier' ], $client->get_labelsets() ); - - update_option( SettingsRepository::OPTION_ZONE, 'bad zone' ); - - self::assertInstanceOf( WP_Error::class, $client->recover_existing_resources( [ 919 ] ) ); - - update_option( SettingsRepository::OPTION_ZONE, 'europe-1' ); - $GLOBALS['progress_agentic_rag_test_http_responses'][] = new WP_Error( 'http_failed', 'Lookup failed' ); - - self::assertInstanceOf( WP_Error::class, $client->recover_existing_resources( [ 920 ] ) ); } - public function test_private_lookup_helpers_cover_empty_inputs(): void { + public function test_private_lookup_helper_requires_a_slug_and_connection(): void { $client = new ApiClient( new SettingsRepository() ); $single_ref = new ReflectionMethod( $client, 'find_resource_by_slug' ); @@ -1198,9 +1124,23 @@ public function test_private_lookup_helpers_cover_empty_inputs(): void { self::assertInstanceOf( WP_Error::class, $single_ref->invoke( $client, '' ) ); - $many_ref = new ReflectionMethod( $client, 'find_resources_by_slugs' ); - $many_ref->setAccessible( true ); + update_option( SettingsRepository::OPTION_ZONE, 'bad zone' ); + + self::assertInstanceOf( WP_Error::class, $single_ref->invoke( $client, '919' ) ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + } + + public function test_slug_lookup_only_reports_missing_for_404_and_errors_on_a_200_without_an_id(): void { + $client = new ApiClient( new SettingsRepository() ); + + $GLOBALS['progress_agentic_rag_test_http_responses'] = [ + [ 'response' => [ 'code' => 404 ], 'body' => '{"detail":"Resource does not exist"}' ], + [ 'response' => [ 'code' => 200 ], 'body' => 'Bad gateway page' ], + [ 'response' => [ 'code' => 200 ], 'body' => '{"id":"rid-1","last_seqid":4}' ], + ]; - self::assertSame( [], $many_ref->invoke( $client, [ '', ' ' ] ) ); + self::assertSame( [ 'rid' => '', 'seqid' => '' ], $client->find_resource_by_slug( '5' ) ); + self::assertInstanceOf( WP_Error::class, $client->find_resource_by_slug( '5' ) ); + self::assertSame( [ 'rid' => 'rid-1', 'seqid' => '4' ], $client->find_resource_by_slug( '5' ) ); } } diff --git a/tests/phpunit/ManualPreparationTest.php b/tests/phpunit/ManualPreparationTest.php new file mode 100644 index 0000000..8bec023 --- /dev/null +++ b/tests/phpunit/ManualPreparationTest.php @@ -0,0 +1,349 @@ + 1 ] ); + $this->settings = new SettingsRepository(); + $this->store = $this->createMock( PreparationStore::class ); + $this->store->method( 'lock' )->willReturn( true ); + $this->store->method( 'bounds' )->willReturn( [ 'mapping_limit' => 10000, 'post_limit' => 10000 ] ); + $this->store->method( 'snapshot_mappings' )->willReturn( [ 'cursor' => 10000, 'done' => true ] ); + $this->store->method( 'snapshot_candidates' )->willReturn( [ 'cursor' => 10000, 'done' => true ] ); + $this->preparation = new ManualPreparation( $this->settings, new ApiClient( $this->settings ), new Scheduler(), $this->store ); + } + + public function test_scan_advances_one_page_per_job_and_reconciliation_waits_for_last_page(): void { + $this->store->expects( self::once() )->method( 'apply_page' )->willReturn( [ 'cursor' => 10000, 'done' => true ] ); + $this->store->expects( self::exactly( 2 ) )->method( 'record_page' ); + $this->prepare_scan(); + $this->response( 200, [ 'resources' => [ [ 'id' => 'rid-first', 'slug' => '1' ] ], 'pagination' => [ 'page' => 0, 'last' => false ] ] ); + $step = $this->state()['step']; + $this->next(); + self::assertCount( 1, $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertSame( 'scan', $this->state()['phase'] ); + self::assertSame( 1, $this->state()['page'] ); + $this->preparation->process( $this->state()['id'], $step ); + self::assertCount( 1, $GLOBALS['progress_agentic_rag_test_http_requests'], 'Duplicate checkpoints must do nothing.' ); + $this->response( 200, [ 'resources' => [ [ 'id' => 'rid-last', 'slug' => '10000' ] ], 'pagination' => [ 'page' => 1, 'last' => true ] ] ); + $this->next(); + self::assertSame( 'verify', $this->state()['phase'] ); + self::assertStringContainsString( 'page=1&size=250', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['url'] ); + $this->next(); + self::assertSame( 'apply', $this->state()['phase'] ); + $this->next(); + self::assertSame( 'queue', $this->state()['phase'] ); + self::assertTrue( $this->preparation->should_yield( false ), 'The runner must stop batching after preparation.' ); + } + + public function test_malformed_and_repeated_pages_fail_without_reconciling_mappings(): void { + $this->store->expects( self::never() )->method( 'apply_page' ); + $this->prepare_scan(); + $page = [ 'resources' => [ [ 'id' => 'same-rid', 'slug' => '1' ] ], 'pagination' => [ 'last' => false ] ]; + $this->response( 200, $page ); + $this->next(); + $this->response( 200, $page ); + $this->next(); + self::assertSame( 'failed', $this->state()['status'] ); + self::assertStringContainsString( 'repeated a page', $this->state()['message'] ); + + foreach ( [ + [ 'unexpected' => [] ], + [ 'resources' => [], 'pagination' => [ 'last' => 'false' ] ], + [ 'resources' => [], 'pagination' => [ 'page' => 99, 'last' => true ] ], + [ 'resources' => [], 'pagination' => [ 'page' => 'invalid', 'last' => true ] ], + ] as $body ) { + $this->prepare_scan(); + $this->response( 200, $body ); + $this->next(); + self::assertSame( 'failed', $this->state()['status'] ); + self::assertStringContainsString( 'invalid resource page', $this->state()['message'] ); + } + } + + public function test_rate_limits_back_off_and_retry_only_the_saved_page_with_a_limit(): void { + $this->store->expects( self::never() )->method( 'apply_page' ); + $this->prepare_scan(); + $step = $this->state()['step']; + for ( $attempt = 1; $attempt <= 4; $attempt++ ) { + $this->response( 429, [ 'detail' => 'Too many requests' ], [ 'Retry-After' => '90' ] ); + $this->next(); + self::assertSame( $step, $this->state()['step'] ); + self::assertSame( 0, $this->state()['page'] ); + if ( $attempt < 4 ) { + self::assertSame( 'running', $this->state()['status'] ); + self::assertGreaterThanOrEqual( time() + 89, $this->state()['next_attempt'] ); + $this->next(); + self::assertCount( $attempt, $GLOBALS['progress_agentic_rag_test_http_requests'], 'An early retry must not call the API.' ); + $state = $this->state(); + $state['next_attempt'] = 0; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + } + } + self::assertSame( 'failed', $this->state()['status'] ); + self::assertStringContainsString( 'rate limit', $this->state()['message'] ); + self::assertCount( 4, $GLOBALS['progress_agentic_rag_test_http_requests'] ); + } + + public function test_watchdog_recovers_missing_continuation_without_scanning_and_rejects_old_runs(): void { + $this->prepare_scan(); + $before = $this->state(); + $GLOBALS['progress_agentic_rag_test_scheduled_actions'] = []; + $this->preparation->recover(); + $job = end( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( [ 'sync_id' => $before['id'], 'step' => $before['step'] ], $job['args'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertNotFalse( wp_next_scheduled( ManualPreparation::RECOVERY_HOOK ) ); + $this->preparation->process( 'obsolete-run', $before['step'] ); + self::assertSame( $before['page'], $this->state()['page'] ); + $state = $this->state(); + $state['next_attempt'] = 0; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->response( 200, [ 'resources' => [], 'pagination' => [ 'page' => 0, 'last' => true ] ] ); + $this->next(); + self::assertSame( 'verify', $this->state()['phase'] ); + self::assertSame( 1, $this->state()['page'] ); + } + + public function test_repeatedly_interrupted_uploads_become_retryable_failed_items(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['phase'] = ''; + $state['total'] = 1; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'outcome' )->willReturn( '' ); + $this->store->method( 'begin_item' )->willReturn( false ); + $this->store->expects( self::once() )->method( 'finish_item' )->with( $state['id'], 31, false ); + $this->store->expects( self::once() )->method( 'purge' )->with( $state['id'] ); + $this->store->method( 'stats' )->willReturn( [ 'total' => 1, 'completed' => 0, 'failed' => 1, 'recovered' => 0 ] ); + $this->preparation->process_item( 31, 'post', $state['id'], static function (): never { self::fail( 'Exhausted jobs must not upload again.' ); } ); + self::assertSame( 'complete', $this->state()['status'] ); + self::assertSame( 1, $this->state()['failed'] ); + self::assertSame( 31, $this->settings->get_failed_sync_items()['31|post']['post_id'] ); + } + + public function test_cancellation_and_changed_connection_prevent_further_scan_or_application(): void { + $this->store->expects( self::never() )->method( 'apply_page' ); + $this->store->expects( self::exactly( 2 ) )->method( 'purge' ); + $this->prepare_scan(); + $before = $this->state(); + $this->preparation->cancel(); + $this->preparation->process( $before['id'], $before['step'] ); + self::assertSame( 'cancelled', $this->state()['status'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + + $this->prepare_scan(); + update_option( SettingsRepository::OPTION_KBID, 'different-kb' ); + $this->next(); + self::assertSame( 'failed', $this->state()['status'] ); + self::assertStringContainsString( 'settings changed', $this->state()['message'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + } + + public function test_upload_outcomes_are_idempotent_and_missing_actions_are_requeued(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['phase'] = ''; + $state['total'] = 2; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'outcome' )->willReturn( '', 'complete' ); + $this->store->method( 'begin_item' )->willReturn( true ); + $this->store->expects( self::once() )->method( 'finish_item' )->with( $state['id'], 31, true ); + $this->store->method( 'stats' )->willReturn( [ 'total' => 2, 'completed' => 1, 'failed' => 0, 'recovered' => 0 ] ); + $this->store->method( 'unfinished' )->willReturn( [ (object) [ 'post_id' => 32, 'post_type' => 'post' ] ] ); + $calls = 0; + $sync = static function () use ( &$calls ): bool { $calls++; return true; }; + $this->preparation->process_item( 31, 'post', $state['id'], $sync ); + $this->preparation->process_item( 31, 'post', $state['id'], $sync ); + self::assertSame( 1, $calls ); + self::assertSame( 1, $this->state()['completed'] ); + $GLOBALS['progress_agentic_rag_test_scheduled_actions'] = []; + $this->preparation->recover(); + $job = end( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( ManualPreparation::POST_HOOK, $job['hook'] ); + self::assertSame( 32, $job['args']['post_id'] ); + } + + public function test_preparation_status_never_reports_completion_or_mutates_the_checkpoint(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['total'] = 1; + $state['completed'] = 1; + $state['updated_at'] = time() - 600; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $sync = new ManualSync( $this->settings, new ApiClient( $this->settings ), null, $this->store ); + for ( $poll = 0; $poll < 3; $poll++ ) { + $status = $sync->status(); + self::assertSame( 'running', $status['status'] ); + self::assertSame( 0, $status['percent'] ); + self::assertTrue( $status['preparing'] ); + self::assertStringContainsString( 'WordPress cron', $status['message'] ); + } + self::assertSame( $state, $this->state() ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + } + + public function test_verify_confirms_unseen_mappings_by_slug_before_apply_can_delete_them(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['phase'] = 'verify'; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'unseen' )->willReturn( [ (object) [ 'post_id' => 7 ], (object) [ 'post_id' => 8 ] ] ); + $this->store->expects( self::once() )->method( 'record_page' )->with( $state['id'], [ [ 'slug' => '7', 'rid' => 'rid-7', 'seqid' => '3' ] ] ); + $this->response( 200, [ 'id' => 'rid-7', 'slug' => '7', 'last_seqid' => 3 ] ); + $this->response( 404, [ 'detail' => 'Resource does not exist' ] ); + $this->next(); + self::assertSame( 'apply', $this->state()['phase'] ); + self::assertSame( 0, $this->state()['cursor'] ); + self::assertCount( 2, $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertStringContainsString( '/slug/7', $GLOBALS['progress_agentic_rag_test_http_requests'][0]['url'] ); + self::assertStringContainsString( '/slug/8', $GLOBALS['progress_agentic_rag_test_http_requests'][1]['url'] ); + } + + public function test_verify_keeps_progress_and_retries_when_the_slug_lookup_is_rate_limited(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['phase'] = 'verify'; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'unseen' )->willReturn( [ (object) [ 'post_id' => 7 ], (object) [ 'post_id' => 8 ] ] ); + $this->store->expects( self::once() )->method( 'record_page' ); + $this->response( 200, [ 'id' => 'rid-7', 'slug' => '7' ] ); + $this->response( 429, [], [ 'retry-after' => '60' ] ); + $this->next(); + self::assertSame( 'running', $this->state()['status'] ); + self::assertSame( 'verify', $this->state()['phase'] ); + self::assertSame( 7, $this->state()['cursor'], 'Confirmed mappings must not be looked up again.' ); + self::assertGreaterThan( time(), $this->state()['next_attempt'] ); + } + + public function test_cancel_reports_busy_without_touching_the_run_when_the_lock_is_held(): void { + $this->prepare_scan(); + $before = $this->state(); + $busy = $this->createMock( PreparationStore::class ); + $busy->method( 'lock' )->willReturn( false ); + $busy->expects( self::never() )->method( 'purge' ); + $result = ( new ManualPreparation( $this->settings, new ApiClient( $this->settings ), new Scheduler(), $busy ) )->cancel(); + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( $before, $this->state() ); + } + + public function test_cancel_removes_every_queued_step_and_item_regardless_of_group(): void { + $this->prepare_scan(); + $run = $this->state()['id']; + foreach ( [ ManualPreparation::HOOK, ManualPreparation::POST_HOOK ] as $hook ) { + as_schedule_single_action( time(), $hook, [ 'sync_id' => $run ], 'progress-agentic-rag-indexing' ); + } + as_schedule_single_action( time(), 'unrelated_hook', [], 'progress-agentic-rag-indexing' ); + $this->preparation->cancel(); + self::assertSame( [ 'unrelated_hook' ], array_column( $GLOBALS['progress_agentic_rag_test_scheduled_actions'], 'hook' ) ); + } + + public function test_scan_is_retried_later_when_the_lock_cannot_be_retaken_after_the_request(): void { + $this->prepare_scan(); + $before = $this->state(); + $store = $this->createMock( PreparationStore::class ); + $store->method( 'lock' )->willReturnOnConsecutiveCalls( true, false ); + $store->expects( self::never() )->method( 'record_page' ); + $this->response( 200, [ 'resources' => [ [ 'id' => 'rid-first', 'slug' => '1' ] ], 'pagination' => [ 'page' => 0, 'last' => false ] ] ); + ( new ManualPreparation( $this->settings, new ApiClient( $this->settings ), new Scheduler(), $store ) )->process( $before['id'], $before['step'] ); + self::assertSame( $before, $this->state() ); + $job = end( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( [ 'sync_id' => $before['id'], 'step' => $before['step'] ], $job['args'] ); + self::assertGreaterThan( time() + 20, $job['timestamp'] ); + } + + public function test_a_cancel_during_slow_requests_is_not_overwritten_by_the_running_step_or_item(): void { + $this->prepare_scan(); + $run = $this->state()['id']; + $cancel = function () use ( $run ): void { + $state = $this->state(); + $state['status'] = 'cancelled'; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + }; + $this->store->expects( self::never() )->method( 'record_page' ); + $this->store->method( 'unlock' )->willReturnCallback( $cancel ); + $this->response( 200, [ 'resources' => [ [ 'id' => 'rid-first', 'slug' => '1' ] ], 'pagination' => [ 'page' => 0, 'last' => false ] ] ); + $this->next(); + self::assertSame( 'cancelled', $this->state()['status'] ); + self::assertSame( 0, $this->state()['page'] ); + + $state = $this->state(); + $state['status'] = 'running'; + $state['phase'] = ''; + $state['total'] = 1; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'outcome' )->willReturn( '' ); + $this->store->method( 'begin_item' )->willReturn( true ); + $this->store->expects( self::once() )->method( 'finish_item' )->with( $run, 31, true ); + $this->store->expects( self::never() )->method( 'stats' ); + $this->preparation->process_item( 31, 'post', $run, static fn (): bool => true ); + self::assertSame( 'cancelled', $this->state()['status'] ); + } + + public function test_current_label_is_saved_before_unlocking_so_a_cancel_cannot_be_undone(): void { + $this->prepare_scan(); + $state = $this->state(); + $state['phase'] = ''; + $state['total'] = 1; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + $this->store->method( 'outcome' )->willReturn( '' ); + $this->store->method( 'begin_item' )->willReturn( true ); + $this->store->method( 'unlock' )->willReturnCallback( function (): void { + self::assertSame( 'Post: Hello', $this->state()['current'], 'The label must be saved while the lock is still held.' ); + $state = $this->state(); + $state['status'] = 'cancelled'; + update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state ); + } ); + $this->preparation->process_item( 31, 'post', $state['id'], static fn (): bool => true, 'Post: Hello' ); + self::assertSame( 'cancelled', $this->state()['status'] ); + } + + private function prepare_scan(): void { + $this->preparation->begin( [ 'post' ] ); + $this->next(); + $this->next(); + self::assertSame( 'scan', $this->state()['phase'] ); + } + + private function next(): void { + $state = $this->state(); + $this->preparation->process( $state['id'], $state['step'] ); + } + + private function state(): array { + return get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, [] ); + } + + private function response( int $code, array $body, array $headers = [] ): void { + $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ + 'response' => [ 'code' => $code ], 'body' => wp_json_encode( $body ), 'headers' => $headers, + ]; + } +} diff --git a/tests/phpunit/ManualSyncTest.php b/tests/phpunit/ManualSyncTest.php index 70eb4e1..1735e2f 100644 --- a/tests/phpunit/ManualSyncTest.php +++ b/tests/phpunit/ManualSyncTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use ProgressAgenticRag\Api\ApiClient; use ProgressAgenticRag\Indexing\ManualSync; +use ProgressAgenticRag\Indexing\ManualPreparation; +use ProgressAgenticRag\Indexing\PreparationStore; use ProgressAgenticRag\Settings\SettingsRepository; final class ManualSyncTest extends TestCase { @@ -71,19 +73,23 @@ public function test_start_schedules_manual_sync_jobs_and_worker_indexes_post(): ); $settings = new SettingsRepository(); - $sync = new ManualSync( $settings, new ApiClient( $settings ) ); + $sync = new ManualSync( $settings, new ApiClient( $settings ), null, $this->preparation_store( [ 31, 32 ] ) ); $result = $sync->start( [ 'post' ] ); self::assertSame( 'running', $result['status'] ); - self::assertSame( 2, $result['total'] ); + self::assertSame( 0, $result['total'] ); self::assertSame( 0, $result['processed'] ); - self::assertSame( 'Waiting for the first entity.', $result['current'] ); - self::assertSame( 'Sync in progress: 0 of 2 processed.', $result['message'] ); - self::assertCount( 2, $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); - self::assertSame( 'progress_agentic_rag_manual_sync_post', $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['hook'] ); - self::assertSame( 'progress-agentic-rag-indexing', $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['group'] ); - self::assertSame( 31, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_id'] ); - self::assertSame( 'post', $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_type'] ); + self::assertTrue( $result['preparing'] ); + self::assertSame( 0, $result['percent'] ); + self::assertCount( 1, $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( ManualPreparation::HOOK, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['hook'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ 'response' => [ 'code' => 200 ], 'body' => '{"resources":[]}' ]; + $this->finish_preparation( $sync ); + $jobs = array_values( array_filter( $GLOBALS['progress_agentic_rag_test_scheduled_actions'], static fn ( $action ) => ManualPreparation::POST_HOOK === $action['hook'] ) ); + self::assertCount( 2, $jobs ); + self::assertSame( 31, $jobs[0]['args']['post_id'] ); + self::assertSame( 'post', $jobs[0]['args']['post_type'] ); $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ 'response' => [ @@ -95,7 +101,7 @@ public function test_start_schedules_manual_sync_jobs_and_worker_indexes_post(): $sync->process_single_post( 31, 'post', - $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['sync_id'] + $jobs[0]['args']['sync_id'] ); $status = $sync->status(); @@ -103,7 +109,7 @@ public function test_start_schedules_manual_sync_jobs_and_worker_indexes_post(): self::assertSame( 1, $status['completed'] ); self::assertSame( 'Post: Manual Sync Article', $status['current'] ); - self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources?from=0&size=250', $GLOBALS['progress_agentic_rag_test_http_requests'][0]['url'] ); + self::assertStringStartsWith( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources?page=0&size=', $GLOBALS['progress_agentic_rag_test_http_requests'][0]['url'] ); self::assertSame( 'POST', $request['args']['method'] ); self::assertSame( 'https://europe-1.rag.progress.cloud/api/v1/kb/kb-123/resources', $request['url'] ); self::assertSame( 'rid-31', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); @@ -137,24 +143,7 @@ public function test_start_returns_existing_running_status_without_scheduling_du self::assertSame( [], $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); } - public function test_start_clears_stale_mapping_before_scheduling_manual_sync(): void { - $GLOBALS['wpdb'] = new class() extends ProgressAgenticRagTestWpdb { - private int $unindexed_calls = 0; - - public function get_results( string $query ): array { - if ( str_contains( $query, 'post_id, nuclia_rid' ) ) { - return [ - (object) [ - 'post_id' => '33', - 'nuclia_rid' => 'rid-stale', - ], - ]; - } - - $this->unindexed_calls++; - return 1 === $this->unindexed_calls ? [ (object) [ 'ID' => '33' ] ] : []; - } - }; + public function test_preparation_applies_stale_mapping_scan_before_scheduling_manual_sync(): void { $GLOBALS['progress_agentic_rag_test_posts'][33] = new WP_Post( [ 'ID' => 33, @@ -178,12 +167,17 @@ public function get_results( string $query ): array { ], ]; - $result = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->start( [ 'post' ] ); + $store = $this->preparation_store( [ 33 ] ); + $store->expects( self::once() )->method( 'apply_page' ); + $sync = new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ), null, $store ); + $sync->start( [ 'post' ] ); + self::assertSame( [], $GLOBALS['wpdb']->deleted ); + $result = $this->finish_preparation( $sync ); self::assertSame( 'running', $result['status'] ); self::assertSame( 1, $result['total'] ); - self::assertSame( [ 'post_id' => 33 ], $GLOBALS['wpdb']->deleted[0]['where'] ); - self::assertSame( 33, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_id'] ); + $job = end( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( 33, $job['args']['post_id'] ); } public function test_background_post_sync_schedules_and_indexes_publishable_post(): void { @@ -234,154 +228,25 @@ public function test_background_post_sync_schedules_and_indexes_publishable_post self::assertSame( 100, $status['percent'] ); } - public function test_ensure_automatic_sync_schedules_selected_unindexed_content(): void { - $GLOBALS['wpdb']->results = [ - (object) [ - 'ID' => '51', - ], - (object) [ - 'ID' => '52', - ], - ]; - $GLOBALS['progress_agentic_rag_test_posts'][51] = new WP_Post( - [ - 'ID' => 51, - 'post_title' => 'Automatic One', - 'post_type' => 'post', - 'post_status' => 'publish', - ] - ); - $GLOBALS['progress_agentic_rag_test_posts'][52] = new WP_Post( - [ - 'ID' => 52, - 'post_title' => 'Automatic Two', - 'post_type' => 'post', - 'post_status' => 'publish', - ] - ); - + public function test_ensure_automatic_sync_starts_a_background_run_without_contacting_the_api(): void { $settings = new SettingsRepository(); - $sync = new ManualSync( $settings, new ApiClient( $settings ) ); + $sync = new ManualSync( $settings, new ApiClient( $settings ), null, $this->preparation_store( [] ) ); $result = $sync->ensure_automatic_sync(); self::assertSame( 'running', $result['status'] ); - self::assertSame( 2, $result['total'] ); - self::assertSame( 2, $result['pending'] ); - self::assertSame( 2, $result['scheduled'] ); - self::assertTrue( $result['is_active'] ); - self::assertCount( 2, $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); - self::assertSame( 'progress_agentic_rag_background_sync_post', $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['hook'] ); - self::assertSame( 'progress-agentic-rag-background', $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['group'] ); - self::assertSame( 51, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_id'] ); - self::assertSame( 52, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][1]['args']['post_id'] ); - self::assertSame( $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['background_id'], $GLOBALS['progress_agentic_rag_test_scheduled_actions'][1]['args']['background_id'] ); - } - - public function test_ensure_automatic_sync_recovers_existing_resources_before_scheduling(): void { - $GLOBALS['wpdb'] = new class() extends ProgressAgenticRagTestWpdb { - private int $get_results_calls = 0; - - public function get_results( string $query ): array { - if ( str_contains( $query, 'post_id, nuclia_rid' ) ) { - return []; - } - - $this->get_results_calls++; - - return 1 === $this->get_results_calls ? [ (object) [ 'ID' => '53' ] ] : []; - } - }; - $GLOBALS['progress_agentic_rag_test_posts'][53] = new WP_Post( - [ - 'ID' => 53, - 'post_title' => 'Recovered Automatic', - 'post_type' => 'post', - 'post_status' => 'publish', - ] - ); - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[{"id":"rid-53","slug":"53","seqid":"seq-53"}]}', - ]; - - $result = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->ensure_automatic_sync(); - - self::assertSame( 0, $result['scheduled'] ); - self::assertSame( 'rid-53', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); - self::assertSame( [], $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); - } - - public function test_ensure_automatic_sync_clears_stale_mapping_before_scheduling(): void { - $GLOBALS['wpdb'] = new class() extends ProgressAgenticRagTestWpdb { - private int $unindexed_calls = 0; - - public function get_results( string $query ): array { - if ( str_contains( $query, 'post_id, nuclia_rid' ) ) { - return [ - (object) [ - 'post_id' => '54', - 'nuclia_rid' => 'rid-stale', - ], - ]; - } - - $this->unindexed_calls++; - return 1 === $this->unindexed_calls ? [ (object) [ 'ID' => '54' ] ] : []; - } - }; - $GLOBALS['progress_agentic_rag_test_posts'][54] = new WP_Post( - [ - 'ID' => 54, - 'post_title' => 'Stale Automatic Article', - 'post_type' => 'post', - 'post_status' => 'publish', - ] - ); - $GLOBALS['progress_agentic_rag_test_http_responses'] = [ - [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[]}', - ], - [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[]}', - ], - ]; + self::assertTrue( $result['preparing'] ); + self::assertCount( 1, $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); + self::assertSame( ManualPreparation::HOOK, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['hook'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_http_requests'] ); + self::assertSame( 'automatic', get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE )['trigger'] ); - $result = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->ensure_automatic_sync(); + $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ 'response' => [ 'code' => 200 ], 'body' => '{"resources":[]}' ]; + $this->finish_preparation( $sync ); - self::assertSame( 'running', $result['status'] ); - self::assertSame( 1, $result['scheduled'] ); - self::assertSame( [ 'post_id' => 54 ], $GLOBALS['wpdb']->deleted[0]['where'] ); - self::assertSame( 54, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_id'] ); + self::assertSame( 'automatic_sync', $settings->get_sync_history()[0]['type'] ); } public function test_ensure_automatic_sync_retries_stale_failed_state_without_failed_items(): void { - $GLOBALS['wpdb']->results = [ - (object) [ - 'ID' => '54', - ], - ]; - $GLOBALS['progress_agentic_rag_test_posts'][54] = new WP_Post( - [ - 'ID' => 54, - 'post_title' => 'Retry Stale Automatic', - 'post_type' => 'post', - 'post_status' => 'publish', - ] - ); - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 200, - ], - 'body' => '{"resources":[]}', - ]; update_option( SettingsRepository::OPTION_BACKGROUND_SYNC_STATE, [ @@ -391,11 +256,11 @@ public function test_ensure_automatic_sync_retries_stale_failed_state_without_fa ] ); - $result = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->ensure_automatic_sync(); + $settings = new SettingsRepository(); + $result = ( new ManualSync( $settings, new ApiClient( $settings ), null, $this->preparation_store( [] ) ) )->ensure_automatic_sync(); - self::assertSame( 1, $result['scheduled'] ); self::assertSame( 'running', $result['status'] ); - self::assertSame( 54, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['args']['post_id'] ); + self::assertSame( ManualPreparation::HOOK, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][0]['hook'] ); } public function test_ensure_automatic_sync_does_not_duplicate_active_background_jobs(): void { @@ -496,6 +361,33 @@ public function test_background_failures_are_recorded_and_retryable(): void { self::assertSame( 61, $GLOBALS['progress_agentic_rag_test_scheduled_actions'][1]['args']['post_id'] ); } + public function test_background_sync_with_lost_jobs_stops_reporting_active_but_recent_runs_are_left_alone(): void { + $state = [ + 'id' => 'background-lost', + 'status' => 'running', + 'total' => 4, + 'completed' => 3, + 'failed' => 0, + 'message' => 'Automatic background sync queued.', + 'started_at' => time() - 7200, + 'updated_at' => time() - 60, + ]; + update_option( SettingsRepository::OPTION_BACKGROUND_SYNC_STATE, $state ); + $settings = new SettingsRepository(); + $sync = new ManualSync( $settings, new ApiClient( $settings ) ); + + self::assertSame( 'running', $sync->background_sync_status()['status'] ); + + $state['updated_at'] = time() - 3600; + update_option( SettingsRepository::OPTION_BACKGROUND_SYNC_STATE, $state ); + $status = $sync->background_sync_status(); + + self::assertSame( 'complete', $status['status'] ); + self::assertSame( 1, $status['failed'] ); + self::assertFalse( $status['is_active'] ); + self::assertSame( 'failed', $settings->get_sync_history()[0]['status'] ); + } + public function test_background_sync_status_reconciles_completed_actions_for_current_progress_id(): void { $GLOBALS['progress_agentic_rag_test_scheduled_actions'][] = [ 'timestamp' => time(), @@ -542,6 +434,39 @@ public function test_background_sync_status_reconciles_completed_actions_for_cur self::assertSame( 1, $state['completed'] ); } + public function test_status_does_not_count_finished_actions_while_jobs_are_pending(): void { + $queue = static fn ( string $hook, string $group, array $args, string $status ): array => [ + 'timestamp' => time(), + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + 'status' => $status, + ]; + + $GLOBALS['progress_agentic_rag_test_scheduled_actions'][] = $queue( 'progress_agentic_rag_background_sync_post', 'progress-agentic-rag-background', [ 'post_id' => 51, 'post_type' => 'post', 'background_id' => 'background-current' ], 'complete' ); + $GLOBALS['progress_agentic_rag_test_scheduled_actions'][] = $queue( 'progress_agentic_rag_background_sync_post', 'progress-agentic-rag-background', [ 'post_id' => 52, 'post_type' => 'post', 'background_id' => 'background-current' ], 'pending' ); + $GLOBALS['progress_agentic_rag_test_scheduled_actions'][] = $queue( 'progress_agentic_rag_delete_synced_resource', 'progress-agentic-rag-delete', [ 'delete_id' => 'delete-current' ], 'complete' ); + $GLOBALS['progress_agentic_rag_test_scheduled_actions'][] = $queue( 'progress_agentic_rag_delete_synced_resource', 'progress-agentic-rag-delete', [ 'delete_id' => 'delete-current' ], 'pending' ); + update_option( SettingsRepository::OPTION_BACKGROUND_SYNC_STATE, [ 'id' => 'background-current', 'status' => 'running', 'total' => 2, 'completed' => 0, 'failed' => 0 ] ); + update_option( SettingsRepository::OPTION_DELETE_SYNC_STATE, [ 'id' => 'delete-current', 'status' => 'running', 'total' => 2, 'deleted' => 0, 'failed' => 0 ] ); + + $settings = new SettingsRepository(); + $sync = new ManualSync( $settings, new ApiClient( $settings ) ); + + // Counters are trusted while the queue still has work, so the finished jobs are not read. + self::assertSame( 0, $sync->background_sync_status()['completed'] ); + self::assertSame( 0, $sync->delete_status()['deleted'] ); + + foreach ( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] as &$action ) { + $action['status'] = 'complete'; + } + unset( $action ); + + // Once the queue is idle but the runs are still open, the lost counters are rebuilt from the finished jobs. + self::assertSame( 2, $sync->background_sync_status()['completed'] ); + self::assertSame( 2, $sync->delete_status()['deleted'] ); + } + public function test_background_post_sync_schedules_delete_for_existing_non_indexable_post(): void { $GLOBALS['wpdb']->var = 'rid-42'; $post = new WP_Post( @@ -813,7 +738,7 @@ public function test_status_completes_running_state_and_preserves_recovered_mess $status = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->status(); self::assertSame( 'complete', $status['status'] ); - self::assertSame( 'Manual sync complete.', $status['message'] ); + self::assertSame( 'Sync complete.', $status['message'] ); self::assertSame( 100, $status['percent'] ); } @@ -838,11 +763,15 @@ public function test_start_reports_recovered_existing_resources(): void { 'body' => '{"resources":[{"id":"rid-61","slug":"61","seqid":"seq-61"}]}', ]; - $result = ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->start( [ 'post' ] ); + $store = $this->preparation_store( [], 1 ); + $store->expects( self::once() )->method( 'record_page' )->with( self::anything(), [ [ 'slug' => '61', 'rid' => 'rid-61', 'seqid' => 'seq-61' ] ] ); + $sync = new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ), null, $store ); + $sync->start( [ 'post' ] ); + $result = $this->finish_preparation( $sync ); self::assertSame( 1, $result['recovered'] ); - self::assertStringContainsString( 'Recovered 1 existing upstream resource.', $result['message'] ); - self::assertSame( 'rid-61', $GLOBALS['wpdb']->inserted[0]['data']['nuclia_rid'] ); + self::assertSame( 'complete', $result['status'] ); + self::assertSame( 0, $result['total'] ); } public function test_delete_synced_resources_guard_errors_and_empty_completion(): void { @@ -1161,7 +1090,9 @@ public function test_ensure_automatic_sync_skips_for_invalid_states_and_failed_c 'failed' => 1, ] ); + ( new SettingsRepository() )->add_failed_sync_item( 7, 'post', 'Post: Seven', 'Failed', 'background' ); self::assertSame( 1, $sync->ensure_automatic_sync()['failed'] ); + self::assertSame( [], $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ); } public function test_schedule_background_post_sync_skips_unselected_and_duplicate_posts(): void { @@ -1311,23 +1242,9 @@ public function test_background_delete_worker_throws_on_api_error(): void { ( new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ) )->process_background_delete( 132, 'rid-132', 'background-delete-error' ); } - public function test_manual_sync_edge_states_cover_recovery_delete_and_label_branches(): void { - $GLOBALS['wpdb']->results = [ - (object) [ - 'ID' => '141', - ], - ]; - $GLOBALS['progress_agentic_rag_test_http_responses'][] = [ - 'response' => [ - 'code' => 500, - ], - 'body' => '{"detail":"Lookup failed"}', - ]; - + public function test_sync_edge_states_cover_delete_and_label_branches(): void { $sync = new ManualSync( new SettingsRepository(), new ApiClient( new SettingsRepository() ) ); - self::assertInstanceOf( WP_Error::class, $sync->start( [ 'post' ] ) ); - update_option( SettingsRepository::OPTION_BACKGROUND_SYNC_STATE, [ @@ -1509,4 +1426,45 @@ public function get_args(): array { self::assertSame( 'complete', $action_status_ref->invoke( $sync, 'STATUS_COMPLETE', 'fallback' ) ); } + + private function preparation_store( array $ids, int $recovered = 0 ): PreparationStore { + $store = $this->createMock( PreparationStore::class ); + $store->method( 'lock' )->willReturn( true ); + $store->method( 'begin_item' )->willReturn( true ); + $store->method( 'bounds' )->willReturn( [ 'mapping_limit' => 100, 'post_limit' => 100 ] ); + foreach ( [ 'snapshot_mappings', 'snapshot_candidates', 'apply_page' ] as $method ) { + $store->method( $method )->willReturn( [ 'cursor' => 100, 'done' => true ] ); + } + $store->method( 'candidates' )->willReturn( array_map( static fn ( $id ) => (object) [ 'post_id' => $id, 'post_type' => 'post' ], $ids ) ); + $queued = []; + $outcomes = []; + $store->method( 'mark_queued' )->willReturnCallback( static function ( $run, $id ) use ( &$queued ): void { $queued[ $id ] = true; } ); + $store->method( 'outcome' )->willReturnCallback( static function ( $run, $id ) use ( &$outcomes ): string { return $outcomes[ $id ] ?? ''; } ); + $store->method( 'finish_item' )->willReturnCallback( static function ( $run, $id, $success ) use ( &$outcomes ): void { $outcomes[ $id ] = $success ? 'complete' : 'failed'; } ); + $store->method( 'stats' )->willReturnCallback( static function () use ( &$queued, &$outcomes, $recovered ): array { + return [ + 'total' => count( $queued ), 'completed' => count( array_filter( $outcomes, static fn ( $outcome ) => 'complete' === $outcome ) ), + 'failed' => count( array_filter( $outcomes, static fn ( $outcome ) => 'failed' === $outcome ) ), 'recovered' => $recovered, + ]; + } ); + return $store; + } + + private function finish_preparation( ManualSync $sync ): array { + $sync->register(); + $callback = null; + foreach ( $GLOBALS['progress_agentic_rag_test_actions'] as $action ) { + if ( ManualPreparation::HOOK === $action['hook_name'] ) { + $callback = $action['callback']; + } + } + for ( $i = 0; $i < 10; $i++ ) { + $state = get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE ); + if ( empty( $state['phase'] ) || 'running' !== $state['status'] ) { + break; + } + $callback( $state['id'], $state['step'] ); + } + return $sync->status(); + } } diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index cdd4f5a..08643db 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -60,12 +60,20 @@ if ( ! class_exists( 'WP_Error' ) ) { class WP_Error { - public function __construct( private readonly string $code = '', private readonly string $message = '' ) { + public function __construct( private readonly string $code = '', private readonly string $message = '', private readonly mixed $data = null ) { } public function get_error_message(): string { return $this->message; } + + public function get_error_code(): string { + return $this->code; + } + + public function get_error_data(): mixed { + return $this->data; + } } } @@ -126,7 +134,7 @@ class ProgressAgenticRagTestWpdb { public mixed $var = ''; public function prepare( string $query, mixed ...$args ): string { - return vsprintf( str_replace( [ '%s', '%d' ], [ "'%s'", '%d' ], $query ), $args ); + return vsprintf( str_replace( [ '%s', '%d', '%i' ], [ "'%s'", '%d', '`%s`' ], $query ), $args ); } public function delete( string $table, array $where, array $formats = [] ): bool { @@ -144,6 +152,9 @@ public function get_results( string $query ): array { } public function get_var( string $query ): mixed { + if ( str_contains( $query, 'GET_LOCK(' ) || str_contains( $query, 'RELEASE_LOCK(' ) ) { + return '1'; + } return $this->var; } @@ -653,7 +664,8 @@ function wp_remote_request( string $url, array $args = [] ): mixed { ]; if ( ! empty( $GLOBALS['progress_agentic_rag_test_http_responses'] ) ) { - return array_shift( $GLOBALS['progress_agentic_rag_test_http_responses'] ); + $response = array_shift( $GLOBALS['progress_agentic_rag_test_http_responses'] ); + return is_callable( $response ) ? $response( $url, $args ) : $response; } return [ @@ -703,6 +715,24 @@ function wp_remote_retrieve_headers( mixed $response ): array { } } +if ( ! function_exists( 'wp_remote_retrieve_header' ) ) { + function wp_remote_retrieve_header( mixed $response, string $name ): string { + return (string) ( array_change_key_case( wp_remote_retrieve_headers( $response ) )[ strtolower( $name ) ] ?? '' ); + } +} + +if ( ! function_exists( 'wp_next_scheduled' ) ) { + function wp_next_scheduled( string $hook ): int|false { + return $GLOBALS['progress_agentic_rag_test_cron'][ $hook ] ?? false; + } + function wp_schedule_single_event( int $timestamp, string $hook ): bool { + $GLOBALS['progress_agentic_rag_test_cron'][ $hook ] = $timestamp; + return true; + } +} + +defined( 'MINUTE_IN_SECONDS' ) || define( 'MINUTE_IN_SECONDS', 60 ); + if ( ! function_exists( 'wp_generate_uuid4' ) ) { function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-' . str_pad( (string) ( count( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] ) + 1 ), 12, '0', STR_PAD_LEFT ); @@ -730,6 +760,17 @@ function as_schedule_single_action( int $timestamp, string $hook, array $args = } } +if ( ! function_exists( 'as_has_scheduled_action' ) ) { + function as_has_scheduled_action( string $hook, array $args = [], string $group = '' ): bool { + foreach ( $GLOBALS['progress_agentic_rag_test_scheduled_actions'] as $action ) { + if ( $action['hook'] === $hook && $action['group'] === $group && $action['args'] === $args && in_array( $action['status'], [ 'pending', 'in-progress' ], true ) ) { + return true; + } + } + return false; + } +} + if ( ! function_exists( 'as_get_scheduled_actions' ) ) { function as_get_scheduled_actions( array $args = [], string $return_format = 'objects' ): array { $matches = []; @@ -759,7 +800,7 @@ function as_unschedule_all_actions( string $hook, mixed $args = null, string $gr $GLOBALS['progress_agentic_rag_test_scheduled_actions'] = array_values( array_filter( $GLOBALS['progress_agentic_rag_test_scheduled_actions'], - static fn ( array $action ): bool => $hook !== $action['hook'] || $group !== $action['group'] || ( null !== $args && $args !== $action['args'] ) + static fn ( array $action ): bool => $hook !== $action['hook'] || ( '' !== $group && $group !== $action['group'] ) || ( null !== $args && $args !== $action['args'] ) ) ); } diff --git a/tests/scaling/fixtures/scaling.php b/tests/scaling/fixtures/scaling.php new file mode 100644 index 0000000..92b0638 --- /dev/null +++ b/tests/scaling/fixtures/scaling.php @@ -0,0 +1,294 @@ + true, 'label' => 'Scaling fixtures' ] ); +} ); +add_filter( 'pre_http_request', 'rag_scale_http', PHP_INT_MAX, 3 ); +add_filter( 'query', static function ( string $sql ): string { + if ( str_contains( $sql, 'actionscheduler_actions' ) && preg_match( '/^\s*SELECT\b/i', $sql ) && ! preg_match( '/\bCOUNT\s*\(/i', $sql ) ) { + $GLOBALS['rag_scale_queue_reads'] = ( $GLOBALS['rag_scale_queue_reads'] ?? 0 ) + 1; + } + return $sql; +} ); + +add_action( 'rest_api_init', static function (): void { + foreach ( [ 'seed', 'clear', 'metrics', 'lookup', 'run-one', 'probe', 'interrupt', 'replay' ] as $operation ) { + register_rest_route( 'rag-scaling/v1', '/' . $operation, [ + 'methods' => 'POST', + 'callback' => 'rag_scale_' . str_replace( '-', '_', $operation ), + 'permission_callback' => static function ( WP_REST_Request $request ): bool { + $key = (string) get_option( 'progress_agentic_rag_e2e_key', '' ); + return '' !== $key && hash_equals( $key, (string) $request->get_header( 'x-rag-scaling-key' ) ); + }, + ] ); + } +} ); + +add_action( 'shutdown', static function (): void { + if ( empty( $_POST['action'] ) || ! str_starts_with( (string) $_POST['action'], 'progress_agentic_rag_' ) ) { + return; + } + update_option( 'rag_scale_last_ajax', [ + 'action' => sanitize_key( $_POST['action'] ), + 'queue_reads' => $GLOBALS['rag_scale_queue_reads'] ?? 0, + ], false ); +} ); + +function rag_scale_services(): array { + $settings = new SettingsRepository(); + $api = new ApiClient( $settings ); + return [ $api, new ManualSync( $settings, $api ) ]; +} + +function rag_scale_clear(): array { + update_option( RAG_SCALE_LOG, [], false ); + delete_option( 'rag_scale_last_ajax' ); + return [ 'ok' => true ]; +} + +function rag_scale_seed( WP_REST_Request $request ): array { + global $wpdb; + $sync_table = $wpdb->prefix . SettingsRepository::SYNC_TABLE_NAME; + + // All rows in this database are disposable: the compose project has its own volumes. + $wpdb->query( "DELETE FROM {$wpdb->prefix}actionscheduler_logs" ); + $wpdb->query( "DELETE FROM {$wpdb->prefix}actionscheduler_actions" ); + $wpdb->query( "DELETE FROM {$sync_table}" ); + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}agentic_rag_preparation" ); + wp_clear_scheduled_hook( 'progress_agentic_rag_recover_manual_sync' ); + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY ID", RAG_SCALE_TYPE ) ); + if ( count( $ids ) !== RAG_SCALE_SIZE ) { + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->posts} WHERE post_type = %s", RAG_SCALE_TYPE ) ); + for ( $offset = 0; $offset < RAG_SCALE_SIZE; $offset += 250 ) { + $rows = []; + for ( $i = $offset; $i < $offset + 250; $i++ ) { + $rows[] = $wpdb->prepare( + "(%s, 'publish', %s, %s, '', '', '', '', '')", + RAG_SCALE_TYPE, + 'Scale item ' . $i, + 'scale-item-' . $i + ); + } + $wpdb->query( "INSERT INTO {$wpdb->posts} (post_type, post_status, post_title, post_name, post_content, post_excerpt, to_ping, pinged, post_content_filtered) VALUES " . implode( ',', $rows ) ); + } + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY ID", RAG_SCALE_TYPE ) ); + } + $ids = array_map( 'intval', $ids ); + if ( RAG_SCALE_SIZE !== count( $ids ) ) { + throw new RuntimeException( 'Could not seed 10,000 scaling posts: ' . $wpdb->last_error ); + } + + $mapped = max( 0, min( RAG_SCALE_SIZE, (int) ( $request['mapped'] ?? 9000 ) ) ); + for ( $offset = 0; $offset < $mapped; $offset += 250 ) { + $rows = []; + foreach ( array_slice( $ids, $offset, min( 250, $mapped - $offset ) ) as $id ) { + $rows[] = $wpdb->prepare( '(%d, %s, %s)', $id, 'scale-rid-' . $id, '0' ); + } + $wpdb->query( "INSERT INTO {$sync_table} (post_id, nuclia_rid, nuclia_seqid) VALUES " . implode( ',', $rows ) ); + } + if ( $mapped !== (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$sync_table}" ) ) { + throw new RuntimeException( 'Could not seed sync mappings: ' . $wpdb->last_error ); + } + update_option( 'nuclia_zone', 'scaling' ); + update_option( 'nuclia_kbid', 'scaling-kb' ); + update_option( 'nuclia_token', 'fake-scaling-token' ); + update_option( 'nuclia_api_is_reachable', 'yes' ); + update_option( 'nuclia_indexable_post_types', [ RAG_SCALE_TYPE => true ] ); + foreach ( [ 'manual_sync_state', 'background_sync_state', 'delete_sync_state', 'sync_history', 'failed_sync_items' ] as $option ) { + delete_option( 'progress_agentic_rag_' . $option ); + } + update_option( RAG_SCALE_STATE, [ + 'ids' => $ids, + 'delay_ms' => min( 2000, max( 0, (int) ( $request['delay_ms'] ?? 0 ) ) ), + 'mode' => (string) ( $request['mode'] ?? 'normal' ), + 'upstream' => (int) ( $request['upstream'] ?? RAG_SCALE_SIZE ), + ], false ); + rag_scale_clear(); + + if ( ! empty( $request['queue'] ) ) { + $template_id = as_schedule_single_action( time(), 'progress_agentic_rag_background_sync_post', [], 'progress-agentic-rag-background' ); + $template = $wpdb->get_row( $wpdb->prepare( "SELECT schedule, group_id FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", $template_id ) ); + for ( $offset = 0; $offset < RAG_SCALE_SIZE; $offset += 250 ) { + $rows = []; + foreach ( array_slice( $ids, $offset, 250 ) as $id ) { + $rows[] = $wpdb->prepare( '(%s, %s, %s, %s, %d)', 'progress_agentic_rag_background_sync_post', 'complete', wp_json_encode( [ + 'post_id' => $id, 'post_type' => RAG_SCALE_TYPE, 'background_id' => 'scale-active', + ] ), $template->schedule, $template->group_id ); + } + $wpdb->query( "INSERT INTO {$wpdb->prefix}actionscheduler_actions (hook, status, args, schedule, group_id) VALUES " . implode( ',', $rows ) ); + } + update_option( 'progress_agentic_rag_background_sync_state', [ + 'id' => 'scale-active', 'status' => 'running', 'total' => RAG_SCALE_SIZE + 1, 'completed' => RAG_SCALE_SIZE, 'failed' => 0, + ] ); + } + + return [ 'posts' => count( $ids ), 'mapped' => $mapped ]; +} + +function rag_scale_metrics(): array { + global $wpdb; + $sync_table = $wpdb->prefix . SettingsRepository::SYNC_TABLE_NAME; + return [ + 'requests' => get_option( RAG_SCALE_LOG, [] ), + 'ajax' => get_option( 'rag_scale_last_ajax', [] ), + 'pending' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}actionscheduler_actions WHERE status = 'pending' AND hook LIKE 'progress_agentic_rag_%'" ), + 'mapped' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$sync_table}" ), + 'staged' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}agentic_rag_preparation" ), + ]; +} + +function rag_scale_lookup(): array { + [ $api ] = rag_scale_services(); + $state = get_option( RAG_SCALE_STATE ); + $method = new ReflectionMethod( $api, 'find_resource_by_slug' ); + $result = $method->invoke( $api, (string) $state['ids'][8999] ); + return [ + 'ok' => ! is_wp_error( $result ), + 'rid' => is_wp_error( $result ) ? '' : $result['rid'], + 'error' => is_wp_error( $result ) ? $result->get_error_message() : '', + ]; +} + +function rag_scale_probe( WP_REST_Request $request ): array { + $response = wp_remote_get( 'https://scaling.rag.progress.cloud/api/v1/kb/scaling-kb/resources?' . (string) $request['query'] ); + return is_wp_error( $response ) ? [ 'error' => $response->get_error_message() ] : json_decode( wp_remote_retrieve_body( $response ), true ); +} + +function rag_scale_run_one( WP_REST_Request $request ): array { + global $wpdb; + $store = ActionScheduler::store(); + $id = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT action_id FROM {$wpdb->prefix}actionscheduler_actions WHERE status = 'pending' AND hook LIKE %s ORDER BY action_id LIMIT 1", + $wpdb->esc_like( 'progress_agentic_rag_' ) . '%' + ) ); + if ( 0 === $id ) { + return [ 'ran' => false ]; + } + if ( ! empty( $request['preparation'] ) ) { + $id = (int) $wpdb->get_var( "SELECT action_id FROM {$wpdb->prefix}actionscheduler_actions WHERE status = 'pending' AND hook = 'progress_agentic_rag_prepare_manual_sync' ORDER BY action_id LIMIT 1" ); + if ( 0 === $id ) { + return [ 'ran' => false ]; + } + } + $action = $store->fetch_action( $id ); + ActionScheduler::runner()->process_action( $id, 'scaling-test' ); + [ , $sync ] = rag_scale_services(); + return [ + 'ran' => true, + 'hook' => $action->get_hook(), + 'args' => $action->get_args(), + 'yielded' => apply_filters( 'action_scheduler_maximum_execution_time_likely_to_be_exceeded', false ), + 'status' => $store->get_status( $id ), + 'manual' => $sync->status(), + ]; +} + +function rag_scale_interrupt(): array { + $before = get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE ); + as_unschedule_all_actions( 'progress_agentic_rag_prepare_manual_sync', [ + 'sync_id' => $before['id'], 'step' => $before['step'], + ], 'progress-agentic-rag-indexing' ); + do_action( 'progress_agentic_rag_recover_manual_sync' ); + $after = get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE ); + return [ + 'before' => [ 'step' => $before['step'], 'page' => $before['page'] ], + 'after' => [ 'step' => $after['step'], 'page' => $after['page'] ], + 'next_attempt' => $after['next_attempt'] ?? 0, + 'recovery_scheduled' => (bool) wp_next_scheduled( 'progress_agentic_rag_recover_manual_sync' ), + ]; +} + +function rag_scale_replay( WP_REST_Request $request ): array { + $args = $request['args']; + if ( isset( $args['post_id'] ) ) { + do_action( 'progress_agentic_rag_manual_sync_post', (int) $args['post_id'], (string) $args['post_type'], (string) $args['sync_id'] ); + } else { + do_action( 'progress_agentic_rag_prepare_manual_sync', (string) $args['sync_id'], (int) $args['step'] ); + } + [ , $sync ] = rag_scale_services(); + return $sync->status(); +} + +function rag_scale_response( int $code, array $body ): array { + return [ + 'headers' => [ 'Content-Type' => 'application/json', 'Retry-After' => '60' ], + 'response' => [ 'code' => $code, 'message' => 'Scaling fixture' ], + 'body' => wp_json_encode( $body ), + 'cookies' => [], + ]; +} + +function rag_scale_http( mixed $preempt, array $args, string $url ): mixed { + $host = (string) wp_parse_url( $url, PHP_URL_HOST ); + if ( ! str_ends_with( $host, '.rag.progress.cloud' ) ) { + return $preempt; + } + // No request from this test environment is allowed to reach a real KB. + $state = get_option( RAG_SCALE_STATE, [] ); + if ( empty( $state['ids'] ) ) { + return new WP_Error( 'scaling_not_seeded', 'Fake KB has not been seeded.' ); + } + static $calls = 0; + static $request_id = null; + $request_id ??= wp_generate_uuid4(); + $calls++; + $log = get_option( RAG_SCALE_LOG, [] ); + $log[] = [ + 'request' => $request_id, + 'action' => sanitize_key( $_POST['action'] ?? 'fixture' ), + 'method' => $args['method'] ?? 'GET', + 'url' => $url, + ]; + update_option( RAG_SCALE_LOG, array_slice( $log, -500 ), false ); + if ( $calls > RAG_SCALE_REQUEST_BUDGET ) { + return new WP_Error( 'scaling_request_budget', 'Fake KB stopped a runaway request after 45 API calls.' ); + } + if ( 'rate-limit' === $state['mode'] ) { + return rag_scale_response( 429, [ 'detail' => [ 'period' => 'minute', 'limit' => 2400 ] ] ); + } + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); + if ( preg_match( '#/slug/(\d+)$#', $path, $slug ) ) { + $known = in_array( (int) $slug[1], array_slice( $state['ids'], 0, $state['upstream'] ), true ); + return $known + ? rag_scale_response( 200, [ 'id' => 'scale-rid-' . $slug[1], 'slug' => $slug[1], 'last_seqid' => 0 ] ) + : rag_scale_response( 404, [ 'detail' => 'not found' ] ); + } + if ( str_ends_with( $path, '/resources' ) && 'GET' === ( $args['method'] ?? 'GET' ) ) { + usleep( $state['delay_ms'] * 1000 ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + $page = 'repeat-page' === $state['mode'] ? 0 : max( 0, (int) ( $query['page'] ?? 0 ) ); + $size = max( 1, min( 250, (int) ( $query['size'] ?? 250 ) ) ); + $resources = []; + for ( $i = $page * $size; $i < min( $state['upstream'], ( $page + 1 ) * $size ); $i++ ) { + $id = $state['ids'][ $i ]; + $resources[] = [ 'id' => 'scale-rid-' . $id, 'slug' => (string) $id, 'last_seqid' => 0 ]; + } + return rag_scale_response( 200, [ + 'resources' => $resources, + 'pagination' => [ 'page' => $page, 'size' => $size, 'last' => ( $page + 1 ) * $size >= $state['upstream'] ], + ] ); + } + if ( str_ends_with( $path, '/resources' ) && 'POST' === ( $args['method'] ?? '' ) ) { + $body = json_decode( (string) ( $args['body'] ?? '' ), true ); + return rag_scale_response( 201, [ 'uuid' => 'scale-rid-' . $body['slug'], 'seqid' => 0 ] ); + } + return rag_scale_response( 200, [ 'labelsets' => [] ] ); +} diff --git a/tests/scaling/sync.spec.ts b/tests/scaling/sync.spec.ts new file mode 100644 index 0000000..b00d8f4 --- /dev/null +++ b/tests/scaling/sync.spec.ts @@ -0,0 +1,216 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; + +const route = '/wp-json/rag-scaling/v1/'; +const type = 'rag_scale'; +const startAction = 'progress_agentic_rag_manual_sync_start'; +const statusAction = 'progress_agentic_rag_background_sync_status'; + +async function fixture(request: APIRequestContext, operation: string, data = {}): Promise { + const response = await request.post(route + operation, { + headers: { 'X-RAG-Scaling-Key': 'scaling-only' }, + data + }); + expect(response.ok(), await response.text()).toBeTruthy(); + return response.json(); +} + +async function openAdmin(page: Page): Promise { + await page.goto('/wp-login.php'); + await page.locator('#user_login').fill('admin'); + await page.locator('#user_pass').fill('scaling-only'); + await page.locator('#wp-submit').click(); + await page.waitForURL(/wp-admin/); + // Keep the tests in control of polling, rather than racing the UI timer. + await page.route('**/assets/js/admin.js*', (route) => route.abort()); + await page.goto('/wp-admin/admin.php?page=progress-agentic-rag&tab=indexation'); +} + +async function ajax(page: Page, action: string): Promise { + return page.evaluate(async ({ action, type }) => { + const config = (window as any).progressAgenticRagAdmin; + const body = new URLSearchParams({ + action, + _ajax_nonce: config.nonce, + 'post_types[]': type + }); + const started = performance.now(); + const response = await fetch(config.ajaxUrl, { method: 'POST', body }); + return { status: response.status, elapsed: performance.now() - started, body: await response.json() }; + }, { action, type }); +} + +test.beforeEach(async ({ request }) => { + expect(await fixture(request, 'seed')).toMatchObject({ posts: 10000, mapped: 9000 }); +}); + +test('fake KB reproduces ignored offsets and advancing page numbers', async ({ request }) => { + const first = await fixture(request, 'probe', { query: 'page=0&size=250' }); + const ignored = await fixture(request, 'probe', { query: 'from=250&size=250' }); + const second = await fixture(request, 'probe', { query: 'page=1&size=250' }); + expect(ignored.resources[0].id).toBe(first.resources[0].id); + expect(second.resources[0].id).not.toBe(first.resources[0].id); + expect(second.pagination.page).toBe(1); +}); + +test('a 409 conflict lookup is one slug request, not a scan of the 10,000-resource KB', async ({ request }) => { + const result = await fixture(request, 'lookup'); + const { requests } = await fixture(request, 'metrics'); + expect(result).toMatchObject({ ok: true, rid: expect.stringContaining('scale-rid-') }); + expect(requests).toHaveLength(1); + expect(new URL(requests[0].url).pathname).toMatch(/\/slug\/\d+$/); +}); + +test('repeated status polls do not contact the KB or enqueue work', async ({ page, request }) => { + await openAdmin(page); + await fixture(request, 'clear'); + const before = await fixture(request, 'metrics'); + for (let poll = 0; poll < 3; poll++) { + const response = await ajax(page, statusAction); + expect.soft(response.body.success).toBe(true); + } + const after = await fixture(request, 'metrics'); + expect.soft(after.requests, 'Status reporting must make zero upstream calls').toHaveLength(0); + expect(after.pending).toBe(before.pending); +}); + +test('manual start returns before slow KB preparation', async ({ page, request }) => { + await fixture(request, 'seed', { delay_ms: 100 }); + await openAdmin(page); + await fixture(request, 'clear'); + const response = await ajax(page, startAction); + const metrics = await fixture(request, 'metrics'); + expect.soft(metrics.requests, 'No remote preparation should run inside the start request').toHaveLength(0); + expect.soft(response.elapsed, 'Start should take under 2s even when a full scan would take at least 4s').toBeLessThan(2000); + expect.soft(response.body.success).toBe(true); + expect.soft(response.body.data.status).toMatch(/running|preparing|starting/); + expect(metrics.pending, 'Start must actually persist a background task').toBeGreaterThan(0); +}); + +test('one preparation job performs at most one KB page and leaves continuation work', async ({ page, request }) => { + await openAdmin(page); + await ajax(page, startAction); + await fixture(request, 'clear'); + const worker = await fixture(request, 'run-one'); + const metrics = await fixture(request, 'metrics'); + expect(worker.ran, 'Manual start must queue preparation for the worker').toBe(true); + expect.soft(worker.status, 'The first preparation chunk must not fail').toBe('complete'); + expect(worker.yielded, 'The actual Action Scheduler runner must yield after a preparation chunk').toBe(true); + expect.soft(metrics.requests.length, 'One chunk may fetch at most one KB page').toBeLessThanOrEqual(1); + expect.soft(metrics.pending, 'Remaining preparation must survive as queued continuation work').toBeGreaterThan(0); + expect(metrics.mapped, 'The first page must not prematurely discard later-page mappings').toBeGreaterThanOrEqual(9000); +}); + +test('10,000 unsynced posts do not all get scheduled inside a single start request', async ({ page, request }) => { + // An empty KB avoids the pagination bug, exposing unbounded scheduling independently. + await fixture(request, 'seed', { mapped: 0, upstream: 0 }); + await openAdmin(page); + const response = await ajax(page, startAction); + expect(response.body.success).toBe(true); + const metrics = await fixture(request, 'metrics'); + expect(metrics.pending, 'The start request must persist work').toBeGreaterThan(0); + expect(metrics.pending, 'Preparation/discovery must queue bounded batches, not all 10,000 posts at once').toBeLessThanOrEqual(100); +}); + +test('preparation continues across bounded jobs and finishes without losing existing mappings', async ({ page, request }) => { + await fixture(request, 'seed', { mapped: 10000 }); + await openAdmin(page); + const start = await ajax(page, startAction); + expect(start.body.success).toBe(true); + + let finalStatus = start.body.data; + for (let job = 0; job < 100 && finalStatus.status !== 'complete'; job++) { + await fixture(request, 'clear'); + const worker = await fixture(request, 'run-one'); + expect(worker.ran, 'Preparation must persist continuation work until it is complete').toBe(true); + expect(worker.status).toBe('complete'); + const metrics = await fixture(request, 'metrics'); + expect(metrics.requests.length, `Preparation job ${job} exceeded its API-call budget`).toBeLessThanOrEqual(1); + expect(metrics.mapped).toBe(10000); + finalStatus = worker.manual; + } + expect(finalStatus.status, 'Preparation must eventually finish, not only stay within each job budget').toBe('complete'); + expect((await fixture(request, 'metrics')).pending).toBe(0); +}); + +test('one complete background scan recovers 1,000 missing local mappings without uploading duplicates', async ({ page, request }) => { + await openAdmin(page); + const start = await ajax(page, startAction); + let finalStatus = start.body.data; + for (let job = 0; job < 100 && finalStatus.status === 'running'; job++) { + const worker = await fixture(request, 'run-one', { preparation: true }); + expect(worker.ran).toBe(true); + expect(worker.status).toBe('complete'); + finalStatus = worker.manual; + } + expect(finalStatus).toMatchObject({ status: 'complete', total: 0, recovered: 1000 }); + const metrics = await fixture(request, 'metrics'); + expect(metrics.mapped).toBe(10000); + expect(metrics.pending).toBe(0); + expect(metrics.requests).toHaveLength(40); + expect(metrics.requests.every((entry: any) => entry.method === 'GET')).toBe(true); +}); + +test('stale mappings are removed only after the scan and missing upstream posts are queued in batches', async ({ page, request }) => { + await fixture(request, 'seed', { mapped: 10000, upstream: 9000 }); + await openAdmin(page); + let status = (await ajax(page, startAction)).body.data; + for (let job = 0; job < 200 && status.preparing; job++) { + const worker = await fixture(request, 'run-one', { preparation: true }); + expect(worker.ran).toBe(true); + expect(worker.status).toBe('complete'); + status = worker.manual; + } + expect(status).toMatchObject({ status: 'running', preparing: false, total: 1000 }); + const before = await fixture(request, 'metrics'); + expect(before.mapped).toBe(9000); + expect(before.pending).toBe(1000); + const uploaded = await fixture(request, 'run-one'); + expect(uploaded.status, JSON.stringify(uploaded)).toBe('complete'); + expect(uploaded.manual, JSON.stringify(uploaded)).toMatchObject({ completed: 1, failed: 0 }); + expect((await fixture(request, 'metrics')).mapped).toBe(9001); + await fixture(request, 'clear'); + expect(await fixture(request, 'replay', { args: uploaded.args })).toMatchObject({ completed: 1, failed: 0 }); + expect((await fixture(request, 'metrics')).requests).toHaveLength(0); +}); + +test('a missing continuation is recovered from its checkpoint and duplicate jobs do nothing', async ({ page, request }) => { + await openAdmin(page); + await ajax(page, startAction); + const worker = await fixture(request, 'run-one'); + await fixture(request, 'clear'); + const interrupted = await fixture(request, 'interrupt'); + expect((await fixture(request, 'metrics')).staged).toBeGreaterThan(0); + expect(interrupted.after).toEqual(interrupted.before); + expect(interrupted.next_attempt).toBeGreaterThan(Date.now() / 1000); + expect(interrupted.recovery_scheduled).toBe(true); + expect((await fixture(request, 'metrics')).pending).toBe(1); + await fixture(request, 'replay', { args: worker.args }); + expect((await fixture(request, 'metrics')).requests).toHaveLength(0); + const cancelled = await ajax(page, 'progress_agentic_rag_manual_sync_cancel'); + expect(cancelled.body.data.status).toBe('cancelled'); + expect((await fixture(request, 'metrics')).pending).toBe(0); + expect((await fixture(request, 'metrics')).staged).toBe(0); +}); + +test('rate-limit responses identify the cause and do not retry in a tight loop', async ({ request }) => { + await fixture(request, 'seed', { mode: 'rate-limit' }); + const result = await fixture(request, 'lookup'); + const metrics = await fixture(request, 'metrics'); + expect(result.ok).toBe(false); + expect.soft(result.error).toMatch(/rate.?limit|too many requests|429/i); + expect(metrics.requests).toHaveLength(1); + expect(metrics.mapped).toBe(9000); +}); + +test('status with 10,000 completed actions does not hydrate the entire queue', async ({ page, request }) => { + await fixture(request, 'seed', { queue: true }); + await openAdmin(page); + await fixture(request, 'clear'); + const response = await ajax(page, statusAction); + const metrics = await fixture(request, 'metrics'); + expect(response.body.success).toBe(true); + expect(response.body.data.completed).toBe(10000); + expect(metrics.requests).toHaveLength(0); + // Allow normal bookkeeping queries, but not one fetch per completed action. + expect(metrics.ajax.queue_reads).toBeLessThan(100); +}); diff --git a/uninstall.php b/uninstall.php index bbbfa85..9fb88d8 100644 --- a/uninstall.php +++ b/uninstall.php @@ -37,3 +37,8 @@ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Intentional uninstall cleanup for the plugin-owned table; table name is escaped. $wpdb->query( "DROP TABLE IF EXISTS {$progress_agentic_rag_table_name}" ); + +$progress_agentic_rag_preparation_table = esc_sql( $wpdb->prefix . 'agentic_rag_preparation' ); +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned staging table. +$wpdb->query( "DROP TABLE IF EXISTS {$progress_agentic_rag_preparation_table}" ); +wp_clear_scheduled_hook( 'progress_agentic_rag_recover_manual_sync' );