Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0d27d3d
feat(dev): add local dev environment via docker compose
GALHP Jul 24, 2026
653e554
feat(dev): add make targets for local development
GALHP Jul 24, 2026
833b1e6
test(init): make IO-error tests user independent
GALHP Aug 7, 2026
66881c1
feat(cloning): add per-table timing and progress tracking data model
GALHP Jul 27, 2026
89446d0
test(cloning): cover per-table timing and progress data model
GALHP Jul 27, 2026
ce61d7d
feat(cloning): emit per-table timing and progress events from run orc…
GALHP Jul 27, 2026
b472571
test(cloning): cover progress and timing events in run orchestrator
GALHP Jul 27, 2026
0894076
feat(cloning): render nested progress bars for cloning:run at -v/-vv/…
GALHP Jul 27, 2026
80ae18b
test(cloning): cover progress rendering and timing summary in cloning…
GALHP Jul 27, 2026
2b977c7
feat(cloning): show per-table ETA on the progress bar in verbose modes
GALHP Aug 7, 2026
714d357
perf(cloning): use keyset pagination for chunked reads to avoid deep …
GALHP Aug 7, 2026
6e618ed
feat(cloning): add support for regex table names in cloning configura…
GALHP Aug 12, 2026
6a431ce
test(cloning): expect to validate config with regex table names
GALHP Aug 12, 2026
27c9c24
test(cloning): expect to match regex table names
GALHP Aug 12, 2026
cede05e
docs(cloning): add row strategy `skip` to docs
GALHP Aug 10, 2026
af9ae06
feat(cloning): add remap_keys toggle for cloning:dump and run
GALHP Aug 12, 2026
85361ff
test(cloning): expect to validate config with remap_keys options
GALHP Aug 12, 2026
1a6eeb8
test(cloning): expect to apply the remap_keys option gracefully
GALHP Aug 12, 2026
6e24bed
test(cloning): expect to produce quoted null strategy column
GALHP Aug 13, 2026
9697935
fix(cloning): quote the null column strategy so dumped configs validate
GALHP Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
APP_KEY=
# DEV_PHP_VERSION=8.5-dev-macos
DEV_PHP_VERSION=8.5-dev
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,6 @@ docker-test: docker-build

docker-shell: docker-build
docker run --rm -it -v "$$(pwd)":/workspace --entrypoint sh $(DOCKER_IMAGE)


include Makefile.dev.mk
17 changes: 17 additions & 0 deletions Makefile.dev.mk
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
up:
docker compose up -d

down:
docker compose down

restart: down up

bash:
docker compose exec php bash

test:
docker compose exec php bash -c 'XDEBUG_MODE=coverage composer test'

check:
docker compose exec php bash -c 'composer lint && composer test:types'

4 changes: 4 additions & 0 deletions app/Commands/Cloning/DumpCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class DumpCommand extends Command
{--drop-unknown-tables : Set drop_unknown_tables: true in the generated YAML}
{--drop-extra-columns : Set drop_extra_columns: true in the generated YAML}
{--no-disable-fk-checks : Set disable_foreign_key_checks: false in the generated YAML}
{--skip-remapping-keys : Set remap_keys: false in the generated YAML}
{--ci : CI mode — suppress non-error output}';

/**
Expand Down Expand Up @@ -245,13 +246,15 @@ static function (TableDumpData $t): TableDumpData {
$dropUnknownTables = (bool) $this->option('drop-unknown-tables');
$dropExtraColumns = (bool) $this->option('drop-extra-columns');
$disableForeignKeyChecks = ! $this->option('no-disable-fk-checks');
$remapKeys = ! (bool) $this->option('skip-remapping-keys');

if (! $ci) {
$this->line(' Transfer options:');
$enforceColumnTypes = $this->confirm(' Enforce column types on target?', $enforceColumnTypes);
$dropUnknownTables = $this->confirm(' Drop unknown tables on target?', $dropUnknownTables);
$dropExtraColumns = $this->confirm(' Drop extra columns on target?', $dropExtraColumns);
$disableForeignKeyChecks = $this->confirm(' Disable foreign key checks?', $disableForeignKeyChecks);
$remapKeys = $this->confirm(' Remap keys?', $remapKeys);
$this->line('');
}

Expand All @@ -269,6 +272,7 @@ static function (TableDumpData $t): TableDumpData {
dropUnknownTables: $dropUnknownTables,
dropExtraColumns: $dropExtraColumns,
disableForeignKeyChecks: $disableForeignKeyChecks,
remapKeys: $remapKeys,
);

// 12. Write YAML
Expand Down
351 changes: 305 additions & 46 deletions app/Commands/Cloning/RunCommand.php

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/Data/Cloning/CloningOptionsData.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public function __construct(
public bool $dropExtraColumns,
public bool $disableForeignKeyChecks,
public string $fakerLocale,
public bool $remapKeys = true,
) {}
}
1 change: 1 addition & 0 deletions app/Data/Cloning/DumpResultData.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public function __construct(
public bool $dropUnknownTables = false,
public bool $dropExtraColumns = false,
public bool $disableForeignKeyChecks = true,
public bool $remapKeys = true,
) {}
}
27 changes: 27 additions & 0 deletions app/Data/Cloning/StatsLoopData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Data\Cloning;

/**
* Snapshot of a single chunk-loop iteration inside `transferTable()`.
*
* `rowsDone` and `rowsSkipped` count rows for **this loop only**, not
* cumulative — inspect the parent `StatsTableTransferData` for
* cumulative progress.
*/
final readonly class StatsLoopData
{
public function __construct(
public int $loopIndex,
public int $chunkRows,
public float $selectSeconds,
public float $transformSeconds,
public float $insertSeconds,
public float $overallSeconds,
public int $rowsDone,
public int $rowsSkipped,
public int $totalRows,
) {}
}
28 changes: 28 additions & 0 deletions app/Data/Cloning/StatsLoopSnapshotData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Data\Cloning;

