Skip to content

Commit af74b91

Browse files
authored
Merge pull request #2599 from flow-php/transformation-loader-bug
Branching Loader - aggregating / sorting / grouping transformations bug
2 parents afe4309 + 9b6257d commit af74b91

77 files changed

Lines changed: 4806 additions & 393 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Benchmarks\Service\Doctrine;
6+
7+
use Flow\Benchmarks\Datasets\Datasets;
8+
9+
use function Flow\ETL\Adapter\Doctrine\to_dbal_table_insert;
10+
use function Flow\ETL\DSL\batches;
11+
use function Flow\ETL\DSL\data_frame;
12+
use function Flow\ETL\DSL\write_with_retries;
13+
use function Flow\Floe\DSL\from_floe;
14+
15+
/**
16+
* Rows are re-batched to one per Rows because a floe file arrives pre-chunked, which would hide the
17+
* BatchSizeOptimization that from_csv()/from_json() sources trigger.
18+
*/
19+
final readonly class DoctrineWrappedWriteScenario
20+
{
21+
public function __construct(
22+
private int $rows,
23+
) {}
24+
25+
public function table(): string
26+
{
27+
return 'benchmark_orders_dbal_wrapped_write_' . $this->rows;
28+
}
29+
30+
public function setUp(): void
31+
{
32+
Datasets::orders($this->rows)->floe();
33+
34+
$connection = DoctrineConnection::open();
35+
DoctrineConnection::dropTable($connection, $this->table());
36+
DoctrineConnection::createTable($connection, $this->table());
37+
$connection->close();
38+
}
39+
40+
public function run(): void
41+
{
42+
$connection = DoctrineConnection::open();
43+
44+
data_frame()
45+
->read(batches(from_floe(Datasets::orders($this->rows)->floe()), 1))
46+
->write(write_with_retries(to_dbal_table_insert($connection, $this->table())))
47+
->run();
48+
49+
$connection->close();
50+
}
51+
52+
public function dropTable(): void
53+
{
54+
$connection = DoctrineConnection::open();
55+
DoctrineConnection::dropTable($connection, $this->table());
56+
$connection->close();
57+
}
58+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Benchmarks\Transformation;
6+
7+
use Flow\Benchmarks\BenchmarkConfig;
8+
use Flow\Benchmarks\Datasets\Datasets;
9+
use Flow\ETL\Loader;
10+
11+
use function Flow\ETL\DSL\data_frame;
12+
use function Flow\Floe\DSL\from_floe;
13+
14+
final readonly class NestedTransformationScenario
15+
{
16+
public function __construct(
17+
private int $rows,
18+
private Loader $loader,
19+
) {}
20+
21+
public function run(): void
22+
{
23+
data_frame(BenchmarkConfig::builder())
24+
->read(from_floe(Datasets::orders($this->rows)->floe()))
25+
->batchSize(1000)
26+
->write($this->loader)
27+
->run();
28+
}
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Benchmarks\Service\Doctrine;
6+
7+
use Generator;
8+
use PhpBench\Attributes as Bench;
9+
10+
#[Bench\AfterClassMethods('dropWrappedWrite')]
11+
final class DoctrineWrappedWriteBench
12+
{
13+
public function setUpWrappedWrite(array $params): void
14+
{
15+
(new DoctrineWrappedWriteScenario((int) $params['rows']))->setUp();
16+
}
17+
18+
public static function dropWrappedWrite(): void
19+
{
20+
(new DoctrineWrappedWriteScenario(100_000))->dropTable();
21+
22+
(new DoctrineWrappedWriteScenario((int) (getenv('FLOW_BENCH_ROWS') ?: 100_000)))->dropTable();
23+
}
24+
25+
#[Bench\ParamProviders('rows')]
26+
#[Bench\Groups(['service', 'service-doctrine'])]
27+
#[Bench\BeforeMethods('setUpWrappedWrite')]
28+
public function bench_doctrine_wrapped_write(array $params): void
29+
{
30+
(new DoctrineWrappedWriteScenario((int) $params['rows']))->run();
31+
}
32+
33+
public function rows(): Generator
34+
{
35+
$rows = (int) (getenv('FLOW_BENCH_ROWS') ?: 100_000);
36+
37+
yield number_format($rows) => ['rows' => $rows];
38+
}
39+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Benchmarks\Transformation;
6+
7+
use Flow\ETL\DataFrame;
8+
use Flow\ETL\FlowContext;
9+
use Flow\ETL\Rows;
10+
use Flow\ETL\Transformation;
11+
use Generator;
12+
use PhpBench\Attributes as Bench;
13+
14+
use function Flow\ETL\DSL\ref;
15+
use function Flow\ETL\DSL\select;
16+
use function Flow\ETL\DSL\to_branch;
17+
use function Flow\ETL\DSL\to_callable;
18+
use function Flow\ETL\DSL\to_transformation;
19+
20+
final class NestedTransformationBench
21+
{
22+
#[Bench\ParamProviders('rows')]
23+
#[Bench\Groups(['transformation'])]
24+
public function bench_blocking_transformation(array $params): void
25+
{
26+
$loader = to_transformation(
27+
new class implements Transformation {
28+
public function transform(DataFrame $dataFrame): DataFrame
29+
{
30+
return $dataFrame->sortBy(ref('created_at'));
31+
}
32+
},
33+
to_callable(static function (Rows $rows, FlowContext $context): void {}),
34+
);
35+
36+
(new NestedTransformationScenario((int) $params['rows'], $loader))->run();
37+
}
38+
39+
#[Bench\ParamProviders('rows')]
40+
#[Bench\Groups(['transformation'])]
41+
public function bench_branch_with_transformation(array $params): void
42+
{
43+
$loader = to_branch(
44+
ref('order_id')->isNotNull(),
45+
to_callable(static function (Rows $rows, FlowContext $context): void {}),
46+
)->withTransformation(new class implements Transformation {
47+
public function transform(DataFrame $dataFrame): DataFrame
48+
{
49+
return $dataFrame->sortBy(ref('created_at'));
50+
}
51+
});
52+
53+
(new NestedTransformationScenario((int) $params['rows'], $loader))->run();
54+
}
55+
56+
#[Bench\ParamProviders('rows')]
57+
#[Bench\Groups(['transformation'])]
58+
public function bench_streaming_transformation(array $params): void
59+
{
60+
$loader = to_transformation(
61+
select('order_id'),
62+
to_callable(static function (Rows $rows, FlowContext $context): void {}),
63+
);
64+
65+
(new NestedTransformationScenario((int) $params['rows'], $loader))->run();
66+
}
67+
68+
public function rows(): Generator
69+
{
70+
$rows = (int) (getenv('FLOW_BENCH_ROWS') ?: 100_000);
71+
72+
yield number_format($rows) => ['rows' => $rows];
73+
}
74+
}

bin/docs.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public function execute(InputInterface $input, OutputInterface $output): int
6767
__DIR__ . '/../src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/DSL/functions.php',
6868
__DIR__ . '/../src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/functions.php',
6969
__DIR__ . '/../src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/functions.php',
70+
__DIR__ . '/../src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/functions.php',
7071
__DIR__ . '/../src/adapter/etl-adapter-seal/src/Flow/ETL/Adapter/Seal/functions.php',
7172
__DIR__ . '/../src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/functions.php',
7273
__DIR__ . '/../src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/functions.php',

documentation/components/adapters/doctrine.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,50 @@ data_frame()
121121
// XML entries are automatically converted to strings before database insertion
122122
```
123123

124+
## Transactional Loading
125+
126+
`to_dbal_transaction()` wraps one or more loaders so every delivery happens inside a transaction: each batch of rows
127+
is loaded in its own transaction, and if any loader throws, the open transaction is rolled back:
128+
129+
```php
130+
use function Flow\ETL\DSL\{data_frame, from_array};
131+
use function Flow\ETL\Adapter\Doctrine\{to_dbal_table_insert, to_dbal_transaction};
132+
133+
data_frame()
134+
->read(from_array($data))
135+
->write(to_dbal_transaction(
136+
$connection,
137+
to_dbal_table_insert($connection, 'users'),
138+
to_dbal_table_insert($connection, 'users_audit'),
139+
))
140+
->run();
141+
```
142+
143+
Atomicity requires every wrapped loader to use the same connection as the wrapper - pass one live `Connection` to
144+
`to_dbal_transaction()` and to every wrapped loader. A loader built from array params (like
145+
`to_dbal_table_insert(['url' => $url], 'users')`) opens its own connection and escapes the transaction.
146+
147+
Wrapped `to_transformation()` / `to_branch(...)->withTransformation(...)` steps with blocking operations (`sortBy()`,
148+
`aggregate()`, `groupBy()->aggregate()`, `pivot()`, window functions, `collect()`, `join()` - see
149+
[transformations](../core/transformations.md)) buffer the stream and deliver it when the pipeline closes the loader;
150+
`to_dbal_transaction()` opens one final transaction around that delivery - the whole drained stream commits
151+
atomically, a failure during it rolls back.
152+
153+
Do not place `write_with_retries()` inside the wrapper: on databases that abort the transaction after a failed
154+
statement (PostgreSQL), every retry attempt fails too. Wrap the transaction instead -
155+
`write_with_retries(to_dbal_transaction(...))` gives each attempt a fresh transaction (see
156+
[retry](../core/retry.md)).
157+
158+
Use `withIsolationLevel()` to set the transaction isolation level; it applies to every transaction the wrapper opens,
159+
including the final one:
160+
161+
```php
162+
use Doctrine\DBAL\TransactionIsolationLevel;
163+
164+
to_dbal_transaction($connection, to_dbal_table_insert($connection, 'users'))
165+
->withIsolationLevel(TransactionIsolationLevel::SERIALIZABLE);
166+
```
167+
124168
## Extractor - DbalQuery
125169

126170
This simple but powerful extractor let you extract data from a single or multiple parametrized queries.

documentation/components/adapters/postgresql.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,8 @@ df()
394394

395395
### Transactional Loading
396396

397-
`to_pgsql_transaction()` wraps one or more loaders so each batch of rows is loaded inside a single transaction. If any
398-
loader throws, the whole batch is rolled back:
397+
`to_pgsql_transaction()` wraps one or more loaders so every delivery happens inside a transaction: each batch of rows
398+
is loaded in its own transaction, and if any loader throws, the open transaction is rolled back:
399399

400400
```php
401401
use Flow\PostgreSql\QueryBuilder\Transaction\IsolationLevel;
@@ -412,7 +412,20 @@ df()
412412
->run();
413413
```
414414

415-
Use `withIsolationLevel()` to set the transaction isolation level:
415+
Wrapped `to_transformation()` / `to_branch(...)->withTransformation(...)` steps with blocking operations (`sortBy()`,
416+
`aggregate()`, `groupBy()->aggregate()`, `pivot()`, window functions, `collect()`, `join()` - see
417+
[transformations](../core/transformations.md)) buffer the stream and deliver it when the pipeline closes the loader;
418+
`to_pgsql_transaction()` opens one final transaction around that delivery - the whole drained stream commits
419+
atomically, a failure during it rolls back. Every wrapped loader must use the same `Client` instance as the wrapper -
420+
a loader holding its own `Client` escapes the transaction.
421+
422+
Do not place `write_with_retries()` inside the wrapper: after a failed statement PostgreSQL aborts the whole
423+
transaction, so every retry attempt fails too. Wrap the transaction instead -
424+
`write_with_retries(to_pgsql_transaction(...))` gives each attempt a fresh transaction (see
425+
[retry](../core/retry.md)).
426+
427+
Use `withIsolationLevel()` to set the transaction isolation level; it applies to every transaction the wrapper opens,
428+
including the final one:
416429

417430
```php
418431
to_pgsql_transaction($client, to_pgsql_table($client, 'users'))
@@ -421,13 +434,13 @@ to_pgsql_transaction($client, to_pgsql_table($client, 'users'))
421434

422435
## Loader DSL Functions Reference
423436

424-
| Function | Description |
425-
|------------------------------------------------|-----------------------------------------------------|
426-
| `to_pgsql_table($client, $table)` | Create a PostgreSQL loader for a table |
427-
| `to_pgsql_transaction($client, ...$loaders)` | Run multiple loaders within a single transaction |
428-
| `pgsql_insert_options(...)` | Configure insert behavior (conflicts, upsert) |
429-
| `pgsql_update_options($primaryKeys)` | Configure update behavior (primary key columns) |
430-
| `pgsql_delete_options($primaryKeys)` | Configure delete behavior (primary key columns) |
437+
| Function | Description |
438+
|------------------------------------------------|-----------------------------------------------------------|
439+
| `to_pgsql_table($client, $table)` | Create a PostgreSQL loader for a table |
440+
| `to_pgsql_transaction($client, ...$loaders)` | Run multiple loaders, every delivery inside a transaction |
441+
| `pgsql_insert_options(...)` | Configure insert behavior (conflicts, upsert) |
442+
| `pgsql_update_options($primaryKeys)` | Configure update behavior (primary key columns) |
443+
| `pgsql_delete_options($primaryKeys)` | Configure delete behavior (primary key columns) |
431444

432445
## Schema Conversion
433446

0 commit comments

Comments
 (0)