Compare commits

32 Commits

Author SHA1 Message Date
wpdev 4b34851316 docs(2meet-data-optimizer): correct the 1.4.0 upgrade step for the copy index
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 25s
Tests / PHP Lint (pull_request) Successful in 55s
Tests / Unit Tests (pull_request) Successful in 56s
Tests / Integration Tests (pull_request) Successful in 1m46s
Tests / PHPCS (pull_request) Successful in 44s
Tests / PHPStan (pull_request) Successful in 1m4s
- Replace the index only on tables listed in tmdo_post_status_columns
  (ALTER ADD idx_post_status_date_id / DROP idx_post_status_date)
- Warn that `wp tmdo post-status-sync` also adds and backfills copy
  columns on every other post group (large ALTER + UPDATE)
- Found in the 2.4 rollout (dev21: aeav_only, over ten million posts)
2026-09-28 00:03:02 +08:00
wpdev b30baeddf0 Merge pull request 'hardening 2.4: 2meet-data-optimizer 1.4.0 (index-ordered routed sorts, date copy route, count invalidation)' (#8) from hardening/2.4 into main
Tests / PHP Lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 1m10s
Tests / Integration Tests (push) Failing after 2m0s
Tests / PHPCS (push) Successful in 1m31s
Tests / PHPStan (push) Successful in 50s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Failing after 14m4s
2026-09-27 23:40:30 +08:00
wpdev 13c4a1bcb9 chore(2meet-data-optimizer): 1.4.0 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 27s
Tests / Unit Tests (pull_request) Successful in 44s
Tests / PHP Lint (pull_request) Successful in 27s
Tests / PHPCS (pull_request) Successful in 35s
Tests / Integration Tests (pull_request) Successful in 1m41s
Tests / PHPStan (pull_request) Successful in 1m23s
- CHANGELOG / readme: PR-118, 122, 123, 127, 128, 129; upgrade step
  `wp tmdo post-status-sync` for the three-column copy index
- Version 1.3.0 -> 1.4.0 (header, TMDO_VERSION, Stable tag)
- PR: PR-124
2026-09-27 23:29:11 +08:00
wpdev 692b23aed9 fix(2meet-data-optimizer): drop cached routed counts when the migration engine rebuilds post rows
- migrate_entity (drift-check --fix), migrate_group and migrate_group_batch
  write flat rows outside the Hook Bus: bump the routed count version
  (once per call / batch; not on dry runs; post rows only)
- dev36: a vendor search kept counting 21515 of 21522 matches after a fix
- Verify: composer test (704), test:integration (598), phpcs, stan
- PR: PR-129
2026-09-27 23:25:03 +08:00
wpdev 8ee3dce0fe perf(2meet-data-optimizer): let the status/date copy route stop at the LIMIT
- Copy route: drop WordPress's GROUP BY when the flat row is the only join,
  and read posts.ID tie-breaks as the flat post_id (own direction, once)
- Post status sync: index (post_status, post_date, post_id) replaces the
  two-column idx_post_status_date (added / dropped by post-status-sync)
- dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms
- Verify: composer test (704), test:integration (597), phpcs, stan
- PR: PR-128
2026-09-27 23:12:34 +08:00
wpdev 2aa9058ee9 fix(2meet-data-optimizer): keep a post row's status and date copies when the migration engine rebuilds it
- migrate_entity() (drift-check --fix, per-row backfill) REPLACEs the whole
  flat row and left out the post_status / post_date copies, so a rebuilt row
  had NULL status. One such row turns the status/date copy route off for the
  whole group: on dev36 a single row rebuilt by the stage-3 drift fix made the
  date-ordered price search 1.47 s instead of driving from the flat index
- post rows now carry TMDO_Post_Status_Sync::insert_columns(); the bulk pivot
  backfill (ON DUPLICATE KEY UPDATE) was not affected
- Verify: PostStatusSyncTest (existing and new rows keep their copies),
  integration 593, unit 704, phpcs, stan; dev36 post-status-sync + front-end
- PR: PR-127 (found in 2.4 profiling)
2026-09-27 22:58:09 +08:00
wpdev 66db9e4c56 perf(2meet-data-optimizer): route HivePress's featured-listing lookup
- HivePress runs it on every listing search page from its own
  pre_get_posts: get_posts() with the search's meta query (still unrouted,
  including its sort group), meta_key hp_featured and orderby rand. The
  router declined both shapes, so the lookup stayed native: 2.26–2.38 s of a
  2.5 s page at 1.3M listings on dev36
- a sort group (key NOT EXISTS OR key EXISTS) that no orderby uses matches
  every post and is dropped; meta_key without meta_value and without a
  meta_value sort routes as EXISTS (WP_Query turns it into that clause)
- Verify: PostQueryRouterTest (featured lookup routed, meta_key with a value
  untouched), integration 592, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122 (follow-up)
2026-09-27 22:54:17 +08:00
wpdev 2ed48c3ba3 perf(2meet-data-optimizer): route meta clauses without a value as EXISTS
- WP_Meta_Query adds a value condition only when 'value' is set, so a clause
  like array( 'key' => 'hp_featured' ) only tests that the key exists. The
  router read it as "= ''", declined it, and kept the whole query native
- HivePress runs such a query on every listing search page (featured-listing
  lookup in Attribute::set_search_query(): hp_featured plus the search's own
  filters, ORDER BY RAND()); at 1.3M listings on dev36 it took 2.26 s of a
  2.5 s page, routed or not
- Verify: PostQueryRouterTest (key-only clause routes as IS NOT NULL),
  integration 590, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122
2026-09-27 22:46:52 +08:00
wpdev eac0955b76 perf(2meet-data-optimizer): let flat-first sorts use a (column, id) index
- a group index may end with the entity ID column (post_id, ...)
- flat-first rewrites the posts.ID tie-breaks once, in the sort's direction:
  with a (column, post_id) index MySQL returns rows in order instead of
  sorting the whole match. dev36 (1.3M listings), price range sorted by price:
  850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column
  (natively ID DESC); pagination stays stable, result sets are unchanged
- Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC
  tie-break, single tie-break), unit 704, integration 589, phpcs, stan;
  dev36 with the HivePress indexes (PR-119)
- PR: PR-118
2026-09-27 22:40:59 +08:00
wpdev b2725b219d test(2meet-data-optimizer): live tools no longer flush the object cache
- mut.php called wp_cache_flush() after each write and qbench.php
  wp_cache_flush_group() before each run; with Redis Object Cache's selective
  flush both are one server-side Lua scan over the site's keys, which blocked
  the shared Redis for 5–20 s on dev36 (3 M keys) and broke other sites'
  requests (BUSY / read errors)