/**
* Immutable scalar snapshot captured after a chunk loop is recorded.
* Consumers can iterate `StatsTableTransferData::$statsOverTime` to observe
* how cumulative progress and per-phase throughput evolve across loops.
*
* Only scalars are stored (not references to the mutable aggregates), so a
* snapshot is intrinsically immutable and cheap to retain per loop.
*/
final readonly class StatsLoopSnapshotData
{
public function __construct(
public int $loopIndex,
public int $loopsRecorded,
public int $rowsDoneCumulative,
public int $rowsSkippedCumulative,
public ?float $percentComplete,
public ?float $selectPacePerMillion,
public ?float $transformPacePerMillion,
public ?float $insertPacePerMillion,
public ?float $loopPacePerMillion,
) {}
}
85 changes: 85 additions & 0 deletions app/Data/Cloning/StatsPhaseAggregateData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace App\Data\Cloning;

/**
* Mutable running aggregate for a single per-chunk timing phase. Updated
* in O(1) via `record()`; derived throughput figures are exposed as
* virtual (computed) properties.
*/
final class StatsPhaseAggregateData
{
public private(set) int $count = 0;

public private(set) float $sum = 0.0;

public private(set) ?float $min = null;

public private(set) ?float $max = null;

public private(set) ?float $last = null;

public private(set) ?int $lastRows = null;

public private(set) int $rowsProcessed = 0;

/** Mean seconds per sample, or null when no sample has been recorded. */
public ?float $averageSeconds {
get => $this->count > 0 ? $this->sum / $this->count : null;
}

/**
* Aggregate seconds per row across all samples,
* or null when no rows have been processed.
*/
public ?float $pace {
get => $this->rowsProcessed > 0
? ($this->sum / $this->rowsProcessed)
: null;
}

/**
* Aggregate seconds per 1,000,000 rows across all samples,
* or null when no rows have been processed.
*/
public ?float $pacePerMillion {
get => ($pace = $this->pace) !== null
? $pace * 1_000_000.0
: null;
}

public ?float $latestPace {
get => $this->last !== null && $this->lastRows !== null && $this->lastRows > 0
? $this->last / $this->lastRows
: null;
}

/** Latest-sample seconds per 1,000,000 rows, or null when unavailable. */
public ?float $latestPacePerMillion {
get => ($pace = $this->latestPace) !== null
? $pace * 1_000_000.0
: null;
}

public static function withRecord(float $seconds, int $rows): self
{
$instance = new self;

$instance->record($seconds, $rows);

return $instance;
}

public function record(float $seconds, int $rows): void
{
$this->count++;
$this->sum += $seconds;
$this->min = $this->min === null ? $seconds : min($this->min, $seconds);
$this->max = $this->max === null ? $seconds : max($this->max, $seconds);
$this->last = $seconds;
$this->lastRows = $rows;
$this->rowsProcessed += max(0, $rows);
}
}
173 changes: 173 additions & 0 deletions app/Data/Cloning/StatsTableTransferData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

declare(strict_types=1);

namespace App\Data\Cloning;

use Illuminate\Support\Collection;

