Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 62 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,68 @@ The command asks for confirmation. Use `--force` to skip the prompt:
php artisan operations:forget 2026_10_02_120000_backfill_company_names --force
```

Forgetting only deletes the completion record. It does not undo previous effects, delete the file, or execute the operation. If the file is present, the next `operations:run` will execute it again. Make sure it is safe to repeat.
Forgetting deletes the completion record and any saved checkpoints. It does not undo previous effects, delete the file, or execute the operation. If the file is present, the next `operations:run` will execute it again from the beginning. Make sure it is safe to repeat.

You can also forget records whose files have been removed. The command fails if no completion record matches the supplied name. Failed operations do not need to be forgotten; they are already pending.
You can also forget records whose files have been removed, or clear checkpoints from an unfinished operation. The command fails if neither a completion record nor checkpoints match the supplied name. Failed operations are already pending; only forget them if you want to discard their saved progress.

### Checkpoints

Checkpoints are optional. Publish and run their migration only if you need to save progress between attempts:

```bash
php artisan vendor:publish --tag=operations-checkpoints-migration
php artisan migrate
```

Add the `DirectoryTree\Operations\Concerns\HasCheckpoints` trait to access the operation's checkpoint repository. Use `get()` to read a value with an optional default, and `put()` to save a value or an array of values:

```php
$cursor = $this->checkpoints->get('cursor');
$lastId = $this->checkpoints->get('last_id', 0);

$this->checkpoints->put('last_id', 15000);

$this->checkpoints->put([
'cursor' => $nextCursor,
'processed' => $processed,
]);
```

Keys are scoped to the operation's filename. Writes update the supplied keys and preserve other saved values. Values are stored as JSON and read back as scalars or arrays; use JSON-compatible values such as strings, numbers, booleans, arrays, and `null`. Saving `null` retains the key, so reading it returns `null` even when a default is provided.

For example, save the last processed ID to resume a backfill:

```php
use App\Models\Company;
use DirectoryTree\Operations\Concerns\HasCheckpoints;
use DirectoryTree\Operations\Operation;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;

return new class extends Operation
{
use HasCheckpoints;

public function handle(Command $command): void
{
Company::query()
->where('id', '>', $this->checkpoints->get('last_id', 0))
->chunkById(1000, function (Collection $companies) {
foreach ($companies as $company) {
$company->update(['display_name' => $company->name]);
}

$this->checkpoints->put('last_id', $companies->last()->id);
});
}
};
```

After a failure, `operations:run` starts `handle()` again. Your code reads the saved checkpoint to decide where to continue. Saving a checkpoint does not mark the operation complete or prevent concurrent execution. Checkpoints remain after completion until the operation is forgotten. You can also call `$this->checkpoints->forget()` to clear all of the operation's checkpoints without changing its completion record.

In the example above, an interrupted chunk may run again. Write work that can safely repeat, or wrap each chunk's database changes and checkpoint write in a transaction on the same connection. Checkpoints use `operations.connection` and participate in its active transaction. With `WithinTransaction`, a failure rolls back all checkpoints written during that attempt along with the operation's database changes. External effects, such as API calls, are not rolled back.

The standard `operations-migrations` tag only publishes the operations table. Running, checking status, and forgetting operations work without the checkpoint table; `operations:forget` clears checkpoints when that table exists. Calling the checkpoint repository's `get()`, `put()`, or `forget()` methods before installing its migration raises a database exception.

### Transactions

Expand Down Expand Up @@ -317,4 +376,4 @@ return [
];
```

`connection` selects the database connection for the operations table and optional transactions. `null` uses the application's default connection. It does not change the connection used by your application models.
`connection` selects the database connection for the operations and checkpoint tables, and optional transactions. `null` uses the application's default connection. It does not change the connection used by your application models.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::connection(config('operations.connection'))->create('operation_checkpoints', function (Blueprint $table) {
$table->id();
$table->string('operation');
$table->string('key');
$table->json('value');
$table->unique(['operation', 'key']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::connection(config('operations.connection'))->dropIfExists('operation_checkpoints');
}
};
72 changes: 72 additions & 0 deletions src/CheckpointRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace DirectoryTree\Operations;

use Illuminate\Database\ConnectionInterface;

