Skip to content

Commit eef91a3

Browse files
authored
fix: bound abandoned cleanup budget (#577)
1 parent 7d0e7a8 commit eef91a3

2 files changed

Lines changed: 91 additions & 14 deletions

File tree

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2764,6 +2764,14 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
27642764
$limit = isset($assoc_args['limit']) ? max(1, min(100, (int) $assoc_args['limit'])) : 100;
27652765
$passes = isset($assoc_args['passes']) ? max(1, min(25, (int) $assoc_args['passes'])) : 5;
27662766
$until_budget = isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ? trim( (string) $assoc_args['until-budget']) : '';
2767+
$deadline = null;
2768+
if ( '' !== $until_budget ) {
2769+
$budget_seconds = $this->parse_worktree_abandoned_budget($until_budget);
2770+
if ( is_wp_error($budget_seconds) ) {
2771+
return $budget_seconds;
2772+
}
2773+
$deadline = microtime(true) + $budget_seconds;
2774+
}
27672775

27682776
$required = array(
27692777
'reconcile_metadata' => 'datamachine-code/workspace-worktree-reconcile-metadata',
@@ -2818,9 +2826,6 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28182826
'limit' => $limit,
28192827
'source' => self::CLEANUP_CLI_SOURCE,
28202828
);
2821-
if ( '' !== $until_budget ) {
2822-
$common_page['until_budget'] = $until_budget;
2823-
}
28242829

28252830
$reconcile_input = array_merge(
28262831
$common_page,
@@ -2829,7 +2834,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28292834
'apply' => $apply,
28302835
)
28312836
);
2832-
$reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply);
2837+
$reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline);
28332838
if ( is_wp_error($reconcile) ) {
28342839
return $reconcile;
28352840
}
@@ -2848,14 +2853,19 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28482853
$result['executed_passes'] = $pass;
28492854
$pass_marked = 0;
28502855
foreach ( $mark_steps as $key => $ability ) {
2856+
if ( $this->worktree_abandoned_budget_expired($deadline) ) {
2857+
$result['evidence']['budget_exhausted'] = true;
2858+
break 2;
2859+
}
2860+
28512861
$step_input = array_merge(
28522862
$common_page,
28532863
array(
28542864
'dry_run' => ! $apply,
28552865
'offset' => 0,
28562866
)
28572867
);
2858-
$step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply);
2868+
$step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply, $deadline);
28592869
if ( is_wp_error($step) ) {
28602870
return $step;
28612871
}
@@ -2940,7 +2950,12 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
29402950
* @param bool $apply Whether the orchestration is applying changes.
29412951
* @return array<string,mixed>|\WP_Error
29422952
*/
2943-
private function drain_worktree_abandoned_pages( object $ability, array $base_input, bool $apply ): array|\WP_Error {
2953+
private function drain_worktree_abandoned_pages( object $ability, array $base_input, bool $apply, ?float $deadline = null ): array|\WP_Error {
2954+
$execute = array( $ability, 'execute' );
2955+
if ( ! is_callable($execute) ) {
2956+
return new \WP_Error('worktree_abandoned_ability_invalid', 'Worktree abandoned cleanup ability is not executable.', array( 'status' => 500 ));
2957+
}
2958+
29442959
$pages = array();
29452960
$summary = array();
29462961
$pagination = array();
@@ -2949,12 +2964,17 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29492964
$last_result = array();
29502965

29512966
for ( $page = 1; $page <= $max_pages; ++$page ) {
2967+
if ( null !== $deadline && $this->worktree_abandoned_budget_expired($deadline) ) {
2968+
break;
2969+
}
2970+
29522971
$input = $base_input;
29532972
if ( isset($base_input['offset']) || $page > 1 ) {
29542973
$input['offset'] = $offset;
29552974
}
2975+
$this->apply_worktree_abandoned_remaining_budget($input, $deadline);
29562976

2957-
$result = $ability->execute($input);
2977+
$result = $execute($input);
29582978
if ( is_wp_error($result) ) {
29592979
return $result;
29602980
}
@@ -2986,6 +3006,16 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29863006
$offset = $next_offset;
29873007
}
29883008

3009+
if ( array() === $last_result ) {
3010+
$last_result = array(
3011+
'success' => true,
3012+
'mode' => 'abandoned_budget_exhausted',
3013+
'dry_run' => ! empty($base_input['dry_run']),
3014+
'applied' => $apply,
3015+
'budget_exhausted' => true,
3016+
);
3017+
}
3018+
29893019
$last_result['summary'] = $summary;
29903020
$last_result['pagination'] = $pagination;
29913021
$last_result['pages'] = $pages;
@@ -2994,6 +3024,54 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29943024
return $last_result;
29953025
}
29963026

3027+
/**
3028+
* Parse abandoned-cleanup wall-clock budget.
3029+
*
3030+
* @param string $duration Duration like 60s, 10m, or 1h.
3031+
* @return int|\WP_Error
3032+
*/
3033+
private function parse_worktree_abandoned_budget( string $duration ): int|\WP_Error {
3034+
if ( ! preg_match('/^(\d+)([smh])$/', trim($duration), $matches) ) {
3035+
return new \WP_Error('invalid_worktree_abandoned_budget', 'Invalid --until-budget duration. Use a compact value like 60s, 10m, or 1h.', array( 'status' => 400 ));
3036+
}
3037+
3038+
$value = (int) $matches[1];
3039+
if ( $value < 1 ) {
3040+
return new \WP_Error('invalid_worktree_abandoned_budget', 'Invalid --until-budget duration. Duration must be greater than zero.', array( 'status' => 400 ));
3041+
}
3042+
3043+
return match ( $matches[2] ) {
3044+
'h' => $value * HOUR_IN_SECONDS,
3045+
'm' => $value * MINUTE_IN_SECONDS,
3046+
default => $value,
3047+
};
3048+
}
3049+
3050+
/**
3051+
* Forward only the remaining abandoned-cleanup budget to one ability call.
3052+
*
3053+
* @param array<string,mixed> $input Ability input.
3054+
* @param float|null $deadline Shared wall-clock deadline.
3055+
*/
3056+
private function apply_worktree_abandoned_remaining_budget( array &$input, ?float $deadline ): void {
3057+
if ( null === $deadline ) {
3058+
return;
3059+
}
3060+
3061+
$remaining = max(1, (int) floor($deadline - microtime(true)));
3062+
$input['until_budget'] = $remaining . 's';
3063+
}
3064+
3065+
/**
3066+
* Check whether the abandoned-cleanup shared deadline has expired.
3067+
*
3068+
* @param float|null $deadline Shared wall-clock deadline.
3069+
* @return bool
3070+
*/
3071+
private function worktree_abandoned_budget_expired( ?float $deadline ): bool {
3072+
return null !== $deadline && microtime(true) >= $deadline;
3073+
}
3074+
29973075
/**
29983076
* Build a compact step summary for abandoned cleanup output.
29993077
*
@@ -3020,17 +3098,15 @@ private function summarize_worktree_abandoned_step( array $step ): array {
30203098
/**
30213099
* Merge blocked rows by handle so repeated passes do not duplicate output.
30223100
*
3023-
* @param array<int,array<string,mixed>> $existing Existing blocked rows.
3024-
* @param array<int,mixed> $incoming Incoming skipped rows.
3101+
* @param array<int|string,array<string,mixed>> $existing Existing blocked rows.
3102+
* @param array<int,mixed> $incoming Incoming skipped rows.
30253103
* @return array<string,array<string,mixed>>
30263104
*/
30273105
private function merge_worktree_abandoned_blockers( array $existing, array $incoming ): array {
30283106
$merged = array();
30293107
foreach ( $existing as $row ) {
3030-
if ( is_array($row) ) {
3031-
$handle = (string) ( $row['handle'] ?? count($merged) );
3032-
$merged[ $handle ] = $row;
3033-
}
3108+
$handle = (string) ( $row['handle'] ?? count($merged) );
3109+
$merged[ $handle ] = $row;
30343110
}
30353111

30363112
foreach ( $incoming as $row ) {

tests/smoke-worktree-cleanup-cli.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,8 @@ public function execute( array $input ): array
10781078
datamachine_code_cleanup_assert(1 === (int) ( $reconcile_metadata_ability->last_input['limit'] ?? 0 ), 'abandoned forwards limit to metadata reconciliation');
10791079
datamachine_code_cleanup_assert(false === ( $reconcile_metadata_ability->last_input['dry_run'] ?? null ), 'abandoned --apply applies metadata reconciliation');
10801080
datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($reconcile_metadata_ability->inputs, -2)), 'abandoned drains metadata reconciliation pages in apply mode');
1081-
datamachine_code_cleanup_assert('30s' === ( $active_finalized_ability->last_input['until_budget'] ?? '' ), 'abandoned forwards time budget to active/no-signal marking');
1081+
$abandoned_forwarded_budget = (string) ( $active_finalized_ability->last_input['until_budget'] ?? '' );
1082+
datamachine_code_cleanup_assert(1 === preg_match('/^\d+s$/', $abandoned_forwarded_budget) && (int) $abandoned_forwarded_budget <= 30, 'abandoned forwards remaining time budget to active/no-signal marking');
10821083
datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($active_finalized_ability->inputs, -2)), 'abandoned drains active/no-signal classifier pages in apply mode');
10831084
datamachine_code_cleanup_assert(true === ( $bounded_apply_ability->last_input['force'] ?? null ), 'abandoned forwards force only to bounded cleanup removal');
10841085
datamachine_code_cleanup_assert(false === ( $bounded_apply_ability->last_input['dry_run'] ?? null ), 'abandoned --apply removes eligible rows');

0 commit comments

Comments
 (0)