@@ -141,6 +141,7 @@ public static function status(): array {
141141 'active_keys ' => self ::lock_keys_for_status ('active ' , false ),
142142 'stale ' => (int ) $ wpdb ->get_var ($ wpdb ->prepare ("SELECT COUNT(*) FROM {$ table } WHERE status = %s AND expires_at < %s " , 'active ' , $ now )),
143143 'stale_keys ' => self ::lock_keys_for_status ('active ' , true ),
144+ 'locks ' => array_merge (self ::lock_rows_for_status ('active ' , false ), self ::lock_rows_for_status ('active ' , true )),
144145 'released ' => (int ) $ wpdb ->get_var ($ wpdb ->prepare ("SELECT COUNT(*) FROM {$ table } WHERE status = %s " , 'released ' )),
145146 'total ' => (int ) $ wpdb ->get_var ("SELECT COUNT(*) FROM {$ table }" ),
146147 );
@@ -211,7 +212,7 @@ public static function active_lock( string $lock_key, string $scope ): array|nul
211212 *
212213 * @return array<string,mixed>
213214 */
214- public static function prune_expired (): array {
215+ public static function prune_expired ( array $ protected_lock_keys = array () ): array {
215216 $ status = self ::status ();
216217 if ( empty ($ status ['available ' ]) ) {
217218 return array (
@@ -224,22 +225,35 @@ public static function prune_expired(): array {
224225 }
225226
226227 global $ wpdb ;
227- $ table = self ::table_name ();
228- $ now = gmdate ('Y-m-d H:i:s ' );
229- $ released_cutoff = gmdate ('Y-m-d H:i:s ' , time () - self ::released_ttl_seconds ());
228+ $ table = self ::table_name ();
229+ $ now = gmdate ('Y-m-d H:i:s ' );
230+ $ released_cutoff = gmdate ('Y-m-d H:i:s ' , time () - self ::released_ttl_seconds ());
231+ $ protected_lock_keys = array_values (array_unique (array_filter (array_map ('strval ' , $ protected_lock_keys ))));
232+ $ protected_sql = '' ;
233+ $ protected_args = array ();
234+ if ( array () !== $ protected_lock_keys ) {
235+ $ protected_sql = ' AND lock_key NOT IN ( ' . implode (', ' , array_fill (0 , count ($ protected_lock_keys ), '%s ' )) . ') ' ;
236+ $ protected_args = $ protected_lock_keys ;
237+ }
230238
231- // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
232- $ wpdb ->query ($ wpdb ->prepare ("UPDATE {$ table } SET status = %s WHERE status = %s AND expires_at < %s " , 'stale ' , 'active ' , $ now ));
239+ // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
240+ $ mark_sql = "UPDATE {$ table } SET status = %s WHERE status = %s AND expires_at < %s {$ protected_sql }" ;
241+ $ mark_args = array_merge (array ( 'stale ' , 'active ' , $ now ), $ protected_args );
242+ $ wpdb ->query (call_user_func_array (array ( $ wpdb , 'prepare ' ), array_merge (array ( $ mark_sql ), $ mark_args )));
233243 $ marked = (int ) $ wpdb ->rows_affected ;
234244
235- $ wpdb ->query ($ wpdb ->prepare ("DELETE FROM {$ table } WHERE status IN (%s, %s) AND COALESCE(released_at, expires_at) < %s " , 'released ' , 'stale ' , $ released_cutoff ));
236- // phpcs:enable WordPress.DB.PreparedSQL
245+ $ delete_sql = "DELETE FROM {$ table } WHERE status IN (%s, %s) AND COALESCE(released_at, expires_at) < %s {$ protected_sql }" ;
246+ $ delete_args = array_merge (array ( 'released ' , 'stale ' , $ released_cutoff ), $ protected_args );
247+ $ wpdb ->query (call_user_func_array (array ( $ wpdb , 'prepare ' ), array_merge (array ( $ delete_sql ), $ delete_args )));
248+ // phpcs:enable WordPress.DB.PreparedSQL
237249 $ deleted = (int ) $ wpdb ->rows_affected ;
238250
239251 return array (
240252 'available ' => true ,
241253 'active_marked_stale ' => $ marked ,
242254 'released_deleted ' => $ deleted ,
255+ 'protected_active ' => count ($ protected_lock_keys ),
256+ 'protected_keys ' => $ protected_lock_keys ,
243257 'before ' => $ status ,
244258 'after ' => self ::status (),
245259 );
@@ -322,6 +336,60 @@ private static function lock_keys_for_status( string $status, bool $expired ): a
322336 );
323337 }
324338
339+ /**
340+ * Return lock rows for owner/session/age diagnostics.
341+ *
342+ * @return array<int,array<string,mixed>>
343+ */
344+ private static function lock_rows_for_status ( string $ status , bool $ expired ): array {
345+ global $ wpdb ;
346+ if ( ! is_object ($ wpdb ) || ! is_callable (array ( $ wpdb , 'prepare ' )) || ! is_callable (array ( $ wpdb , 'get_results ' )) ) {
347+ return array ();
348+ }
349+
350+ $ table = self ::table_name ();
351+ $ now = gmdate ('Y-m-d H:i:s ' );
352+ $ operator = $ expired ? '< ' : '>= ' ;
353+
354+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, operator is constant-selected above.
355+ $ query = call_user_func (array ( $ wpdb , 'prepare ' ), "SELECT id, lock_key, purpose, scope, owner, run_id, job_id, status, acquired_at, heartbeat_at, expires_at, released_at, metadata_json FROM {$ table } WHERE status = %s AND expires_at {$ operator } %s ORDER BY acquired_at DESC, id DESC LIMIT 25 " , $ status , $ now );
356+ $ rows = call_user_func (array ( $ wpdb , 'get_results ' ), $ query , ARRAY_A );
357+ if ( ! is_array ($ rows ) ) {
358+ return array ();
359+ }
360+
361+ return array_values (array_map (static fn ( array $ row ): array => self ::normalize_lock_row ($ row , $ expired ? 'stale ' : 'active ' ), $ rows ));
362+ }
363+
364+ /**
365+ * @param array<string,mixed> $row Raw DB row.
366+ * @return array<string,mixed>
367+ */
368+ private static function normalize_lock_row ( array $ row , string $ state ): array {
369+ $ row ['id ' ] = (int ) ( $ row ['id ' ] ?? 0 );
370+ $ row ['job_id ' ] = isset ($ row ['job_id ' ]) ? (int ) $ row ['job_id ' ] : null ;
371+ $ row ['state ' ] = $ state ;
372+ $ row ['metadata ' ] = self ::decode_metadata ( (string ) ( $ row ['metadata_json ' ] ?? '' ) );
373+ unset($ row ['metadata_json ' ]);
374+
375+ $ acquired = self ::timestamp_seconds ( (string ) ( $ row ['acquired_at ' ] ?? '' ) );
376+ $ heartbeat = self ::timestamp_seconds ( (string ) ( $ row ['heartbeat_at ' ] ?? '' ) );
377+ $ expires = self ::timestamp_seconds ( (string ) ( $ row ['expires_at ' ] ?? '' ) );
378+ $ time = time ();
379+ if ( null !== $ acquired ) {
380+ $ row ['age_seconds ' ] = max (0 , $ time - $ acquired );
381+ }
382+ if ( null !== $ heartbeat ) {
383+ $ row ['heartbeat_age_seconds ' ] = max (0 , $ time - $ heartbeat );
384+ }
385+ if ( null !== $ expires ) {
386+ $ row ['expires_age_seconds ' ] = 'stale ' === $ state ? max (0 , $ time - $ expires ) : 0 ;
387+ $ row ['expires_in_seconds ' ] = 'active ' === $ state ? max (0 , $ expires - $ time ) : 0 ;
388+ }
389+
390+ return $ row ;
391+ }
392+
325393 private static function expires_seconds (): int {
326394 $ seconds = self ::DEFAULT_EXPIRES_SECONDS ;
327395 if ( function_exists ('apply_filters ' ) ) {
0 commit comments