class CheckpointRepository
{
/**
* Create a new checkpoint repository.
*/
public function __construct(
protected ConnectionInterface $connection,
protected string $operation
) {}

/**
* Get a checkpoint from the writer connection.
*/
public function get(string $key, mixed $default = null): mixed
{
$value = $this->connection->table('operation_checkpoints')
->useWritePdo()
->where('operation', $this->operation)
->where('key', $key)
->value('value');

return $value === null ? value($default) : json_decode($value, true, flags: JSON_THROW_ON_ERROR);
}

/**
* Persist checkpoint values without replacing other keys.
*
* @param string|array<string, mixed> $key
*/
public function put(string|array $key, mixed $value = null): void
{
$values = is_array($key) ? $key : [$key => $value];

$checkpoints = [];

foreach ($values as $key => $value) {
$checkpoints[] = [
'operation' => $this->operation,
'key' => $key,
'value' => json_encode($value, JSON_THROW_ON_ERROR),
];
}

$this->connection->table('operation_checkpoints')
->upsert($checkpoints, ['operation', 'key'], ['value']);
}

/**
* Determine whether the checkpoint table is installed.
*/
public function installed(): bool
{
return $this->connection->getSchemaBuilder()
->hasTable('operation_checkpoints');
}

/**
* Delete the operation's checkpoints.
*/
public function forget(): bool
{
return $this->connection->table('operation_checkpoints')
->where('operation', $this->operation)
->delete() > 0;
}
}
4 changes: 2 additions & 2 deletions src/Commands/ForgetCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ForgetCommand extends Command
*
* @var string
*/
protected $description = 'Delete the completion record of an operation';
protected $description = 'Delete the completion record and checkpoints of an operation';

/**
* Execute the console command.
Expand All @@ -39,7 +39,7 @@ public function handle(OperationRepository $repository): int
}

if (! $repository->forget($name)) {
$this->components->error("No completion record found for operation [{$name}].");
$this->components->error("No completion record or checkpoints found for operation [{$name}].");

return self::FAILURE;
}
Expand Down
23 changes: 23 additions & 0 deletions src/Concerns/HasCheckpoints.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace DirectoryTree\Operations\Concerns;

use DirectoryTree\Operations\CheckpointRepository;

trait HasCheckpoints
{
/**
* The repository storing the operation's checkpoints.
*/
protected CheckpointRepository $checkpoints;

/**
* Set the operation's checkpoint repository.
*/
public function setCheckpoints(CheckpointRepository $checkpoints): static
{
$this->checkpoints = $checkpoints;

return $this;
}
}
29 changes: 25 additions & 4 deletions src/OperationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace DirectoryTree\Operations;

use DirectoryTree\Operations\Concerns\HasCheckpoints;
use DirectoryTree\Operations\Contracts\WithinTransaction;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
Expand Down Expand Up @@ -29,20 +30,32 @@ public function completed(): Collection
}

/**
* Delete the completion record for the given operation.
* Delete the completion record and checkpoints for the given operation.
*/
public function forget(string $name): bool
{
return $this->connection->table('operations')
->where('name', $name)
->delete() > 0;
return $this->connection->transaction(function () use ($name) {
$deleted = $this->connection->table('operations')
->where('name', $name)
->delete();

$checkpoints = $this->checkpoints($name);

$forgotten = $checkpoints->installed() && $checkpoints->forget();

return $deleted > 0 || $forgotten;
});
}

/**
* Execute an operation and record its successful completion.
*/
public function run(string $name, Operation $operation, Command $command): void
{
if (in_array(HasCheckpoints::class, class_uses_recursive($operation))) {
$operation->setCheckpoints($this->checkpoints($name));
}

$run = function () use ($name, $operation, $command) {
$operation->handle($command);

Expand All @@ -58,4 +71,12 @@ public function run(string $name, Operation $operation, Command $command): void
$run();
}
}

/**
* Create a checkpoint repository for the given operation.
*/
protected function checkpoints(string $name): CheckpointRepository
{
return new CheckpointRepository($this->connection, $name);
}
}
6 changes: 5 additions & 1 deletion src/OperationsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ public function boot(): void
], 'operations-config');

$this->publishesMigrations([
__DIR__.'/../database/migrations' => database_path('migrations'),
__DIR__.'/../database/migrations/2026_10_02_165413_create_operations_table.php' => database_path('migrations/2026_10_02_165413_create_operations_table.php'),
], 'operations-migrations');

$this->publishesMigrations([
__DIR__.'/../database/migrations/2026_10_05_041651_create_operation_checkpoints_table.php' => database_path('migrations/2026_10_05_041651_create_operation_checkpoints_table.php'),
], 'operations-checkpoints-migration');
}
}
}
Loading
Loading