Working through a driver implementation against these contracts, there are a few places where the contracts themselves make correct async behaviour harder than it should be. Grouping them here since they are all contract-level changes that would need a coordinated update.
1. No CancellationToken on query methods
QueryInterface and StreamingQueryInterface have no way to accept a cancellation signal. When an HTTP request times out or a caller wants to abort an in-flight query, there is no path through the contract. Drivers end up either ignoring cancellation entirely or adding driver-specific overloads that break the abstraction.
Adding an optional ?CancellationToken $cancellation = null as the last parameter on query(), execute(), executeGetId(), fetchOne(), fetchValue(), prepare(), and stream() would let structured cancellation flow through without breaking callers that don't use it.
This does pull in hiblaphp/cancellation as a dependency on the contracts package, which is worth discussing. The alternative is leaving cancellation out of the contract entirely and accepting that drivers will handle it inconsistently.
2. CancellableStreamInterface::cancel() returns void
Cancelling a streaming result at the driver level is not a synchronous operation. MySQL requires sending a KILL QUERY (or using server-side cancellation), which is I/O. Returning void means the caller cannot wait for the server to acknowledge the cancellation, and any error from the cancel attempt is silently swallowed.
If cancel fails after a connection reset, the server-side cursor can leak. The connection may also be returned to the pool while the server is still processing rows.
Two options:
- Change
cancel() to return PromiseInterface<void> (clean, breaking change, but worth it while still in beta).
- Add a
cancelAsync(): PromiseInterface<void> alongside the existing void cancel() for callers that need to wait on cancellation.
3. fetchValue() parameter order
public function fetchValue(string $sql, string|int|null $column = null, array $params = []): PromiseInterface;
Every other method on QueryInterface follows ($sql, $params). fetchValue() puts $column between them, which means positional callers have to pass $column before $params:
// Looks right but is wrong - passes column name as the $params array
$client->fetchValue('SELECT name FROM users WHERE id = ?', 'name', [42]);
// Correct but unintuitive order
$client->fetchValue('SELECT name FROM users WHERE id = ?', null, [42]);
Swapping to ($sql, $params, $column) aligns with the rest of the interface and eliminates the ambiguity.
4. MultiResult::nextResult() is synchronous
public function nextResult(): ?self;
Advancing to the next result set in MySQL requires reading the response packet off the wire. That is I/O. A synchronous return type means the driver either has to pre-buffer the entire wire stream upfront (defeating the purpose of having MultiResult as a separate type from Result) or block the event loop while waiting for the next result set header.
PromiseInterface<static|null> would let the driver yield correctly between result sets.
5. bufferSize missing from executeStream()
StreamingQueryInterface::stream() accepts $bufferSize:
public function stream(string $sql, array $params = [], int $bufferSize = 100): PromiseInterface;
But StreamingStatementInterface::executeStream() does not:
public function executeStream(array $params = []): PromiseInterface;
Prepared statements are typically the performance-critical path. Not being able to tune the buffer size there is an odd gap. Adding int $bufferSize = 100 would keep the two streaming interfaces consistent.
6. No retry delay in TransactionOptions
TransactionOptions supports $attempts but has no way to configure a delay between retries. Under concurrent write load, retrying a deadlock immediately can produce another deadlock on the very next attempt if both transactions are racing on the same rows. A small jitter delay (even a fixed 50ms) significantly reduces repeated deadlock collisions.
A withRetryDelay(float $seconds) method, defaulting to 0.0 to preserve current behaviour, would let callers add delay without breaking anything.
Happy to discuss any of these, and open to splitting into separate issues if that is easier to track.
Working through a driver implementation against these contracts, there are a few places where the contracts themselves make correct async behaviour harder than it should be. Grouping them here since they are all contract-level changes that would need a coordinated update.
1. No CancellationToken on query methods
QueryInterfaceandStreamingQueryInterfacehave no way to accept a cancellation signal. When an HTTP request times out or a caller wants to abort an in-flight query, there is no path through the contract. Drivers end up either ignoring cancellation entirely or adding driver-specific overloads that break the abstraction.Adding an optional
?CancellationToken $cancellation = nullas the last parameter onquery(),execute(),executeGetId(),fetchOne(),fetchValue(),prepare(), andstream()would let structured cancellation flow through without breaking callers that don't use it.This does pull in
hiblaphp/cancellationas a dependency on the contracts package, which is worth discussing. The alternative is leaving cancellation out of the contract entirely and accepting that drivers will handle it inconsistently.2.
CancellableStreamInterface::cancel()returns voidCancelling a streaming result at the driver level is not a synchronous operation. MySQL requires sending a KILL QUERY (or using server-side cancellation), which is I/O. Returning
voidmeans the caller cannot wait for the server to acknowledge the cancellation, and any error from the cancel attempt is silently swallowed.If cancel fails after a connection reset, the server-side cursor can leak. The connection may also be returned to the pool while the server is still processing rows.
Two options:
cancel()to returnPromiseInterface<void>(clean, breaking change, but worth it while still in beta).cancelAsync(): PromiseInterface<void>alongside the existingvoid cancel()for callers that need to wait on cancellation.3.
fetchValue()parameter orderEvery other method on
QueryInterfacefollows($sql, $params).fetchValue()puts$columnbetween them, which means positional callers have to pass$columnbefore$params:Swapping to
($sql, $params, $column)aligns with the rest of the interface and eliminates the ambiguity.4.
MultiResult::nextResult()is synchronousAdvancing to the next result set in MySQL requires reading the response packet off the wire. That is I/O. A synchronous return type means the driver either has to pre-buffer the entire wire stream upfront (defeating the purpose of having
MultiResultas a separate type fromResult) or block the event loop while waiting for the next result set header.PromiseInterface<static|null>would let the driver yield correctly between result sets.5.
bufferSizemissing fromexecuteStream()StreamingQueryInterface::stream()accepts$bufferSize:But
StreamingStatementInterface::executeStream()does not:Prepared statements are typically the performance-critical path. Not being able to tune the buffer size there is an odd gap. Adding
int $bufferSize = 100would keep the two streaming interfaces consistent.6. No retry delay in
TransactionOptionsTransactionOptionssupports$attemptsbut has no way to configure a delay between retries. Under concurrent write load, retrying a deadlock immediately can produce another deadlock on the very next attempt if both transactions are racing on the same rows. A small jitter delay (even a fixed 50ms) significantly reduces repeated deadlock collisions.A
withRetryDelay(float $seconds)method, defaulting to0.0to preserve current behaviour, would let callers add delay without breaking anything.Happy to discuss any of these, and open to splitting into separate issues if that is easier to track.