Skip to content

Commit 08c5cc6

Browse files
authored
Fix stale workspace lock triage (#655)
1 parent f9eac72 commit 08c5cc6

7 files changed

Lines changed: 438 additions & 12 deletions

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,7 @@ private function cleanup_run_control_job_ids( string $operation, int $job_id ):
874874
}
875875

876876
private function render_cleanup_control_result( array $result, array $assoc_args ): void {
877+
$result = $this->attach_current_workspace_lock_status($result);
877878
$format = (string) ( $assoc_args['format'] ?? 'table' );
878879
if ( 'json' === $format ) {
879880
WP_CLI::log( (string) wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
@@ -896,11 +897,37 @@ private function render_cleanup_control_result( array $result, array $assoc_args
896897
if ( ! empty($result['remaining_work_summary']) && is_array($result['remaining_work_summary']) ) {
897898
$this->render_cleanup_remaining_work_summary( (array) $result['remaining_work_summary']);
898899
}
900+
if ( ! empty($result['locks']['stale_locks']) && is_array($result['locks']['stale_locks']) ) {
901+
$this->render_stale_lock_followup( (array) $result['locks']['stale_locks']);
902+
}
899903
if ( ! empty($result['evidence']) ) {
900904
WP_CLI::log( (string) wp_json_encode($result['evidence'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
901905
}
902906
}
903907

908+
/**
909+
* Attach live workspace lock status to cleanup triage surfaces when available.
910+
*
911+
* @param array<string,mixed> $result Cleanup result.
912+
* @return array<string,mixed>
913+
*/
914+
private function attach_current_workspace_lock_status( array $result ): array {
915+
if ( isset($result['locks']) || ! class_exists(Workspace::class) || ! class_exists(WorkspaceMutationLock::class) ) {
916+
return $result;
917+
}
918+
919+
try {
920+
$workspace = new Workspace();
921+
$result['locks'] = WorkspaceMutationLock::status($workspace->get_path());
922+
} catch ( \Throwable $e ) {
923+
$result['locks'] = array(
924+
'error' => $e->getMessage(),
925+
);
926+
}
927+
928+
return $result;
929+
}
930+
904931
/**
905932
* Render compact cleanup remaining-work summary.
906933
*
@@ -3918,6 +3945,30 @@ static function ( array $lock ): array {
39183945
$this->format_items($items, array( 'lock_key', 'scope', 'state', 'age_seconds', 'safe_to_prune', 'owner_source', 'path' ), $assoc_args, 'lock_key');
39193946
}
39203947

3948+
$db_locks = (array) ( $db['locks'] ?? array() );
3949+
if ( ! empty($db_locks) ) {
3950+
$items = array_map(
3951+
static function ( array $lock ): array {
3952+
return array(
3953+
'lock_key' => (string) ( $lock['lock_key'] ?? '' ),
3954+
'scope' => (string) ( $lock['scope'] ?? '' ),
3955+
'state' => (string) ( $lock['state'] ?? $lock['status'] ?? '' ),
3956+
'owner' => (string) ( $lock['owner'] ?? '' ),
3957+
'age_seconds' => $lock['age_seconds'] ?? null,
3958+
'expires_at' => (string) ( $lock['expires_at'] ?? '' ),
3959+
);
3960+
},
3961+
$db_locks
3962+
);
3963+
WP_CLI::log('');
3964+
WP_CLI::log('Database lock rows:');
3965+
$this->format_items($items, array( 'lock_key', 'scope', 'state', 'owner', 'age_seconds', 'expires_at' ), $assoc_args, 'lock_key');
3966+
}
3967+
3968+
if ( ! empty($status['stale_locks']) && is_array($status['stale_locks']) ) {
3969+
$this->render_stale_lock_followup( (array) $status['stale_locks']);
3970+
}
3971+
39213972
$guidance = (array) ( $fs['guidance'] ?? $status['recovery_guidance'] ?? array() );
39223973
if ( ! empty($guidance) ) {
39233974
WP_CLI::log(sprintf('Status: %s', (string) ( $guidance['status_command'] ?? 'wp datamachine-code workspace worktree locks --format=json' )));
@@ -3926,6 +3977,57 @@ static function ( array $lock ): array {
39263977
}
39273978
}
39283979

3980+
/**
3981+
* Render stale lock follow-up rows with exact preview/apply commands.
3982+
*
3983+
* @param array<string,mixed> $report Stale lock report.
3984+
*/
3985+
private function render_stale_lock_followup( array $report ): void {
3986+
if ( (int) ( $report['count'] ?? 0 ) <= 0 ) {
3987+
return;
3988+
}
3989+
3990+
WP_CLI::log('');
3991+
WP_CLI::log('Stale workspace locks:');
3992+
WP_CLI::log(sprintf('Preview: %s', (string) ( $report['preview_command'] ?? 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json' )));
3993+
WP_CLI::log(sprintf('Apply: %s', (string) ( $report['apply_command'] ?? 'wp datamachine-code workspace worktree locks --prune-stale --format=json' )));
3994+
WP_CLI::log((string) ( $report['safety'] ?? 'Active filesystem flocks are reported and protected.' ));
3995+
3996+
$rows = array();
3997+
foreach ( (array) ( $report['database'] ?? array() ) as $row ) {
3998+
if ( ! is_array($row) ) {
3999+
continue;
4000+
}
4001+
$rows[] = array(
4002+
'source' => 'database',
4003+
'lock_key' => (string) ( $row['lock_key'] ?? '' ),
4004+
'scope' => (string) ( $row['scope'] ?? '' ),
4005+
'owner' => (string) ( $row['owner'] ?? '' ),
4006+
'session' => (string) ( $row['session'] ?? '' ),
4007+
'age_seconds' => $row['age_seconds'] ?? null,
4008+
'live_flock_present' => ! empty($row['live_flock_present']) ? 'yes' : 'no',
4009+
'safe_to_prune' => ! empty($row['safe_to_prune']) ? 'yes' : 'no',
4010+
);
4011+
}
4012+
foreach ( (array) ( $report['filesystem'] ?? array() ) as $row ) {
4013+
if ( ! is_array($row) ) {
4014+
continue;
4015+
}
4016+
$rows[] = array(
4017+
'source' => 'filesystem',
4018+
'lock_key' => (string) ( $row['lock_key'] ?? '' ),
4019+
'scope' => (string) ( $row['scope'] ?? '' ),
4020+
'owner' => '',
4021+
'session' => '',
4022+
'age_seconds' => $row['age_seconds'] ?? null,
4023+
'live_flock_present' => ! empty($row['live_flock_present']) ? 'yes' : 'no',
4024+
'safe_to_prune' => ! empty($row['safe_to_prune']) ? 'yes' : 'no',
4025+
);
4026+
}
4027+
4028+
$this->format_items($rows, array( 'source', 'lock_key', 'scope', 'owner', 'session', 'age_seconds', 'live_flock_present', 'safe_to_prune' ), array( 'format' => 'table' ), 'lock_key');
4029+
}
4030+
39294031
private function render_workspace_error( \WP_Error $error ): void {
39304032
$data = (array) $error->get_error_data();
39314033
if ( 'workspace_repo_busy' !== $error->get_error_code() && ! empty($data['next_commands']) && is_array($data['next_commands']) ) {
@@ -4165,6 +4267,10 @@ private function render_workspace_hygiene_report( array $report, array $assoc_ar
41654267
'metric'
41664268
);
41674269

4270+
if ( ! empty($locks['stale_locks']) && is_array($locks['stale_locks']) ) {
4271+
$this->render_stale_lock_followup( (array) $locks['stale_locks']);
4272+
}
4273+
41684274
$duplicates = (array) ( $worktrees['duplicates'] ?? array() );
41694275
if ( array() !== $duplicates ) {
41704276
WP_CLI::log('');

inc/Workspace/WorkspaceHygieneReport.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ private function build_workspace_inventory_rows(): array {
365365

366366
$rows = array();
367367
foreach ( $entries as $entry ) {
368-
if ( '.' === $entry || '..' === $entry ) {
368+
if ( '.' === $entry || '..' === $entry || str_starts_with((string) $entry, '.') ) {
369369
continue;
370370
}
371371

@@ -444,7 +444,7 @@ private function build_workspace_size_report( int $limit ): array {
444444
$dirs = array_values(
445445
array_filter(
446446
$entries,
447-
fn( $entry ) => '.' !== $entry && '..' !== $entry && is_dir($this->workspace_path . '/' . $entry)
447+
fn( $entry ) => '.' !== $entry && '..' !== $entry && ! str_starts_with((string) $entry, '.') && is_dir($this->workspace_path . '/' . $entry)
448448
)
449449
);
450450
sort($dirs, SORT_NATURAL);

inc/Workspace/WorkspaceLockStore.php

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)