0`, an aggregate SUM(status) over whatever * completion rows landed. If 3 of 4 shards succeeded and the 4th child * crashed before writing a completion row, the sum from the 3 successful * shards was still positive and every archive table for the run -- including * the crashed child's entire unprocessed shard -- got dropped. * * The drain loop a few lines above already computes the correct signal * (boost_completed_children() < $expected_children) to detect a crashed * child; the fix wires that same completeness check into the drop decision. */ $boostPollerPath = CACTI_PATH_BASE . '/poller_boost.php'; test('archive-table drop is gated on shard completeness, not the status sum', function () use ($boostPollerPath) { $contents = file_get_contents($boostPollerPath); $rrd_updates_pos = strpos($contents, 'COALESCE(SUM(CAST(status AS SIGNED)), 0)'); expect($rrd_updates_pos)->not->toBeFalse(); $drop_pos = strpos($contents, 'DROP TABLE IF EXISTS `$table`', $rrd_updates_pos); expect($drop_pos)->not->toBeFalse(); $segment = substr($contents, $rrd_updates_pos, $drop_pos - $rrd_updates_pos); // The completeness check must appear between the SUM() read and the // actual DROP, gating it. expect($segment)->toContain('boost_completed_children($run_id) >= $expected_children'); expect($segment)->toContain('$failed_children === 0'); // A bare "if ($rrd_updates > 0) { ... DROP ... }" with nothing else // between them is exactly the bug: the drop must not be reachable // without the completeness check in between. $gate_pos = strpos($segment, 'boost_completed_children($run_id) >= $expected_children'); expect($gate_pos)->toBeGreaterThan(0); }); test('partial-completion path logs a warning instead of silently keeping the tables', function () use ($boostPollerPath) { $contents = file_get_contents($boostPollerPath); $parser = (new \PhpParser\ParserFactory())->createForNewestSupportedVersion(); $finder = new \PhpParser\NodeFinder(); $printer = new \PhpParser\PrettyPrinter\Standard(); $branches = $finder->findInstanceOf($parser->parse($contents), \PhpParser\Node\Stmt\If_::class); $gate = null; foreach ($branches as $branch) { $condition = $printer->prettyPrintExpr($branch->cond); if (str_contains($condition, 'boost_completed_children($run_id) >= $expected_children')) { $gate = $branch; break; } } expect($gate)->not->toBeNull(); expect($gate->else)->not->toBeNull(); $logs = $finder->find($gate->else->stmts, fn ($node) => $node instanceof \PhpParser\Node\Expr\FuncCall && $node->name instanceof \PhpParser\Node\Name && $node->name->toString() === 'cacti_log' ); expect($logs)->toHaveCount(1); $format = array_values($logs)[0]->args[0]->value; expect($format)->toBeInstanceOf(\PhpParser\Node\Expr\FuncCall::class); expect($format->name->toString())->toBe('sprintf'); $message = (new \PhpParser\ConstExprEvaluator())->evaluateDirectly($format->args[0]->value); expect($message)->toContain('leaving archive tables in place for the next run to pick up'); }); test('boost_completed_children and expected_children are still computed the same way the drain loop uses', function () use ($boostPollerPath) { $contents = file_get_contents($boostPollerPath); // The drain loop's own completeness check must still be present and // unchanged; the drop-gate fix reuses these exact symbols rather than // inventing a parallel accounting mechanism that could drift from it. expect($contents)->toContain('boost_completed_children($run_id) < $expected_children'); expect(substr_count($contents, '$expected_children'))->toBeGreaterThanOrEqual(4); });