- mut.php clears the touched post or user caches plus the runtime cache;
  qbench.php bumps the routed count version (posts' last_changed before 1.3.0)
- tests/ is not packaged; plugin code does not flush
2026-09-27 22:38:16 +08:00
wpdev f929165b7b Merge pull request 'hardening 2.3: 2meet-data-optimizer 1.3.0 (flat-table DDL outside web requests, routed count cache, audit fixes)' (#7) from hardening/2.3 into main
Tests / PHP Lint (push) Successful in 11s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 14s
Tests / Unit Tests (push) Successful in 43s
Tests / PHPStan (push) Failing after 14m14s
Tests / PHPCS (push) Failing after 14m16s
Tests / Integration Tests (push) Failing after 15m11s
2026-09-27 18:19:17 +08:00
wpdev 947e130386 docs(2meet-data-optimizer): add PR-116 and PR-117 to the 1.3.0 release notes
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 24s
Tests / PHP Lint (pull_request) Successful in 40s
Tests / Unit Tests (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m23s
Tests / PHPCS (pull_request) Successful in 29s
Tests / PHPStan (pull_request) Successful in 1m14s
- routed count cache: placeholder escape and count version group
- PR: PR-101 (follow-up)
2026-09-27 17:58:07 +08:00
wpdev ca2cbdab65 perf(2meet-data-optimizer): keep cached routed counts across unrelated post meta writes
- cached counts followed posts' last_changed, which WordPress moves on any
  post meta write; themes write meta on page views (on dev36 Blocksy saved a
  dynamic-CSS descriptor for each listing a crawler visited), so the cache
  was invalidated every few seconds and keyword/filter searches recounted
- counts now follow their own last_changed group (tmdo_posts), bumped on
  clean_post_cache (post saved or deleted; hooked in every mode) and on flat
  writes through the Hook Bus, plus terms' last_changed; a routed search reads
  no unmanaged meta
- Verify: PostQueryRouterFoundRowsTest (unrelated meta write keeps the count,
  save or flat write recounts), integration 588, unit 703, phpcs, stan;
  dev36 front-end
- PR: PR-117 (2.3 acceptance)
2026-09-27 17:46:54 +08:00
wpdev 7851f150fa perf(2meet-data-optimizer): key the routed count cache on the SQL without wpdb's placeholder escape
- keyword searches carry wpdb's placeholder escape for "%" ({<hash>}), which
  is new in every request, so md5(count SQL) never matched across requests
  and every keyword search recounted (the 2.2 "keyword + filter routed slower
  than native" finding). The key now uses remove_placeholder_escape()
- found in the 2.3 acceptance: dev36 keyword + price routed 6.2 s vs native
  4.9 s; PR-091's in-process check reused one process, one escape
- Verify: PostQueryRouterFoundRowsTest (two escapes, one COUNT), integration
  588, unit 703, phpcs, stan; dev36 front-end
- PR: PR-116 (2.3 acceptance)
2026-09-27 17:39:36 +08:00
wpdev 1835c8776a docs(2meet-data-optimizer): add PR-111, PR-113 and PR-114 to the 1.3.0 release notes
- deferred-upgrade hardening from the acceptance reviews, notice() audit
  trail, known cold-cache version window
- PR: PR-101 (follow-up)
2026-09-27 17:34:19 +08:00
wpdev 1d70d13f45 fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or
  upgraded by another process) goes through end_deferral() instead of staying
  native for good (review F2)
- the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with
  ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3)
- a new group table created while the entity writes to flat stays native
  until the drift fix has filled it from native meta; an opted-in HivePress
  attribute no longer routes searches to an empty table (F6)
- direct_read() returns native values for a deferred group (F10); the Hook
  Bus ignores deferral in aeav_only, as the router does
- dbDelta failures: the definition is recorded only when every column exists
  (schema.upgrade_failed otherwise), so a failed ALTER is retried and the
  group is not released onto missing columns
- test bootstrap: get_metadata() reads the real meta table
- Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration
  587, unit 703, phpcs, stan
- PR: PR-114 (2.3 acceptance correctness review)
2026-09-27 17:30:36 +08:00
wpdev e26aadc4ec fix(2meet-data-optimizer): keep the audit trail of mode changes and snapshot deletions
- TMDO_Logger::notice() is always written to the error log (it had no
  callers); bridge_mode_changed and snapshot_deleted use it. Since PR-095
  info() is silent without WP_DEBUG_LOG, which left no record of who switched
  a mode or deleted a snapshot (security review L1)
- Verify: LoggerTest (notice always logged, info gated), unit 703,
  integration 585, phpcs, stan
- PR: PR-113 (2.3 acceptance security review)
2026-09-27 17:25:56 +08:00
wpdev 29adb05567 fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix
- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(),
  and dbDelta on an existing table had no lock, so concurrent requests each
  ran the ALTER on a large table. The upgrade now runs under the same MySQL
  GET_LOCK as the index DDL (skipped when another process holds it) and
  re-checks the stored hash once locked; this covers aeav_only too. The table
  build moves unchanged to write_table(); lock()/unlock() are shared
- security review M1: after a deferred upgrade the group stays native until
  tmdo_drift_resync has rebuilt the rows written meanwhile
  (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat
  writes (disabled) it is released at once
- Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release),
  integration 585, unit 702, phpcs, stan
- PR: PR-111 (2.3 acceptance security review)
2026-09-27 17:22:57 +08:00
wpdev 0aec4b9bb6 docs(2meet-data-optimizer): add PR-106 to PR-110 to the 1.3.0 release notes
- Changed: deferred table upgrades (PR-107), index lock on the primary
  (PR-108), cold cache flush by version (PR-110); Fixed: dropped tables in
  post status sync (PR-106)
- Verify: composer test
- PR: PR-101 (follow-up)
2026-09-27 17:12:54 +08:00
wpdev eb1c8b78f4 perf(2meet-data-optimizer): flush the cold cache group without a server-side scan
- TMDO_Cache_Layer::flush_group() bumps the post type's cold cache version
  (option wpdo_cold_cache_version) instead of wp_cache_flush_group(), which
  Redis Object Cache runs as one server-side scan that blocks Redis on a large
  group; entries under older keys expire by their TTL
- TMDO_Zone_Cold::cache_key() builds every cold blob key; version 0 keeps the
  current keys, so upgrading invalidates nothing
- Verify: CacheLayerTest (new keys, no scan, other post types untouched),
  unit 702, integration 583, phpcs, stan
- PR: PR-110
2026-09-27 17:12:14 +08:00
wpdev 06a3594417 fix(2meet-data-optimizer): take the index lock on the primary under read/write splitting
- when $wpdb has send_reads_to_masters() (HyperDB, LudicrousDB), it is called
  before SELECT GET_LOCK, which a replica would take without locking out the
  ALTER on the primary
- Verify: GroupCompositeIndexTest (call order through a wpdb proxy),
  integration 583, unit 701, phpcs, stan
- PR: PR-108
2026-09-27 17:09:10 +08:00
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00
wpdev 5968bcd95d fix(2meet-data-optimizer): forget post status columns of groups whose flat table is gone
- on_transition() skips a recorded group whose table no longer exists and
  removes it from tmdo_post_status_columns; every post status change used to
  UPDATE the missing table and print a database error (seen on dev36 after a
  throwaway group's table was dropped)
- Verify: PostStatusSyncTest (dropped table), integration 578, unit 701, phpcs, stan
- PR: PR-106
2026-09-27 16:59:08 +08:00
wpdev 303781e68b docs(2meet-data-optimizer): 1.3.0 release notes
- version 1.3.0; CHANGELOG and readme.txt for PR-090a, 091, 093 (verified,
  no change), 094–098, with the post-upgrade steps (first CLI / cron run adds
  the HivePress indexes; INFO lines need WP_DEBUG_LOG)
- Verify: composer test (701)
- PR: PR-101
2026-09-27 16:46:55 +08:00
wpdev d46913de41 chore(2meet-data-optimizer): prefix the global variables in uninstall.php
- $bootstrap_files, $bootstrap_ok, $f, $result, $snapshot_id and $dir become
  $wpdo_* like the file's other globals (WordPress.org Plugin Check)
- Verify: PrefixAllGlobals sniff 0, phpcs, UninstallGuardTest and
  InstallerCleanupTest, unit 701
- PR: PR-098
2026-09-27 16:41:08 +08:00
wpdev 1b50df7cd0 refactor(2meet-data-optimizer): handle admin form posts before the page renders
- the main page's form handlers move unchanged from render_page() to
  handle_post(), hooked on load-{page}: inside render_page() the admin
  header was already sent, so their wp_safe_redirect() calls could not work
- the setup wizard's step 4 snapshot post moves to handle_snapshot_post() on
  its load hook; a failure redirects with snap_failed=1 for the notice
- Verify: unit 701, integration 577, phpcs, stan; dev36 load hook handles a
  posted reset and redirects before output
- PR: PR-097
2026-09-27 16:39:56 +08:00
wpdev a268470b60 fix(2meet-data-optimizer): treat contact and address keys as private in the public REST filter
- rest_exposable() also withholds keys named like an email, phone, address or
  IP, even when a field declares show_in_rest (defence in depth)
- Verify: RestApiTest (private and look-alike public keys), unit 701,
  integration 577, phpcs, stan; dev36 live rest suite
- PR: PR-096
2026-09-27 16:36:51 +08:00
wpdev 1bdd51d9a3 fix(2meet-data-optimizer): log info and notice events only when debug logging is on
- TMDO_Logger::info()/notice() write to the PHP error log only with
  WP_DEBUG_LOG (filter tmdo_log_info); every CLI run and production request
  printed [WPDO][INFO] lines
- debug() keeps its WP_DEBUG gate; warning() and error() are unchanged
- Verify: LoggerTest (2 new), unit 700, integration 577, phpcs, stan
- PR: PR-095
2026-09-27 16:35:13 +08:00
wpdev 22b46ad175 perf(2meet-data-optimizer): stop writing an option for every rate-limited view
- blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10,
  each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes,
  and the admin top list stays representative (unbiased estimate)
- Verify: RestApiTest counts option writes (about 20 for 200 blocked requests),
  unit 698, integration 577, phpcs, stan
- PR: PR-094
2026-09-27 16:33:16 +08:00
wpdev b187c1c4d3 perf(2meet-data-optimizer): count routed searches from a cached base count minus the excluded posts
- a routed search with post__not_in (HivePress sends a random set per page)
  caches the COUNT without the exclusion and subtracts a live primary-key
  count of the excluded posts that match; the total stays exact
- the excluded count drops STRAIGHT_JOIN so MySQL starts from posts.ID
- Verify: PostQueryRouterFoundRowsTest, unit 698, integration 577, phpcs, stan;
  dev36 live router suite and keyword + price search
- PR: PR-091
2026-09-27 16:19:11 +08:00
wpdev ec8d69a6f4 fix(2meet-data-optimizer): add declared flat-table indexes from WP-CLI or WP-Cron only, one process at a time
- ensure_group_indexes() runs the ALTER only under WP-CLI or WP-Cron (filter
  tmdo_schema_ddl_allowed); web requests, including /wp-admin/ before
  auth_redirect() and admin-post.php, schedule tmdo_ensure_group_indexes instead
- MySQL GET_LOCK keeps it to one process (add_option() is INSERT ... ON
  DUPLICATE KEY UPDATE and cannot serve as a lock); released by MySQL if the
  process dies
- the applied signature is recorded only when every ALTER succeeds; failures
  log schema.index_failed and stay in pending_indexes(), which doctor reports
- new tables get their declared cidx_ indexes in CREATE TABLE
- test stub wpdb::query()/insert() return false on SQL errors like wpdb
- Verify: GroupCompositeIndexTest 7/7, unit 698, integration 576, phpcs, stan
- PR: PR-090a
2026-09-27 16:07:23 +08:00
wpdev 28ac6e3cad Merge pull request 'hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics)' (#6) from hardening/2.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
2026-09-27 10:56:47 +08:00
37 changed files with 1638 additions and 140 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.2.0
* Version: 1.4.0
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.2.0' );
define( 'TMDO_VERSION', '1.4.0' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
+116
View File
@@ -7,6 +7,122 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。
### Changed
- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的)
改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的
entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。
同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118)
- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing
查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同;
沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing
查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122)
- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的
`GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為
`idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。
dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128)
### Fixed
- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` /
`post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127)
- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增),
修正後的 found rows 不再沿用舊的快取計數。(PR-129)
### Dev
- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本
掃描,會卡住共用 Redis 5–20 s)。(PR-123)
### 升級後
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
離峰時段以 `--dry-run` 確認列數。
- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、
評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。
---
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
與系列稽核(2026-09-27)。
### Changed
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape
(每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的
原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus
的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入
都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效
(PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為
WordPress 核心的 LIKE 全表掃描)。
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
`show_in_rest`。(PR-096)
- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入
`tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生
(Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。
新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107)
驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過
`ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表;
寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的
HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表
(`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed`
並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114)
- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`,
`GET_LOCK` 才會在 primary 上。(PR-108)
- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫
`wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住
Redis);舊 key 依 TTL 過期。(PR-110)
### Fixed
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
不存在的表並印出 DB error。(PR-106)
### Security(已驗證,無程式變更)
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
改名反而讓新快照脫離保護。(PR-093)
### 升級後
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
---
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
+17 -4
View File
@@ -89,13 +89,16 @@ class TMDO_Admin {
* Register the admin menu page under Tools.
*/
public static function register_menu(): void {
add_management_page(
$hook_suffix = add_management_page(
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
'manage_options',
self::MENU_SLUG,
array( __CLASS__, 'render_page' )
);
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) );
}
}
/**
@@ -326,9 +329,10 @@ class TMDO_Admin {
}
/**
* Render the admin page.
* Handle the page's form posts on load-{page}, before any output, so their
* redirects work (inside render_page() the admin header was already sent).
*/
public static function render_page(): void {
public static function handle_post(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
@@ -752,6 +756,15 @@ class TMDO_Admin {
);
exit;
}
}
/**
* Render the admin page.
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$tabs = array(
'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
@@ -774,7 +787,7 @@ class TMDO_Admin {
'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ),
);
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) );
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change.
// v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry.
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
$tab = 'entity-bridge';
+30 -15
View File
@@ -106,7 +106,7 @@ class TMDO_Setup_Wizard {
* @return void
*/
public static function register_menu_page(): void {
add_submenu_page(
$hook_suffix = add_submenu_page(
'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
'wpdo-setup-wizard',
array( __CLASS__, 'render_page' )
);
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
}
}
/**
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
* P1-6b hardening), handled on load-{page} so the redirect runs before any
* output.
*
* @return void
*/
public static function handle_snapshot_post(): void {
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return;
}
check_admin_referer( self::NONCE_NAME );
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
exit;
}
/**
@@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {
</p>
</form>
<?php
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening).
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
if ( ! empty( $result['ok'] ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
exit;
}
// The snapshot itself is taken in handle_snapshot_post(), before any output.
if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
}
?>
+8
View File
@@ -142,6 +142,14 @@ class TMDO_CLI {
foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
}
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
if ( $deferred ) {
WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' );
}
$pending = TMDO_Schema_Manager::pending_indexes();
if ( $pending ) {
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$status = is_array( $status ) ? $status : array();
+12 -12
View File
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
// Check which IDs are already cached.
$uncached = array();
foreach ( $post_ids as $pid ) {
$cached = wp_cache_get( "cold_{$pid}", $group );
$cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group );
if ( false === $cached ) {
$uncached[] = (int) $pid;
}
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
// Cache all results (including empty arrays for posts with no cold data).
foreach ( $uncached as $pid ) {
$data = $fetched[ $pid ] ?? array();
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl );
}
}
@@ -114,7 +114,7 @@ class TMDO_Cache_Layer {
// Force a fresh read from DB (bypasses cache).
$group = 'wpdo_cold_' . sanitize_key( $post_type );
wp_cache_delete( "cold_{$post_id}", $group );
wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group );
// Re-read will populate cache.
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
@@ -136,18 +136,18 @@ class TMDO_Cache_Layer {
/**
* Flush all cold cache for a post type.
*
* Useful after bulk imports or schema changes.
* Useful after bulk imports or schema changes. Bumps the post type's cache
* version (new keys from now on) instead of wp_cache_flush_group(), which
* Redis Object Cache runs as one server-side scan that blocks Redis on a
* large group; older entries expire by their TTL.
*
* @param string $post_type Post type to flush.
*/
public static function flush_group( string $post_type ): void {
$group = 'wpdo_cold_' . sanitize_key( $post_type );
if ( function_exists( 'wp_cache_flush_group' ) ) {
wp_cache_flush_group( $group );
}
// If wp_cache_flush_group is not available (older WP or no object cache),
// individual entries will expire via TTL.
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
$versions = is_array( $versions ) ? $versions : array();
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
}
/**
@@ -183,7 +183,7 @@ class TMDO_Cache_Layer {
return array(
'groups' => $groups,
'prefetch_support' => true,
'flush_support' => function_exists( 'wp_cache_flush_group' ),
'flush_support' => true,
);
}
+6
View File
@@ -261,6 +261,9 @@ class TMDO_Core {
$post_router = new TMDO_Post_Query_Router();
$post_router->register_hooks();
}
if ( class_exists( 'TMDO_Post_Query_Router' ) ) {
add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) );
}
// Zone B: Warm cleanup cron handler.
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
@@ -306,6 +309,9 @@ class TMDO_Core {
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
// 1.3.0: declared indexes a visitor request left for WP-Cron.
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
+2
View File
@@ -789,6 +789,7 @@ class TMDO_Installer {
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
@@ -993,6 +994,7 @@ class TMDO_Installer {
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
+24 -7
View File
@@ -48,7 +48,7 @@ class TMDO_Logger {
/**
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
*
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
* Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
* for actual errors). Mode changes / cache flushes / audit prunes call here
* many times per request — keeping them out of the DB error table.
*
@@ -57,19 +57,36 @@ class TMDO_Logger {
* @return void
*/
public static function info( string $event, array $context = array() ): void {
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
// CLI run and production request would flood the log.
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
return;
}
self::write_info( $event, $context );
}
/**
* Write an INFO line to the PHP error log.
*
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
private static function write_info( string $event, array $context ): void {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
* Log a NOTICE-level event — alias of info() for callers that want a stronger
* level intent. Same routing (error_log only).
* Log a NOTICE-level event: always written to the PHP error log (unlike
* info()), for the audit trail of operator actions such as mode changes
* and snapshot deletions.
*
* @param string $event Event name.
* @param array $context Structured context.
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
public static function notice( string $event, array $context = array() ): void {
self::info( $event, $context );
error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
@@ -97,7 +114,7 @@ class TMDO_Logger {
*/
public static function debug( string $event, array $context = array() ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context );
self::write_info( $event, $context );
}
}
+14 -7
View File
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class TMDO_REST_API {
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
private const RL_STATS_SAMPLE = 10;
const NAMESPACE = 'wpdo/v1';
/**
@@ -725,14 +728,18 @@ class TMDO_REST_API {
if ( $cookie_blocked || $ip_blocked ) {
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
$rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true );
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
// anonymous requests cannot turn every blocked view into an option write.
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
$rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true );
}
update_option( 'wpdo_rl_stats', $rl_stats, false );
}
update_option( 'wpdo_rl_stats', $rl_stats, false );
return new WP_REST_Response(
array(
+5 -2
View File
@@ -282,7 +282,8 @@ class TMDO_Schema_Registry {
* Whether a field may appear in the public listings endpoints: declared
* show_in_rest, not a protected meta key (leading underscore — WordPress
* hides those from public output, e.g. _purchase_note), and not named like a
* credential (tokens, secrets, passwords, API keys), whatever it declares.
* credential (tokens, secrets, passwords, API keys) or a contact / location
* detail (email, phone, address, IP), whatever it declares.
*
* @param array<string, mixed> $field Registered field.
* @return bool
@@ -291,7 +292,9 @@ class TMDO_Schema_Registry {
$key = (string) ( $field['meta_key'] ?? '' );
return ! empty( $field['show_in_rest'] )
&& 0 !== strpos( $key, '_' )
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) );
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) )
// Contact and location details (2.3 PR-096): private even when declared public.
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
}
/**
@@ -237,6 +237,7 @@ final class TMDO_Drift_Check {
public static function run_scheduled_fix( string $entity_type ): void {
try {
$result = self::fix( $entity_type );
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
} catch ( InvalidArgumentException | RuntimeException $e ) {
TMDO_Logger::info(
@@ -176,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
// 標記完成
if ( ! $options['dry_run'] ) {
self::mark_migration_completed( $entity_type, $group_name );
if ( $stats['migrated'] > 0 ) {
self::counts_changed( $entity_type );
}
}
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
@@ -183,6 +186,19 @@ final class TMDO_Entity_Migration_Engine {
return $stats;
}
/**
* Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed
* post counts cached for the old rows are stale (2.4 PR-129).
*
* @param string $entity_type Entity type.
* @return void
*/
private static function counts_changed( string $entity_type ): void {
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) {
TMDO_Post_Query_Router::bump_count_version();
}
}
/**
* Rebuild one entity's row of a group from native meta.
*
@@ -194,7 +210,7 @@ final class TMDO_Entity_Migration_Engine {
if ( ! $adapter || empty( $group_fields ) ) {
return false;
}
return self::migrate_single_entity(
$migrated = self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
@@ -203,6 +219,10 @@ final class TMDO_Entity_Migration_Engine {
$adapter,
false
);
if ( $migrated ) {
self::counts_changed( $entity_type );
}
return $migrated;
}
/**
@@ -278,6 +298,15 @@ final class TMDO_Entity_Migration_Engine {
}
}
// REPLACE drops columns it is not given: keep a post row's status/date
// copies, or one NULL row turns the copy route off for the group (2.4 PR-127).
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) {
foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) {
$data[ $col ] = $value;
$formats[] = '%s';
}
}
if ( $dry_run ) {
return true;
}
@@ -400,6 +429,9 @@ final class TMDO_Entity_Migration_Engine {
$total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size;
if ( $batch_migrated > 0 ) {
self::counts_changed( $entity_type );
}
if ( $done ) {
self::mark_migration_completed( $entity_type, $group_name );
@@ -159,9 +159,12 @@ final class TMDO_Entity_Registry {
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
}
$keys = array_column( $normalized, 'key' );
// A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118).
$id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column();
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
$columns = array_values( array_map( 'strval', (array) $columns ) );
if ( $columns && ! array_diff( $columns, $keys ) ) {
$fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns;
if ( $fields && ! array_diff( $fields, $keys ) ) {
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
}
}
+24 -6
View File
@@ -912,6 +912,7 @@ final class TMDO_Hook_Bus {
private static function touch_posts_last_changed( string $type ): void {
if ( 'post' === $type ) {
wp_cache_set_posts_last_changed();
TMDO_Post_Query_Router::bump_count_version();
}
}
@@ -948,10 +949,23 @@ final class TMDO_Hook_Bus {
*/
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null === $field_def || 'post' !== $type ) {
return $field_def;
if ( null !== $field_def && 'post' === $type ) {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
}
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
// A group whose table upgrade waits for WP-CLI / WP-Cron stays native
// (not in aeav_only, where native is stale; the router agrees).
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
}
/**
* Whether a group is deferred and so read and written natively.
*
* @param string $type Entity type.
* @param string $group Group name.
* @return bool
*/
private static function deferred( string $type, string $group ): bool {
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
}
/**
@@ -969,10 +983,13 @@ final class TMDO_Hook_Bus {
// 回傳所有群組的所有欄位
$result = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
$row = self::get_or_load_row( $type, $entity_id, $group );
$deferred = self::deferred( $type, $group );
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
$result[ $field['key'] ] = $deferred
? get_metadata( $type, $entity_id, $field['key'], true )
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
}
}
return $result;
@@ -980,7 +997,8 @@ final class TMDO_Hook_Bus {
$field_def = self::field_for( $type, $entity_id, $key );
if ( ! $field_def ) {
return null;
// A deferred group's value is the native one.
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
}
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
+1 -1
View File
@@ -159,7 +159,7 @@ final class TMDO_Mode_Manager {
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
TMDO_Logger::notice(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
@@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync {
public const OPTION = 'tmdo_post_status_columns';
private const INDEX = 'idx_post_status_date';
private const INDEX = 'idx_post_status_date_id';
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
private const LEGACY_INDEX = 'idx_post_status_date';
/**
* Hook the sync into WordPress.
@@ -65,6 +68,10 @@ final class TMDO_Post_Status_Sync {
unset( $new_status, $old_status );
foreach ( self::groups_with_columns() as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
self::forget( $group ); // The table was dropped (retired or uninstalled group).
continue;
}
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
@@ -199,7 +206,12 @@ final class TMDO_Post_Status_Sync {
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
}
if ( ! in_array( self::INDEX, $indexes, true ) ) {
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
// Ends with post_id: the router reads the date route's ID tie-break
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
}
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
}
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
return false;
@@ -220,6 +232,20 @@ final class TMDO_Post_Status_Sync {
return is_array( $states ) ? array_keys( $states ) : array();
}
/**
* Drop a group from the recorded states.
*
* @param string $group Group name.
* @return void
*/
private static function forget( string $group ): void {
$states = get_option( self::OPTION, array() );
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
unset( $states[ $group ] );
update_option( self::OPTION, $states, true );
}
}
/**
* Record a group's state.
*
+323 -13
View File
@@ -23,6 +23,12 @@ final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array();
@@ -77,13 +83,13 @@ final class TMDO_Schema_Manager {
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
if ( $stored_hash === $schema_hash ) {
// Upgraded meanwhile, or the definition went back to the stored one.
self::end_deferral( $entity_type, $group_name );
return true;
}
@@ -92,7 +98,55 @@ final class TMDO_Schema_Manager {
return false;
}
$table = self::get_table_name( $entity_type, $group_name );
$table = self::get_table_name( $entity_type, $group_name );
if ( self::upgrade_waits( $entity_type, $table ) ) {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
if ( ! self::table_exists( $table ) ) {
if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) {
return false;
}
// A new table starts empty while native meta may already hold the
// group's values: native until the drift fix has filled it in.
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) {
self::record_deferred( "{$entity_type}/{$group_name}" );
self::end_deferral( $entity_type, $group_name );
}
return true;
}
// An existing table is altered by one process at a time (anonymous
// /wp-cron.php requests also pass ddl_allowed()); a process that waited
// finds the upgrade done.
if ( ! self::lock() ) {
return false;
}
try {
if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) {
return true;
}
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
} finally {
self::unlock();
}
}
/**
* Create or alter the table (dbDelta) and record its definition.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @param array<int, array<string, mixed>> $field_definitions Normalized fields.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $table Table name.
* @param string $schema_hash Definition hash.
* @return bool
*/
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
global $wpdb;
$existed = self::table_exists( $table );
$charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column();
@@ -144,6 +198,11 @@ final class TMDO_Schema_Manager {
}
}
// A new table gets its declared composite indexes while it is empty.
foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) {
$sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')';
}
$columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes );
@@ -157,8 +216,27 @@ final class TMDO_Schema_Manager {
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
self::$table_exists_cache[ $table ] = true;
// dbDelta reports no failure: record the definition only when every column is there.
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );
$missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns );
if ( $missing ) {
unset( self::$table_exists_cache[ $table ] );
TMDO_Logger::warning(
'schema.upgrade_failed',
array(
'table' => $table,
'missing' => array_values( $missing ),
'error' => $wpdb->last_error,
)
);
return false;
}
// 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
if ( $existed ) {
self::end_deferral( $entity_type, $group_name );
}
/**
* Action: 表建立/升級完成
@@ -176,6 +254,12 @@ final class TMDO_Schema_Manager {
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* On a large table the ALTER takes seconds, so it runs only from WP-CLI or
* WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one
* process at a time (MySQL GET_LOCK on index_lock_name()). Until every
* ALTER succeeds the group stays in pending_indexes() and the table works
* without the index.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
@@ -193,23 +277,249 @@ final class TMDO_Schema_Manager {
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
if ( ! self::ddl_allowed() ) {
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK );
}
return;
}
$table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) {
return;
}
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
if ( ! self::lock() ) {
return; // Another process is adding indexes.
}
try {
// SHOW INDEX (not the option, cached for this request) tells what another process already added.
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
$failed = false;
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) {
$failed = true;
TMDO_Logger::warning(
'schema.index_failed',
array(
'table' => $table,
'index' => $index,
'error' => $wpdb->last_error,
)
);
}
}
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
if ( ! $failed ) {
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
}
} finally {
self::unlock();
}
}
/**
* INDEXES_HOOK handler: the table upgrades and indexes a web request left.
* The cron request's init usually did them already; not with
* ALTERNATE_WP_CRON, where the event runs inside a visitor's request.
*
* @return void
*/
public static function ensure_pending_indexes(): void {
foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) {
list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( $fields ) {
self::create_or_upgrade_table( $entity_type, $group_name, $fields );
}
}
foreach ( self::pending_indexes() as $id ) {
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
self::ensure_group_indexes( $entity_type, $group_name );
}
}
/**
* Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its
* keys then stay native (Hook Bus, query router).
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return bool
*/
public static function is_deferred( string $entity_type, string $group_name ): bool {
$deferred = get_option( self::DEFERRED_OPTION, array() );
return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] );
}
/**
* Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering
* a large table takes seconds to minutes, as for indexes. Until then the
* group reads and writes native meta (is_deferred()). Not in aeav_only,
* where the flat table is the only copy: that upgrade runs at once.
*
* @param string $entity_type Entity type.
* @param string $table Flat table name.
* @return bool
*/
public static function upgrade_waits( string $entity_type, string $table ): bool {
return self::table_exists( $table )
&& ! self::ddl_allowed()
&& TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type );
}
/**
* Record a deferred table upgrade and have WP-Cron run it.
*
* @param string $id "entity/group".
* @return void
*/
private static function defer_upgrade( string $id ): void {
self::record_deferred( $id );
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
}
}
/**
* Record a group as deferred (native until resynced()).
*
* @param string $id "entity/group".
* @return void
*/
private static function record_deferred( string $id ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
$deferred = is_array( $deferred ) ? $deferred : array();
if ( ! isset( $deferred[ $id ] ) ) {
$deferred[ $id ] = time();
update_option( self::DEFERRED_OPTION, $deferred, true );
}
}
/**
* A deferred upgrade is done. Writes made meanwhile skipped the flat table,
* so the group stays native until a drift fix (tmdo_drift_resync, a minute
* later) has rebuilt it from native and calls resynced(). With no flat
* writes (disabled), nothing is behind.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
private static function end_deferral( string $entity_type, string $group_name ): void {
if ( ! self::is_deferred( $entity_type, $group_name ) ) {
return;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) {
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
return;
}
if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) );
}
}
/**
* A drift fix of the entity succeeded: its deferred groups whose table is
* upgraded read and write the flat table again.
*
* @param string $entity_type Entity type.
* @return void
*/
public static function resynced( string $entity_type ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
if ( ! is_array( $deferred ) || ! $deferred ) {
return;
}
foreach ( array_keys( $deferred ) as $id ) {
list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
unset( $deferred[ $id ] );
}
}
update_option( self::DEFERRED_OPTION, $deferred, true );
}
/**
* Take the schema lock without waiting.
*
* add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
* processes both "acquire" an option lock; GET_LOCK is atomic and is
* released by MySQL if this process dies. Re-entrant per connection.
*
* @return bool False when another process holds it.
*/
private static function lock(): bool {
global $wpdb;
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
// where it locks nothing against the ALTER on the primary.
$wpdb->send_reads_to_masters();
}
return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) );
}
/**
* Release the schema lock.
*
* @return void
*/
private static function unlock(): void {
global $wpdb;
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) );
}
/**
* Key of the MySQL lock held while indexes are added. Lock names are
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
*
* @return string
*/
public static function index_lock_name(): string {
global $wpdb;
return $wpdb->prefix . self::INDEXES_OPTION;
}
/**
* Whether this request may alter flat tables: WP-CLI or WP-Cron only.
* Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests
* (init runs before auth_redirect()) can be a visitor's, who would wait
* for the ALTER. Filter: `tmdo_schema_ddl_allowed`.
*
* @return bool
*/
public static function ddl_allowed(): bool {
$allowed = ( defined( 'WP_CLI' ) && WP_CLI )
|| ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed );
}
/**
* Registered groups whose declared indexes are not on their table yet.
*
* @return string[] "entity/group"
*/
public static function pending_indexes(): array {
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
$pending = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) {
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) )
&& self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) {
$pending[] = "{$entity_type}/{$group_name}";
}
}
}
return $pending;
}
/**
+148 -25
View File
@@ -26,13 +26,17 @@
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies and orders by the copied
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
* LIMIT. Not with NOT EXISTS: posts without a flat row must still match.
* post_date (ID tie-breaks read as the flat post_id), so MySQL can walk
* `idx_post_status_date_id` and stop at the LIMIT. Not with NOT EXISTS:
* posts without a flat row must still match.
*
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
* match inside the page query); found_posts_query answers from a COUNT of the
* same query, kept in the object cache until posts' last_changed moves (core
* post saves, and flat writes via TMDO_Hook_Bus).
* same query, kept in the object cache until a post is saved or deleted, a
* flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms'
* last_changed moves. Not posts' last_changed: WordPress moves it on any post
* meta write, which themes do on page views (Blocksy's dynamic CSS), and a
* routed search reads no unmanaged meta.
*
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'.
@@ -69,6 +73,9 @@ class TMDO_Post_Query_Router {
/** Object cache group for found-rows counts. */
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
/** The last_changed group the cached counts follow (bump_count_version()). */
public const COUNT_VERSION_GROUP = 'tmdo_posts';
/** Seconds a "every post of the type has a flat row" answer is reused. */
private const COVERAGE_TTL = 900;
@@ -185,6 +192,25 @@ class TMDO_Post_Query_Router {
$routed[ $pt ][] = $route;
$sort = self::sort_for( $pt, $route, $direction );
}
// meta_key alone (no meta_value, not a meta_value sort): WP_Query adds a
// clause that tests the key exists — HivePress's featured-listing lookup.
$exists_key = ! $take_key && is_string( $meta_key ) && '' !== $meta_key
&& '' === (string) $query->get( 'meta_value' ) && '' === (string) $query->get( 'meta_compare' )
&& ! in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $exists_key ) {
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
),
self::field_for( array( 'key' => $meta_key ), $pt )
);
if ( ! $route ) {
return;
}
$routed[ $pt ][] = $route;
}
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) ) {
@@ -193,7 +219,10 @@ class TMDO_Post_Query_Router {
}
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
if ( '' !== $name && ! in_array( $name, $named, true ) && self::field_for( $clause[ $name ], $pt ) ) {
continue; // Matches every post and orders nothing here (a copy of a search's meta query).
}
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
if ( $field ) {
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
@@ -233,7 +262,7 @@ class TMDO_Post_Query_Router {
if ( empty( $routed ) && empty( $sort ) ) {
return;
}
if ( $take_key ) {
if ( $take_key || $exists_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
@@ -372,7 +401,23 @@ class TMDO_Post_Query_Router {
if ( ! $counted ) {
return $request;
}
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
global $wpdb;
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
// post__not_in (HivePress sends a random set per page) would give every
// search its own cache key: count without it (cached) minus the excluded
// posts that match (primary-key lookups, so no forced flat-first order).
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
$query->set(
self::COUNT_VAR,
array(
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
)
);
return $request;
}
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request;
}
@@ -414,13 +459,21 @@ class TMDO_Post_Query_Router {
if ( empty( $count_sql ) ) {
return (string) ( $sql ?? '' );
}
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
global $wpdb;
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
// Keyword searches carry wpdb's placeholder escape for "%", which is new
// in every request: without it the key would never match again.
$stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql;
$key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
if ( false === $total ) {
global $wpdb;
$total = (int) $wpdb->get_var( $count_sql );
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
}
if ( '' !== $excluded_sql ) {
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
}
return 'SELECT ' . (int) $total;
}
@@ -428,6 +481,17 @@ class TMDO_Post_Query_Router {
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* A post was saved or deleted, or a managed key written: cached routed
* counts are stale. Hooked on clean_post_cache whatever the mode, so a
* count cached before the router was last active is not reused.
*
* @return void
*/
public static function bump_count_version(): void {
wp_cache_set_last_changed( self::COUNT_VERSION_GROUP );
}
/**
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
*
@@ -613,6 +677,9 @@ class TMDO_Post_Query_Router {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
return false;
}
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
@@ -634,11 +701,17 @@ class TMDO_Post_Query_Router {
*/
private static function route_clause( array $clause, array $field ): ?array {
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
$type = '' === $type ? 'CHAR' : $type;
$value = $clause['value'] ?? '';
$kind = $field['type'] ?? 'text';
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
// Without 'value', WP_Meta_Query checks only that the key exists (it adds a
// value condition only when one is set): HivePress's featured-listing
// lookup, run on every search page, is array( 'key' => 'hp_featured' ).
if ( ! array_key_exists( 'value', $clause ) && ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$compare = 'EXISTS';
}
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
$type = '' === $type ? 'CHAR' : $type;
$value = $clause['value'] ?? '';
$kind = $field['type'] ?? 'text';
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
return null;
@@ -677,7 +750,56 @@ class TMDO_Post_Query_Router {
* @return string
*/
private static function routed_request( string $request, \WP_Query $query ): string {
return self::flat_first( self::without_found_rows( $request ), $query );
return self::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query );
}
/**
* The copy route's request without WordPress's GROUP BY (when the flat row
* is the only join) and with posts.ID tie-breaks read as the flat post_id,
* each in its own direction, once. MariaDB then walks the
* (post_status, post_date, post_id) index and stops at the LIMIT instead
* of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms).
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function copy_ordered( string $request, \WP_Query $query ): string {
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $request;
}
global $wpdb;
$tie = '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_id` ';
return (string) preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $wpdb, $tie ) {
$order = (string) preg_replace_callback(
'/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(?:\s+(ASC|DESC)\b)?/i',
static fn( $id ) => $tie . ( 'DESC' === strtoupper( $id[1] ?? '' ) ? 'DESC' : 'ASC' ),
$m[0]
);
return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order );
},
self::without_group_by( $request )
);
}
/**
* WordPress groups by posts.ID for any tax or meta query. With a flat row
* (one per post) as the only join there is nothing to collapse, and the
* GROUP BY makes MySQL sort the whole match in a temporary table
* (WooCommerce's product_visibility NOT IN on searches is a subquery).
*
* @param string $request SELECT statement.
* @return string
*/
private static function without_group_by( string $request ): string {
global $wpdb;
if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) {
return $request;
}
return (string) preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 );
}
/**
@@ -746,17 +868,18 @@ class TMDO_Post_Query_Router {
if ( 1 !== $replaced ) {
return $request;
}
// WordPress groups by posts.ID for any tax or meta query. With the flat row
// (one per post) as the only join there is nothing to collapse, and the
// GROUP BY makes MySQL sort the whole match in a temporary table
// (WooCommerce's product_visibility NOT IN on searches is a subquery).
if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) {
$flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 );
}
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
$flat_first = self::without_group_by( (string) $flat_first );
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat
// post_id, once, in the sort's direction: a (column, post_id) index then
// returns rows in order with no filesort. Ties are ordered by ID the same
// way as the column (natively ID DESC); pagination stays stable.
$tie = "`{$alias}`.`post_id` " . ( 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC' );
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
static function ( $m ) use ( $wpdb, $tie ) {
$order = (string) preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(\s+(ASC|DESC)\b)?/i', $tie, $m[0] );
return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . ')(\s*,\s*' . preg_quote( $tie, '/' ) . ')+/', '$1', $order );
},
(string) $flat_first
);
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
}
$table = $wpdb->prefix . self::TABLE_SLUG;
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
TMDO_Logger::notice( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
return false !== $deleted;
}
+23 -5
View File
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class TMDO_Zone_Cold {
/** Option: post type → cold cache version (TMDO_Cache_Layer::flush_group()). */
public const CACHE_VERSION_OPTION = 'wpdo_cold_cache_version';
/**
* Get the cold table name for a post type.
*
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
return 'wpdo_cold_' . sanitize_key( $post_type );
}
/**
* Object-cache key of a post's cold blob. It carries the post type's cache
* version once TMDO_Cache_Layer::flush_group() has bumped it (none before,
* so existing entries keep their keys).
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return string
*/
public static function cache_key( int $post_id, string $post_type ): string {
$versions = get_option( self::CACHE_VERSION_OPTION, array() );
$version = is_array( $versions ) ? (int) ( $versions[ $post_type ] ?? 0 ) : 0;
return $version ? "cold_{$post_id}_v{$version}" : "cold_{$post_id}";
}
/**
* Ensure the cold table exists for a post type.
*
@@ -92,7 +110,7 @@ class TMDO_Zone_Cold {
*/
public static function get_blob( int $post_id, string $post_type ): array {
$group = self::cache_group( $post_type );
$cache_key = "cold_{$post_id}";
$cache_key = self::cache_key( $post_id, $post_type );
$cached = wp_cache_get( $cache_key, $group );
if ( false !== $cached ) {
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
/**
@@ -215,7 +233,7 @@ class TMDO_Zone_Cold {
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
// ── Private helpers ───────────────────────────────────────────────────
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
);
// Invalidate object cache.
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
/**
@@ -305,6 +323,6 @@ class TMDO_Zone_Cold {
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
}
+20 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.2.0
Stable tag: 1.4.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,25 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
= 1.4.0 =
* Flat-first sorts read the ID tie-break from the flat post_id, so a declared (column, post_id) index returns rows in order; ties now follow the sort direction
* Meta clauses without a value route as EXISTS; HivePress's featured-listing lookup on every search page uses the flat table
* Date-ordered routed searches drop the redundant GROUP BY and use a (post_status, post_date, post_id) index (replace the index on the tables listed in `tmdo_post_status_columns`; `wp tmdo post-status-sync` also adds the copy columns to every other post group)
* Rows rebuilt by the migration engine keep their status/date copies and invalidate cached routed counts
* Live test tools no longer flush the object cache
= 1.3.0 =
* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet
* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native)
* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
* The public listings REST output withholds email, phone, address and IP fields even when declared public
* Admin form posts are handled before the page renders; uninstall.php globals are prefixed
* Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it
* The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group
* Groups whose flat table was dropped no longer cause database errors on post status changes
* Schema upgrades share one MySQL lock with index DDL; new or upgraded groups stay native until a drift fix has filled them; mode changes and snapshot deletions are always logged
= 1.2.0 =
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
@@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase {
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array();
// Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one.
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$GLOBALS['_wp_scheduled_single'] = array();
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
@@ -53,6 +57,120 @@ class GroupCompositeIndexTest extends TestCase {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] );
}
public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect().
$GLOBALS['_wp_is_admin'] = $is_admin;
$this->assertFalse( TMDO_Schema_Manager::ddl_allowed() );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' );
}
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run.
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
}
public function test_the_ddl_decision_can_be_overridden(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => false );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() );
}
public function test_a_failed_alter_is_not_recorded_as_applied(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group(
'post',
'ci_core',
array(
array( 'key' => 'ci_flag', 'type' => 'integer' ),
array( 'key' => 'ci_note', 'type' => 'textarea' ),
),
array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170.
);
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' );
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
}
public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void {
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' );
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
global $wpdb;
$this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' );
}
/**
* Under read/write splitting (HyperDB, LudicrousDB) a SELECT may go to a
* replica, where GET_LOCK would lock nothing: reads go to the primary first.
*/
public function test_the_lock_is_taken_on_the_primary_under_read_write_splitting(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
/** @var string[] */
public array $calls = array();
public function __construct( private object $db ) {}
public function send_reads_to_masters(): void {
$this->calls[] = 'send_reads_to_masters';
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'GET_LOCK' ) ) {
$this->calls[] = 'GET_LOCK';
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$calls = $wpdb->calls;
} finally {
$wpdb = $real;
}
$this->assertSame( array( 'send_reads_to_masters', 'GET_LOCK' ), $calls );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
}
/** @return array<string, string[]> index name → ordered columns */
@@ -227,6 +227,30 @@ class HookBusIntegrationTest extends TestCase {
);
}
/**
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
* stays native: its table may lack the new columns.
*/
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
try {
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
$this->assertNull( $this->read_flat_first_name( 4 ) );
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
}
);
} finally {
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
}
}
/**
* delete_all passes object ID 0: the delete_ action records the touched
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
+50 -1
View File
@@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase {
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_id (post_status, post_date, post_id) ) ENGINE=InnoDB" );
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
@@ -58,6 +58,55 @@ class PostQueryRouterCopyTest extends TestCase {
$mode->setValue( null, null );
}
/**
* The full request WordPress would build for the routed search, after posts_request.
*
* @param string $extra_join Another JOIN (a tax query's).
* @param string $ties ID tie-breaks plugins appended.
*/
private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string {
global $wpdb;
$ties = str_replace( '{posts}', $wpdb->posts, $ties );
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query )
. ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query )
. " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10';
return $router->posts_request( $request, $query );
}
/**
* 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every
* match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks
* (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms).
*/
public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void {
$sql = $this->request();
$this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
public function test_copy_route_keeps_each_tie_break_direction(): void {
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) );
}
public function test_copy_route_keeps_group_by_with_another_join(): void {
global $wpdb;
$sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" );
$this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql );
}
/** @return array{join:string, where:string, orderby:string} */
private function sql( array $vars ): array {
$query = new WP_Query();
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
return array( $router, $query, $request );
}
public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void {
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) );
list( , $query ) = $this->routed();
$this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' );
}
public function test_routed_request_drops_sql_calc_found_rows(): void {
list( $router, $query, $request ) = $this->routed();
@@ -117,13 +128,85 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
wp_cache_set_posts_last_changed();
wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view.
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' );
TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written.
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
}
/**
* HivePress excludes a different random set of posts on each search page;
* the total is the cached count without the exclusion minus a live count
* of the excluded posts that match (by primary key).
*/
public function test_excluded_posts_do_not_split_the_cached_total(): void {
global $wpdb;
$count = function ( string $not_in ) use ( $wpdb ): string {
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request );
$router->posts_request( $request, $query );
return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) );
};
$this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' );
$this->assertSame( '0', $count( '2,3' ) );
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change.
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
$this->assertSame( '1', $count( '2' ) );
TMDO_Post_Query_Router::bump_count_version();
$this->assertSame( '0', $count( '2' ), 'recounted' );
}
/**
* Keyword searches carry wpdb's placeholder escape for "%", which changes
* with every request: the cache key uses the SQL without it, or no search
* with a keyword ever hits the cached count.
*/
public function test_the_cached_count_survives_a_new_placeholder_escape(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
public int $counts = 0;
public function __construct( private object $db ) {}
public function remove_placeholder_escape( string $sql ): string {
return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql );
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) {
++$this->counts;
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests.
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request );
$router->posts_request( $request, $query );
$this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
}
$counts = $wpdb->counts;
} finally {
$wpdb = $real;
}
$this->assertSame( 1, $counts, 'counted once' );
}
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
$before = wp_cache_get_last_changed( 'posts' );
$version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP );
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
$after_write = wp_cache_get_last_changed( 'posts' );
@@ -132,6 +215,7 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertNotSame( $before, $after_write );
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
$this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' );
}
// ── flat-first: a filtered search ordered by a flat column ─────────────
@@ -142,14 +226,14 @@ class PostQueryRouterFoundRowsTest extends TestCase {
*
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
*/
private function sorted( string $compare = '>=' ): array {
private function sorted( string $compare = '>=', string $order = 'DESC' ): array {
global $wpdb;
$query = new WP_Query();
$query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) );
$query->set( 'orderby', 'price' );
$query->set( 'order', 'DESC' );
$query->set( 'order', $order );
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
@@ -168,11 +252,26 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
$this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql );
$this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' );
$this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) );
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) );
}
/**
* The ID tie-break follows the sort direction, so a (column, post_id)
* index returns rows in order with no filesort (2.4 PR-118); ties are
* ordered by ID the same way as the column.
*/
public function test_flat_first_tie_break_follows_the_sort_direction(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' );
$sql = $router->posts_request( $request, $query );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql );
$this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) );
}
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
list( $router, $query, $request ) = $this->routed();
+67
View File
@@ -412,4 +412,71 @@ class PostQueryRouterTest extends TestCase {
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
}
/**
* A clause with no value tests only that the key exists (WP_Meta_Query adds
* a value condition only when 'value' is set), so it routes as EXISTS.
* HivePress's featured-listing lookup on every search page is one:
* array( 'key' => 'hp_featured' ) — it kept the whole query native (2.4 PR-122).
*/
public function test_a_clause_without_a_value_routes_as_exists(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_stock' ),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertEmpty( $query->get( 'meta_query' ), 'both clauses routed' );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $router->posts_where( '', $query ) );
}
/**
* HivePress's featured-listing lookup (Attribute::set_search_query()): the
* search's meta query, including its sort group, plus meta_key hp_featured,
* ordered at random. The sort group (key NOT EXISTS OR key EXISTS) matches
* every post, so unused for ordering it is dropped; meta_key without a
* value or a meta_value sort tests that the key exists (2.4 PR-122).
*/
public function test_hivepress_featured_lookup_is_routed(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_key' => '_stock',
'orderby' => 'rand',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertSame( '', $query->get( 'meta_key' ), 'meta_key routed' );
$this->assertEmpty( array_filter( (array) $query->get( 'meta_query' ), 'is_array' ), 'sort group dropped, filter routed' );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $where );
$this->assertStringContainsString( 'BETWEEN', $where );
}
public function test_meta_key_with_a_value_is_left_alone(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_value' => '5' ) );
$router->pre_get_posts( $query );
$this->assertSame( '_stock', $query->get( 'meta_key' ) );
}
}
+105 -2
View File
@@ -25,6 +25,9 @@ class PostStatusSyncTest extends TestCase {
'includes/adapters/class-tmdo-adapter-post.php',
'includes/engine/class-tmdo-hook-bus.php',
'includes/engine/class-tmdo-post-status-sync.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/query/class-tmdo-post-query-router.php',
) as $file ) {
require_once $base . $file;
}
@@ -81,9 +84,34 @@ class PostStatusSyncTest extends TestCase {
return $out;
}
private function has_index( string $table ): bool {
/** @return array<string, string[]> index name → columns */
private function indexes( string $table ): array {
global $wpdb;
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
$out = array();
foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) {
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
}
return $out;
}
private function has_index( string $table ): bool {
return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null );
}
/**
* 2.4 PR-128: the date route's ID tie-break is read from post_id, which the
* index must end with; the 2.2 two-column index is replaced, not kept beside it.
*/
public function test_run_replaces_the_two_column_index_of_existing_tables(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' );
$GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' );
TMDO_Post_Status_Sync::run();
$this->assertTrue( $this->has_index( self::A ) );
$this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
}
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
@@ -134,6 +162,23 @@ class PostStatusSyncTest extends TestCase {
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
}
public function test_a_group_whose_table_was_dropped_is_forgotten(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$wpdb->query( 'DROP TABLE `' . self::B . '`' );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$wpdb->last_error = '';
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
$this->assertSame( '', (string) $wpdb->last_error, 'no UPDATE of a missing table' );
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
}
public function test_transition_leaves_tables_without_the_columns_alone(): void {
global $wpdb;
$wpdb->last_error = '';
@@ -156,6 +201,64 @@ class PostStatusSyncTest extends TestCase {
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
}
/**
* The migration engine (backfill, drift-check --fix) REPLACEs whole rows:
* without the copies, a rebuilt row had NULL post_status, which turns the
* status/date copy route off for the whole group (2.4 PR-127).
*/
public function test_a_row_rebuilt_from_native_keeps_its_copies(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" );
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row.
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row.
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] );
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
}
/**
* Backfills and drift-check --fix rebuild rows outside the Hook Bus: the
* routed counts cached for the old rows must go (2.4 PR-129; dev36: a
* vendor search kept counting 21515 of 21522 matches after a fix).
*/
public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void {
global $wpdb;
$status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$status}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null;
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) );
$this->assertSame( 'before', $version(), 'a dry run writes nothing' );
foreach ( array(
'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ),
'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ),
'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ),
) as $path => $run ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
$run();
$this->assertNotSame( 'before', $version(), $path );
}
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$status}`" );
}
}
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
TMDO_Post_Status_Sync::run();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
/**
* An existing table is altered by one process at a time: anonymous
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
*/
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
try {
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
} finally {
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
}
/**
* After the upgrade the group stays native until the drift fix has
* rebuilt the rows written meanwhile (security review M1).
*/
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
update_option(
TMDO_Schema_Manager::DEFERRED_OPTION,
array(
'user/sd_core' => 1,
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
'post/sd_core' => 1, // Another entity.
)
);
TMDO_Schema_Manager::resynced( 'user' );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
TMDO_Schema_Manager::resynced( 'user' );
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
}
/**
* The definition went back to the stored one (or another process upgraded
* the table) while the group was deferred (correctness review F2).
*/
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
$this->assertTrue( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
self::set_user_mode( 'disabled' );
$this->upgrade();
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
}
/**
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
* it runs inside a visitor's request, after init (correctness review F3).
*/
public function test_the_cron_event_handles_deferred_groups(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
}
}
+27 -3
View File
@@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) {
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data )
) );
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
try {
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error;
return false;
@@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) {
public function flush(): void {}
public function query( string $sql ): int|bool {
$result = $this->db->query( $sql );
try {
$result = $this->db->query( $sql );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; }
return $this->db->affected_rows;
@@ -289,9 +299,22 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'get_metadata' ) ) {
// Like get_metadata(): the first stored value of a key (single), read from the real meta table.
function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) {
global $wpdb;
$table = $wpdb->{$type . 'meta'};
$id = 'user' === $type ? 'umeta_id' : 'meta_id';
$value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) );
if ( $single ) {
return null === $value ? '' : maybe_unserialize( $value );
}
return null === $value ? array() : array( maybe_unserialize( $value ) );
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
@@ -411,6 +434,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
$GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
if ( ! function_exists( 'wp_cache_set_last_changed' ) ) { function wp_cache_set_last_changed( $group ) { $GLOBALS['_wp_last_changed'][ $group ] = (string) hrtime( true ); return $GLOBALS['_wp_last_changed'][ $group ]; } }
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
+5 -4
View File
@@ -7,7 +7,8 @@ $mode = TMDO_Mode_Manager::get( 'post' ) . '/' . TMDO_Mode_Manager::get( 'user'
function nat( $t, $id, $k ) { global $wpdb; $mt = $t === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $mt WHERE $c=%d AND meta_key=%s", $id, $k ) ); return $v ? implode( '|', $v ) : '∅'; }
function flat( $t, $g, $id, $k ) { global $wpdb; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_row( $wpdb->prepare( "SELECT `$k` v FROM {$wpdb->prefix}wpdo_{$t}_{$g} WHERE $c=%d", $id ) ); return $v ? ( $v->v ?? 'NULL' ) : 'NO-ROW'; }
function row( $name, $vals ) { printf( "%-44s %s\n", $name, implode( ' ', array_map( fn( $k, $v ) => "$k=" . ( is_scalar( $v ) || is_null( $v ) ? var_export( $v, true ) : json_encode( $v ) ), array_keys( $vals ), $vals ) ) ); }
function clear_caches( $id ) { wp_cache_flush(); clean_post_cache( $id ); }
// Never wp_cache_flush(): with Redis Object Cache selective flush it is one server-side scan that blocks Redis for every site.
function clear_caches( $id ) { clean_post_cache( $id ); wp_cache_flush_runtime(); }
echo "== mode post/user = $mode\n";
// T1: HivePress model update of a listing price.
@@ -42,12 +43,12 @@ row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'h
// T6: user first_name via wp_update_user.
$uid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE 's36v_B1p1_%' ORDER BY ID LIMIT 1" );
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); wp_cache_flush();
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); clean_user_cache( $uid ); wp_cache_flush_runtime();
$u = get_userdata( $uid );
row( "T6 user $uid first_name:=$fn", array( 'native' => nat( 'user', $uid, 'first_name' ), 'flat' => flat( 'user', 'core_profile', $uid, 'first_name' ), 'get_meta' => get_user_meta( $uid, 'first_name', true ), 'WP_User' => $u->first_name, 'wc_customer' => ( new WC_Customer( $uid ) )->get_first_name() ) );
// T7: WC customer billing city via CRUD.
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); wp_cache_flush();
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); clean_user_cache( $uid ); wp_cache_flush_runtime();
row( "T7 WC_Customer billing_city:=$city", array( 'native' => nat( 'user', $uid, 'billing_city' ), 'flat' => flat( 'user', 'commerce', $uid, 'billing_city' ), 'get_meta' => get_user_meta( $uid, 'billing_city', true ), 'wc_customer' => ( new WC_Customer( $uid ) )->get_billing_city() ) );
// T8: delete a listing → flat row?
@@ -57,5 +58,5 @@ row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COU
// T9: HP review → listing rating recompute.
$before = get_post_meta( $lid, 'hp_rating_count', true );
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); wp_cache_flush();
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); clean_post_cache( $lid ); wp_cache_flush_runtime();
row( "T9 add review (count was $before)", array( 'native' => nat( 'post', $lid, 'hp_rating_count' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_rating_count' ), 'hp_model' => Models\Listing::query()->get_by_id( $lid )->get_rating_count() ) );
+6 -1
View File
@@ -40,7 +40,12 @@ foreach ( $cases as $name => $qa ) {
$err = '';
$sql = '';
for ( $i = 0; $i < $iters; $i++ ) {
wp_cache_flush_group( 'tmdo_found_rows' );
// Fresh routed counts without wp_cache_flush_group() (a Redis-blocking server-side scan).
if ( method_exists( 'TMDO_Post_Query_Router', 'bump_count_version' ) ) {
TMDO_Post_Query_Router::bump_count_version();
} else {
wp_cache_set_last_changed( 'posts' ); // Core < 1.3.0 keyed the counts on posts' last_changed.
}
$wpdb->last_error = '';
$t = microtime( true );
$q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) );
+19
View File
@@ -197,6 +197,25 @@ class CacheLayerTest extends TestCase {
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
}
/**
* Flushing bumps the post type's cache version instead of asking the
* object cache to scan the group (a server-side script on Redis that
* blocks it on a large group); older entries expire by their TTL.
*/
public function test_flush_group_moves_to_new_keys_without_a_group_flush(): void {
$group = 'wpdo_cold_hp_listing';
$old_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
$GLOBALS['_wp_cache'][ $group ][ $old_key ] = array( 'stale' => true );
WPDO_Cache_Layer::flush_group( 'hp_listing' );
$new_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
$this->assertNotSame( $old_key, $new_key );
$this->assertFalse( wp_cache_get( $new_key, $group ) );
$this->assertSame( 'cold_2', WPDO_Zone_Cold::cache_key( 2, 'other_type' ), 'other post types keep their keys' );
$this->assertArrayHasKey( $old_key, $GLOBALS['_wp_cache'][ $group ], 'nothing scanned or deleted' );
}
// ── get_stats() ──────────────────────────────────────────────────────────
public function test_get_stats_includes_required_keys(): void {
+13 -1
View File
@@ -14,7 +14,9 @@ class EntityRegistryIndexesTest extends TestCase {
protected function setUp(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
$adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class );
$adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' );
TMDO_Entity_Registry::register_adapter( 'post', $adapter );
}
private function fields(): array {
@@ -37,6 +39,16 @@ class EntityRegistryIndexesTest extends TestCase {
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
}
/**
* A sort index ends with the entity ID, so a flat-first search ordered by a
* column with the ID tie-break reads the index in order (2.4 PR-118).
*/
public function test_an_index_may_end_with_the_entity_id_column(): void {
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'price_id' => array( 'x_price', 'post_id' ), 'id_first' => array( 'post_id', 'x_price' ) ) ) );
$this->assertSame( array( 'price_id' => array( 'x_price', 'post_id' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ), 'the ID only as the last column' );
}
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
+37
View File
@@ -130,4 +130,41 @@ class LoggerTest extends TestCase {
$deleted = WPDO_Logger::purge( 30 );
$this->assertSame( 5, $deleted );
}
/** error_log() output of $fn. */
private function logged( callable $fn ): string {
$file = tempnam( sys_get_temp_dir(), 'tmdo-log' );
$old = ini_set( 'error_log', $file );
try {
$fn();
} finally {
ini_set( 'error_log', (string) $old );
}
$out = (string) file_get_contents( $file );
unlink( $file );
return $out;
}
public function test_info_stays_out_of_the_log_unless_debug_logging_is_on(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
$this->assertSame( '', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
add_filter( 'tmdo_log_info', static fn() => true );
try {
$this->assertStringContainsString( '[WPDO][INFO][cache_flushed]', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
} finally {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
}
}
public function test_notices_are_always_logged_for_the_audit_trail(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
$this->assertStringContainsString( '[WPDO][NOTICE][bridge_mode_changed]', $this->logged( static fn() => WPDO_Logger::notice( 'bridge_mode_changed', array( 'to' => 'dual_write' ) ) ) );
}
public function test_warnings_are_always_logged(): void {
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
}
}
+30 -4
View File
@@ -164,6 +164,21 @@ class RestApiTest extends TestCase {
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
}
public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void {
$private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' );
$public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' );
$fields = array();
$values = array();
foreach ( array_merge( $private, $public ) as $key ) {
$fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true );
$values[ $key ] = 'v';
}
$data = $this->listing_with( $fields, $values );
$this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) );
}
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
@@ -377,7 +392,11 @@ class RestApiTest extends TestCase {
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
}
public function test_post_view_429_increments_rate_limit_stats(): void {
/**
* Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous
* traffic cannot turn every blocked request into an option write.
*/
public function test_post_view_429_samples_rate_limit_stats(): void {
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
$nonce = wp_create_nonce( 'wp_rest' );
@@ -385,10 +404,17 @@ class RestApiTest extends TestCase {
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
$req->set_param( 'id', 40 );
$req->set_header( 'X-WP-Nonce', $nonce );
$this->api->post_view( $req );
$writes = 0;
for ( $i = 0; $i < 200; $i++ ) {
$before = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 429, $this->api->post_view( $req )->get_status() );
$writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1;
}
$stats = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
$count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 );
$this->assertSame( 10 * $writes, $count, 'each sample adds 10' );
$this->assertGreaterThan( 0, $writes );
$this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' );
}
// ── get_status: rate_limit_stats ─────────────────────────────────────────
+15 -15
View File
@@ -30,7 +30,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
global $wpdb;
// ── Load shared cleanup logic + snapshot classes ─────────────────────────
$bootstrap_files = array(
$wpdo_bootstrap_files = array(
__DIR__ . '/includes/class-tmdo-logger.php',
__DIR__ . '/includes/class-tmdo-feature-flags.php',
__DIR__ . '/includes/class-tmdo-installer.php',
@@ -39,13 +39,13 @@ $bootstrap_files = array(
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php',
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php',
);
$bootstrap_ok = true;
foreach ( $bootstrap_files as $f ) {
if ( ! is_file( $f ) ) {
$bootstrap_ok = false;
$wpdo_bootstrap_ok = true;
foreach ( $wpdo_bootstrap_files as $wpdo_bootstrap_file ) {
if ( ! is_file( $wpdo_bootstrap_file ) ) {
$wpdo_bootstrap_ok = false;
continue;
}
require_once $f;
require_once $wpdo_bootstrap_file;
}
// ── Sites to check and clean (0 = single site, no blog switching) ────────
@@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id
};
// ── Refuse while any site holds data that exists only in wpdo_* tables ───
if ( ! $bootstrap_ok ) {
if ( ! $wpdo_bootstrap_ok ) {
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
}
@@ -103,7 +103,7 @@ if ( $wpdo_blocked ) {
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
// site-level table DROP that happens later). Best-effort.
if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create(
$wpdo_snapshot = TMDO_Snapshot_Manager::create(
'pre_uninstall',
array(),
array(
@@ -112,18 +112,18 @@ if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
'inline_threshold_bytes' => 0,
)
);
if ( ! empty( $result['ok'] ) ) {
$snapshot_id = $result['snapshot_id'];
$dir = TMDO_Snapshot_Manager::backup_dir();
if ( is_dir( $dir ) ) {
if ( ! empty( $wpdo_snapshot['ok'] ) ) {
$wpdo_snapshot_id = $wpdo_snapshot['snapshot_id'];
$wpdo_backup_dir = TMDO_Snapshot_Manager::backup_dir();
if ( is_dir( $wpdo_backup_dir ) ) {
file_put_contents(
trailingslashit( $dir ) . 'README-pre-uninstall.txt',
"WPDO pre-uninstall snapshot: {$snapshot_id}\n"
trailingslashit( $wpdo_backup_dir ) . 'README-pre-uninstall.txt',
"WPDO pre-uninstall snapshot: {$wpdo_snapshot_id}\n"
. 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n"
. "To restore after re-installing wp-data-optimizer:\n"
. " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n"
. " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n"
. " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n"
. " 3. Run: wp wpdo snapshot restore {$wpdo_snapshot_id} --apply\n"
);
}
}