/**
* Per-table timing container. One instance travels through every chunk
* loop of a single `transferTable()` invocation.
*
* Loops are pushed via `recordLoop(StatsLoopData)`; per-phase running
* aggregates (`selectAggregate` / `transformAggregate` / `insertAggregate`)
* are updated in O(1) so the derived throughput figures never re-scan
* `$loops`.
*/
final class StatsTableTransferData
{
public private(set) ?TableRunPhase $status = null;

/** Wall-clock seconds for each one-shot phase; null until (and unless) it runs. */
public private(set) ?float $countingRowsSeconds = null;

public private(set) ?float $disableFkSeconds = null;

public private(set) ?float $clearTableSeconds = null;

/** @var Collection<int, StatsLoopData> */
public private(set) Collection $loops;

/** @var Collection<int, StatsLoopSnapshotData> */
public private(set) Collection $statsOverTime;

public private(set) int $totalRows = 0;

public private(set) int $rowsDone = 0;

public private(set) int $rowsSkipped = 0;

/** Rows accounted for so far (transferred + skipped). */
public int $rowsProcessed {
get => $this->rowsDone + $this->rowsSkipped;
}

/** Rows still outstanding against `totalRows` (never negative). */
public int $rowsRemaining {
get => max(0, $this->totalRows - $this->rowsProcessed);
}

/** Completion ratio in the range [0, 100], or null when the total is unknown. */
public ?float $percentComplete {
get => $this->totalRows > 0
? min(100.0, ($this->rowsProcessed / $this->totalRows) * 100.0)
: null;
}

/**
* Estimated wall-clock seconds until this table finishes, from the latest
* loop pace × outstanding rows. 0.0 once no rows remain, and 0.0 until a
* row total and at least one completed loop are known.
*/
public float $estimatedSecondsRemaining {
get {
if ($this->rowsRemaining <= 0) {
return 0.0;
}

return $this->loopAggregate->latestPace * $this->rowsRemaining;
}
}

public private(set) StatsPhaseAggregateData $selectAggregate;

public private(set) StatsPhaseAggregateData $transformAggregate;

public private(set) StatsPhaseAggregateData $insertAggregate;

/**
* Per-loop aggregate over each chunk's wall-clock time
* (`StatsLoopData::$overallSeconds`, rows = chunkRows). Useful for
* loop throughput including inter-phase overhead.
*/
public private(set) StatsPhaseAggregateData $loopAggregate;

public function __construct()
{
$this->loops = new Collection;
$this->statsOverTime = new Collection;
$this->selectAggregate = new StatsPhaseAggregateData;
$this->transformAggregate = new StatsPhaseAggregateData;
$this->insertAggregate = new StatsPhaseAggregateData;
$this->loopAggregate = new StatsPhaseAggregateData;
}

public function setStatus(TableRunPhase $status): void
{
$this->status = $status;
}

public function setTotalRows(int $totalRows): void
{
$this->totalRows = max(0, $totalRows);
}

public function recordCountingRows(float $seconds): void
{
$this->countingRowsSeconds = $seconds;
}

public function recordDisableFk(float $seconds): void
{
$this->disableFkSeconds = $seconds;
}

public function recordClearTable(float $seconds): void
{
$this->clearTableSeconds = $seconds;
}

/**
* Append a completed chunk loop, update running aggregates in O(1),
* and append a stats-over-time snapshot.
*/
public function recordLoop(StatsLoopData $loop): void
{
$this->loops->push($loop);

$this->selectAggregate->record($loop->selectSeconds, $loop->chunkRows);
$this->transformAggregate->record($loop->transformSeconds, $loop->chunkRows);
$this->insertAggregate->record($loop->insertSeconds, $loop->chunkRows);
$this->loopAggregate->record($loop->overallSeconds, $loop->chunkRows);

$this->rowsDone += $loop->rowsDone;
$this->rowsSkipped += $loop->rowsSkipped;

$this->statsOverTime->push(new StatsLoopSnapshotData(
loopIndex: $loop->loopIndex,
loopsRecorded: $this->loops->count(),
rowsDoneCumulative: $this->rowsDone,
rowsSkippedCumulative: $this->rowsSkipped,
percentComplete: $this->percentComplete,
selectPacePerMillion: $this->selectAggregate->pacePerMillion,
transformPacePerMillion: $this->transformAggregate->pacePerMillion,
insertPacePerMillion: $this->insertAggregate->pacePerMillion,
loopPacePerMillion: $this->loopAggregate->pacePerMillion,
));
}

public function aggregate(TableRunPhase $phase): StatsPhaseAggregateData
{
return match ($phase) {
TableRunPhase::CountingRows => $this->oneShotAggregate($this->countingRowsSeconds),
TableRunPhase::DisableFkChecks => $this->oneShotAggregate($this->disableFkSeconds),
TableRunPhase::Clear => $this->oneShotAggregate($this->clearTableSeconds),
TableRunPhase::Select => $this->selectAggregate,
TableRunPhase::Transform => $this->transformAggregate,
TableRunPhase::Insert => $this->insertAggregate,
TableRunPhase::Loop => $this->loopAggregate,
};
}

/**
* Wrap a one-shot phase duration as a single-sample aggregate. A phase that
* never ran (null) yields an empty (count-0) aggregate so consumers can skip it.
*/
private function oneShotAggregate(?float $seconds): StatsPhaseAggregateData
{
return $seconds === null
? new StatsPhaseAggregateData
: StatsPhaseAggregateData::withRecord($seconds, $this->totalRows);
}
}
Loading