Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca2cbdab65 | |||
| 7851f150fa | |||
| 1835c8776a | |||
| 1d70d13f45 | |||
| e26aadc4ec | |||
| 29adb05567 | |||
| 0aec4b9bb6 | |||
| eb1c8b78f4 | |||
| 06a3594417 | |||
| e18030b206 | |||
| 5968bcd95d |
+22
-2
@@ -25,16 +25,34 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091)
|
精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091)
|
||||||
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
|
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
|
||||||
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
|
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
|
||||||
- `TMDO_Logger::info()` / `notice()` 只在 `WP_DEBUG_LOG` 時寫 error_log
|
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
|
||||||
(filter `tmdo_log_info`);`debug()`、`warning()`、`error()` 不變。(PR-095)
|
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
|
||||||
|
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
|
||||||
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
|
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
|
||||||
`show_in_rest`。(PR-096)
|
`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
|
### Fixed
|
||||||
|
|
||||||
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
|
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
|
||||||
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
|
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
|
||||||
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
|
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
|
||||||
|
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
|
||||||
|
不存在的表並印出 DB error。(PR-106)
|
||||||
|
|
||||||
### Security(已驗證,無程式變更)
|
### Security(已驗證,無程式變更)
|
||||||
|
|
||||||
@@ -48,6 +66,8 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
|
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
|
||||||
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
|
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
|
||||||
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
|
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
|
||||||
|
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
|
||||||
|
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ class TMDO_CLI {
|
|||||||
foreach ( $violations as $entity_type => $integrations ) {
|
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`." );
|
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();
|
$pending = TMDO_Schema_Manager::pending_indexes();
|
||||||
if ( $pending ) {
|
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).' );
|
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).' );
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
|
|||||||
// Check which IDs are already cached.
|
// Check which IDs are already cached.
|
||||||
$uncached = array();
|
$uncached = array();
|
||||||
foreach ( $post_ids as $pid ) {
|
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 ) {
|
if ( false === $cached ) {
|
||||||
$uncached[] = (int) $pid;
|
$uncached[] = (int) $pid;
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
|
|||||||
// Cache all results (including empty arrays for posts with no cold data).
|
// Cache all results (including empty arrays for posts with no cold data).
|
||||||
foreach ( $uncached as $pid ) {
|
foreach ( $uncached as $pid ) {
|
||||||
$data = $fetched[ $pid ] ?? array();
|
$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).
|
// Force a fresh read from DB (bypasses cache).
|
||||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
$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.
|
// Re-read will populate cache.
|
||||||
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
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.
|
* 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.
|
* @param string $post_type Post type to flush.
|
||||||
*/
|
*/
|
||||||
public static function flush_group( string $post_type ): void {
|
public static function flush_group( string $post_type ): void {
|
||||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
|
||||||
|
$versions = is_array( $versions ) ? $versions : array();
|
||||||
if ( function_exists( 'wp_cache_flush_group' ) ) {
|
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
|
||||||
wp_cache_flush_group( $group );
|
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
|
||||||
}
|
|
||||||
// If wp_cache_flush_group is not available (older WP or no object cache),
|
|
||||||
// individual entries will expire via TTL.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,7 +183,7 @@ class TMDO_Cache_Layer {
|
|||||||
return array(
|
return array(
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'prefetch_support' => true,
|
'prefetch_support' => true,
|
||||||
'flush_support' => function_exists( 'wp_cache_flush_group' ),
|
'flush_support' => true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ class TMDO_Core {
|
|||||||
$post_router = new TMDO_Post_Query_Router();
|
$post_router = new TMDO_Post_Query_Router();
|
||||||
$post_router->register_hooks();
|
$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.
|
// Zone B: Warm cleanup cron handler.
|
||||||
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ class TMDO_Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a NOTICE-level event — alias of info() for callers that want a stronger
|
* Log a NOTICE-level event: always written to the PHP error log (unlike
|
||||||
* level intent. Same routing (error_log only).
|
* info()), for the audit trail of operator actions such as mode changes
|
||||||
|
* and snapshot deletions.
|
||||||
*
|
*
|
||||||
* @param string $event Event name.
|
* @param string $event Event name.
|
||||||
* @param array $context Structured context.
|
* @param array<string, mixed> $context Structured context.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function notice( string $event, array $context = array() ): 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 ) : '' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ final class TMDO_Drift_Check {
|
|||||||
public static function run_scheduled_fix( string $entity_type ): void {
|
public static function run_scheduled_fix( string $entity_type ): void {
|
||||||
try {
|
try {
|
||||||
$result = self::fix( $entity_type );
|
$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'] );
|
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
||||||
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
TMDO_Logger::info(
|
TMDO_Logger::info(
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ final class TMDO_Hook_Bus {
|
|||||||
private static function touch_posts_last_changed( string $type ): void {
|
private static function touch_posts_last_changed( string $type ): void {
|
||||||
if ( 'post' === $type ) {
|
if ( 'post' === $type ) {
|
||||||
wp_cache_set_posts_last_changed();
|
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 {
|
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||||
if ( null === $field_def || 'post' !== $type ) {
|
if ( null !== $field_def && 'post' === $type ) {
|
||||||
return $field_def;
|
$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();
|
$result = array();
|
||||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
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 ) {
|
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
||||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
$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;
|
return $result;
|
||||||
@@ -980,7 +997,8 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
$field_def = self::field_for( $type, $entity_id, $key );
|
$field_def = self::field_for( $type, $entity_id, $key );
|
||||||
if ( ! $field_def ) {
|
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'] );
|
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ final class TMDO_Mode_Manager {
|
|||||||
$entered[ $entity_type ] = time();
|
$entered[ $entity_type ] = time();
|
||||||
update_option( self::OPT_ENTERED_AT, $entered, false );
|
update_option( self::OPT_ENTERED_AT, $entered, false );
|
||||||
|
|
||||||
TMDO_Logger::info(
|
TMDO_Logger::notice(
|
||||||
'bridge_mode_changed',
|
'bridge_mode_changed',
|
||||||
array(
|
array(
|
||||||
'entity_type' => $entity_type,
|
'entity_type' => $entity_type,
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ final class TMDO_Post_Status_Sync {
|
|||||||
unset( $new_status, $old_status );
|
unset( $new_status, $old_status );
|
||||||
foreach ( self::groups_with_columns() as $group ) {
|
foreach ( self::groups_with_columns() as $group ) {
|
||||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $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->query(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
@@ -220,6 +224,20 @@ final class TMDO_Post_Status_Sync {
|
|||||||
return is_array( $states ) ? array_keys( $states ) : array();
|
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.
|
* Record a group's state.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ final class TMDO_Schema_Manager {
|
|||||||
/** Single cron event that adds declared indexes a visitor request had to leave out. */
|
/** Single cron event that adds declared indexes a visitor request had to leave out. */
|
||||||
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
|
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. */
|
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
||||||
private static array $table_exists_cache = array();
|
private static array $table_exists_cache = array();
|
||||||
|
|
||||||
@@ -80,13 +83,13 @@ final class TMDO_Schema_Manager {
|
|||||||
string $group_name,
|
string $group_name,
|
||||||
array $field_definitions
|
array $field_definitions
|
||||||
): bool {
|
): bool {
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
// Schema 版本比對:若未變動則跳過
|
// Schema 版本比對:若未變動則跳過
|
||||||
$schema_hash = self::calculate_schema_hash( $field_definitions );
|
$schema_hash = self::calculate_schema_hash( $field_definitions );
|
||||||
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
||||||
|
|
||||||
if ( $stored_hash === $schema_hash ) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +98,55 @@ final class TMDO_Schema_Manager {
|
|||||||
return false;
|
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();
|
$charset = $wpdb->get_charset_collate();
|
||||||
$id_col = $adapter->get_entity_id_column();
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
|
||||||
@@ -165,8 +216,27 @@ final class TMDO_Schema_Manager {
|
|||||||
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
|
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
|
||||||
self::$table_exists_cache[ $table ] = true;
|
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 版本與定義
|
// 記錄 Schema 版本與定義
|
||||||
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
|
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
|
||||||
|
if ( $existed ) {
|
||||||
|
self::end_deferral( $entity_type, $group_name );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action: 表建立/升級完成
|
* Action: 表建立/升級完成
|
||||||
@@ -218,11 +288,7 @@ final class TMDO_Schema_Manager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
|
if ( ! self::lock() ) {
|
||||||
// processes both "acquire" an option lock; GET_LOCK is atomic and is
|
|
||||||
// released by MySQL if this process dies.
|
|
||||||
$lock = self::index_lock_name();
|
|
||||||
if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) {
|
|
||||||
return; // Another process is adding indexes.
|
return; // Another process is adding indexes.
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -253,22 +319,163 @@ final class TMDO_Schema_Manager {
|
|||||||
update_option( self::INDEXES_OPTION, $applied, true );
|
update_option( self::INDEXES_OPTION, $applied, true );
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) );
|
self::unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INDEXES_HOOK handler: add the indexes a visitor request could not.
|
* 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
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function ensure_pending_indexes(): 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 ) {
|
foreach ( self::pending_indexes() as $id ) {
|
||||||
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
|
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
|
||||||
self::ensure_group_indexes( $entity_type, $group_name );
|
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
|
* 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.
|
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
|
||||||
|
|||||||
@@ -31,8 +31,11 @@
|
|||||||
*
|
*
|
||||||
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
|
* 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
|
* 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
|
* same query, kept in the object cache until a post is saved or deleted, a
|
||||||
* post saves, and flat writes via TMDO_Hook_Bus).
|
* 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
|
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
||||||
* only entity_type='post'.
|
* only entity_type='post'.
|
||||||
@@ -69,6 +72,9 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Object cache group for found-rows counts. */
|
/** Object cache group for found-rows counts. */
|
||||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
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. */
|
/** Seconds a "every post of the type has a flat row" answer is reused. */
|
||||||
private const COVERAGE_TTL = 900;
|
private const COVERAGE_TTL = 900;
|
||||||
|
|
||||||
@@ -433,8 +439,11 @@ class TMDO_Post_Query_Router {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
|
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
|
||||||
|
|
||||||
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
|
// Keyword searches carry wpdb's placeholder escape for "%", which is new
|
||||||
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
// 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 ) {
|
if ( false === $total ) {
|
||||||
$total = (int) $wpdb->get_var( $count_sql );
|
$total = (int) $wpdb->get_var( $count_sql );
|
||||||
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
||||||
@@ -449,6 +458,17 @@ class TMDO_Post_Query_Router {
|
|||||||
// Helpers
|
// 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).
|
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
|
||||||
*
|
*
|
||||||
@@ -634,6 +654,9 @@ class TMDO_Post_Query_Router {
|
|||||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
|
||||||
return false;
|
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() );
|
$status = get_option( TMDO_Drift_Check::OPTION, array() );
|
||||||
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
|
$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;
|
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
|
|||||||
}
|
}
|
||||||
$table = $wpdb->prefix . self::TABLE_SLUG;
|
$table = $wpdb->prefix . self::TABLE_SLUG;
|
||||||
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
|
$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;
|
return false !== $deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
*/
|
*/
|
||||||
class TMDO_Zone_Cold {
|
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.
|
* Get the cold table name for a post type.
|
||||||
*
|
*
|
||||||
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
|
|||||||
return 'wpdo_cold_' . sanitize_key( $post_type );
|
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.
|
* 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 {
|
public static function get_blob( int $post_id, string $post_type ): array {
|
||||||
$group = self::cache_group( $post_type );
|
$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 );
|
$cached = wp_cache_get( $cache_key, $group );
|
||||||
if ( false !== $cached ) {
|
if ( false !== $cached ) {
|
||||||
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
|
|||||||
);
|
);
|
||||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
// 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 {
|
public static function delete( int $post_id, string $post_type ): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
|
$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 ───────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────
|
||||||
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate object cache.
|
// 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
|
// 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 ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
|||||||
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
|
* 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
|
* 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
|
* 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 =
|
= 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
|
* `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
|
||||||
|
|||||||
@@ -134,6 +134,45 @@ class GroupCompositeIndexTest extends TestCase {
|
|||||||
$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' );
|
$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 */
|
/** @return array<string, string[]> index name → ordered columns */
|
||||||
private function indexes(): array {
|
private function indexes(): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
return array( $router, $query, $request );
|
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 {
|
public function test_routed_request_drops_sql_calc_found_rows(): void {
|
||||||
list( $router, $query, $request ) = $this->routed();
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
|
||||||
@@ -117,7 +128,10 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
|
$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' );
|
$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' );
|
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +156,57 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
|
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
|
||||||
$this->assertSame( '1', $count( '2' ) );
|
$this->assertSame( '1', $count( '2' ) );
|
||||||
|
|
||||||
wp_cache_set_posts_last_changed();
|
TMDO_Post_Query_Router::bump_count_version();
|
||||||
$this->assertSame( '0', $count( '2' ), 'recounted' );
|
$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 {
|
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
|
||||||
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
$before = wp_cache_get_last_changed( 'posts' );
|
$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', '' );
|
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
|
||||||
$after_write = wp_cache_get_last_changed( 'posts' );
|
$after_write = wp_cache_get_last_changed( 'posts' );
|
||||||
|
|
||||||
@@ -157,6 +215,7 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertNotSame( $before, $after_write );
|
$this->assertNotSame( $before, $after_write );
|
||||||
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
$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 ─────────────
|
// ── flat-first: a filtered search ordered by a flat column ─────────────
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ class PostStatusSyncTest extends TestCase {
|
|||||||
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
|
$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 {
|
public function test_transition_leaves_tables_without_the_columns_alone(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$wpdb->last_error = '';
|
$wpdb->last_error = '';
|
||||||
|
|||||||
@@ -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'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,19 @@ 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_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( '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( '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_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_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 { $GLOBALS['_wp_scheduled_single'][] = $hook; 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; } }
|
||||||
@@ -421,6 +434,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
|
|||||||
|
|
||||||
$GLOBALS['_wp_cache'] = [];
|
$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_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_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_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; } }
|
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
||||||
|
|||||||
@@ -197,6 +197,25 @@ class CacheLayerTest extends TestCase {
|
|||||||
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
|
$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() ──────────────────────────────────────────────────────────
|
// ── get_stats() ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_get_stats_includes_required_keys(): void {
|
public function test_get_stats_includes_required_keys(): void {
|
||||||
|
|||||||
@@ -145,13 +145,10 @@ class LoggerTest extends TestCase {
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_info_and_notice_stay_out_of_the_log_unless_debug_logging_is_on(): void {
|
public function test_info_stays_out_of_the_log_unless_debug_logging_is_on(): void {
|
||||||
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
|
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
|
||||||
|
|
||||||
$this->assertSame( '', $this->logged( static function () {
|
$this->assertSame( '', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
|
||||||
WPDO_Logger::info( 'cache_flushed' );
|
|
||||||
WPDO_Logger::notice( 'mode_changed' );
|
|
||||||
} ) );
|
|
||||||
|
|
||||||
add_filter( 'tmdo_log_info', static fn() => true );
|
add_filter( 'tmdo_log_info', static fn() => true );
|
||||||
try {
|
try {
|
||||||
@@ -161,6 +158,12 @@ class LoggerTest extends TestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
public function test_warnings_are_always_logged(): void {
|
||||||
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
|
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user