Skip to content

Commit

Permalink
Allow canceling search / sync operations. These are the only types of…
Browse files Browse the repository at this point in the history
… operations that make sense to have special logic for handling a cancellation.

Add a cancellation strategy for search based requests. They can either choose to "continue" to process messages received until the server acknowledges the cancellation, or they can choose to "stop" (the default) and ignore any subsequent messages received from the point of cancellation to the point where the server acknowledges it.

I can see the "stop" strategy being useful in normal search contexts, while a "continue" would likely be desired in a sync operation search (as you would not want to ignore sync data).
  • Loading branch information
ChadSikorra committed Aug 3, 2023
1 parent 226934e commit 5cb6e57
Show file tree
Hide file tree
Showing 15 changed files with 438 additions and 70 deletions.
40 changes: 39 additions & 1 deletion docs/Client/Searching-and-Filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* [Read Search](#read-search)
* [List Search](#list-search)
* [Subtree Search](#subtree-search)
* [Entry Handler Searches](#entry-handler-searches)
* [Entry Handler Searches](#entry-handler-searches)
* [Cancelling a Search](#cancelling-a-search)
* [Sorting](#sorting)
* [Paging](#paging)
* [Paging Criticality](#paging-criticality)
Expand Down Expand Up @@ -125,6 +126,43 @@ $operation->useEntryHandler(function (EntryResult $result) {
$ldap->search($operation);
```

## Cancelling a Search

Canceling a search provides a way to tell the server to stop sending responses and end the operation. This is only
possible with a search when using [entry handlers](#entry-handler-searches). It's also worth considering using [paging](#paging)
instead of resorting to canceling searches. To cancel a search you can throw a `CancelRequestException` during an [entry handler](#entry-handler-searches)
to indicate that the search should stop.

**Note**: Cancelling a search is dependent on the server supporting the cancellation of an operation. Additionally, the server
may have already responded with all entries and it may be too late to cancel said operation. In this case, an `OperationException`
will be thrown.

```php
use FreeDSx\Ldap\Exception\CancelRequestException;
use FreeDSx\Ldap\Operations;
use FreeDSx\Ldap\Operation\Response\SearchResponse;
use FreeDSx\Ldap\Search\Result\EntryResult;
use FreeDSx\Ldap\Search\Filters;

$operation = Operations::search(
Filters::equal('objectClass', 'user'),
'cn',
);

# Add a closure that will process the entries as they arrive during the search.
$operation->useEntryHandler(function (EntryResult $result) {
$entry = $result->getEntry();

// Add some conditional logic for why to cancel here...
// ...
// You must provide the message ID of the operation you're cancelling in the exception.
throw new CancelRequestException($result->getMessage()->getMessageId())
});

/** @var SearchResponse $response */
$response = $ldap->sendAndReceive($operation);
```

## Sorting

You can sort searches by a specific attribute, or set of attributes, and a direction by using a server side sort control.
Expand Down
14 changes: 14 additions & 0 deletions src/FreeDSx/Ldap/Exception/CancelRequestException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace FreeDSx\Ldap\Exception;

use Exception;

/**
* Thrown during a client operation to indicate that the current operation should be canceled.
*/
class CancelRequestException extends Exception
{
}
50 changes: 48 additions & 2 deletions src/FreeDSx/Ldap/Operation/Request/SearchRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Exception\ProtocolException;
use FreeDSx\Ldap\Exception\RuntimeException;
use FreeDSx\Ldap\Exception\UnexpectedValueException;
use FreeDSx\Ldap\Protocol\Factory\FilterFactory;
use FreeDSx\Ldap\Search\Filter\FilterInterface;
use function array_map;
Expand Down Expand Up @@ -56,6 +57,17 @@ class SearchRequest implements RequestInterface
{
use IntermediateResponseHandlerTrait;

/**
* Subsequent search messages received after the cancellation are ignored as soon as the cancel request is made.
*/
public const CANCEL_STOP = 'stop';

/**
* Subsequent search messages received after the cancellation continue to process until the server cancels
* the request.
*/
public const CANCEL_CONTINUE = 'continue';

/**
* Searches a scope of a single object (IE. a specific DN)
*/
Expand Down Expand Up @@ -111,6 +123,11 @@ class SearchRequest implements RequestInterface

private ?Closure $referralHandler = null;

/**
* @phpstan-var 'continue'|'stop'
*/
private string $cancelStrategy = self::CANCEL_STOP;

public function __construct(
private FilterInterface $filter,
Attribute|string ...$attributes
Expand Down Expand Up @@ -316,6 +333,36 @@ public function useReferralHandler(?Closure $referralHandler): self
return $this;
}

/**
* @phpstan-param 'continue'|'stop' $strategy
*/
public function useCancelStrategy(string $strategy): self
{
if ($strategy !== self::CANCEL_STOP && $strategy!== self::CANCEL_CONTINUE) {
throw new UnexpectedValueException(sprintf(
'The cancel strategy must be one of: %s',
implode(
', ',
[
self::CANCEL_CONTINUE,
self::CANCEL_STOP,
]
)
));
}
$this->cancelStrategy = $strategy;

return $this;
}

/**
* @phpstan-return 'continue'|'stop'
*/
public function getCancelStrategy(): string
{
return $this->cancelStrategy;
}

/**
* {@inheritDoc}
*
Expand All @@ -340,8 +387,7 @@ public static function fromAsn1(AbstractType $type): self
}
$filter = FilterFactory::get($filter);

if (
!($baseDn instanceof OctetStringType
if (!($baseDn instanceof OctetStringType
&& $scope instanceof EnumeratedType
&& $deref instanceof EnumeratedType
&& $sizeLimit instanceof IntegerType
Expand Down
8 changes: 4 additions & 4 deletions src/FreeDSx/Ldap/Operation/Response/SearchResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ public function __construct(
private readonly array $referralResults = [],
) {
parent::__construct(
$result->resultCode,
$result->dn->toString(),
$result->diagnosticMessage,
...$result->referrals
$result->getResultCode(),
$result->getDn()->toString(),
$result->getDiagnosticMessage(),
...$result->getReferrals()
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ClientBasicHandler implements RequestHandlerInterface, ResponseHandlerInte
ResultCode::COMPARE_TRUE,
ResultCode::REFERRAL,
ResultCode::SASL_BIND_IN_PROGRESS,
ResultCode::CANCELED,
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@

namespace FreeDSx\Ldap\Protocol\ClientProtocolHandler;

use Closure;
use FreeDSx\Ldap\Exception\CancelRequestException;
use FreeDSx\Ldap\Operation\LdapResult;
use FreeDSx\Ldap\Operation\Request\SearchRequest;
use FreeDSx\Ldap\Operation\Response\ExtendedResponse;
use FreeDSx\Ldap\Operation\Response\IntermediateResponse;
use FreeDSx\Ldap\Operation\Response\SearchResponse;
use FreeDSx\Ldap\Operation\Response\SearchResultDone;
Expand All @@ -27,39 +31,58 @@

trait ClientSearchTrait
{
private static function search(
private RequestCanceler $requestCanceler;

private Closure $entryHandler;

private Closure $referralHandler;

private ?Closure $intermediateHandler = null;

private bool $wasCancelHandled = false;

private ?ExtendedResponse $canceledResponse = null;

private function search(
LdapMessageResponse $messageFrom,
LdapMessageRequest $messageTo,
ClientQueue $queue,
): LdapMessageResponse {
/** @var SearchRequest $searchRequest */
$searchRequest = $messageTo->getRequest();

$cancelled = null;
$entryResults = [];
$referralResults = [];

$entryHandler = $searchRequest->getEntryHandler() ??
$this->requestCanceler = new RequestCanceler(
queue: $queue,
strategy: $searchRequest->getCancelStrategy(),
messageProcessor: $this->processSearchMessage(...),
);

$this->entryHandler = $searchRequest->getEntryHandler() ??
function (EntryResult $result) use (&$entryResults): void {
$entryResults[] = $result;
};
$referralHandler = $searchRequest->getReferralHandler() ??
$this->referralHandler = $searchRequest->getReferralHandler() ??
function (ReferralResult $result) use (&$referralResults): void {
$referralResults[] = $result;
};
$intermediateHandler = $searchRequest->getIntermediateResponseHandler();
$this->intermediateHandler = $searchRequest->getIntermediateResponseHandler();

while (!$messageFrom->getResponse() instanceof SearchResultDone) {
$response = $messageFrom->getResponse();

if ($response instanceof SearchResultEntry) {
$entryHandler(new EntryResult($messageFrom));
} elseif ($response instanceof SearchResultReference) {
$referralHandler(new ReferralResult($messageFrom));
} elseif ($response instanceof IntermediateResponse && $intermediateHandler) {
$intermediateHandler($messageFrom);
/** @var LdapResult $response */
$response = $messageFrom->getResponse();
while (!$response instanceof SearchResultDone) {
try {
$this->processSearchMessage($messageFrom);
} catch (CancelRequestException) {
break;
}

$messageFrom = $queue->getMessage($messageTo->getMessageId());
/** @var LdapResult $response */
$response = $messageFrom->getResponse();
}

// This is just to use less logic to account whether a handler was used / no search results were returned.
Expand All @@ -68,12 +91,35 @@ function (ReferralResult $result) use (&$referralResults): void {
return new LdapMessageResponse(
$messageFrom->getMessageId(),
new SearchResponse(
$messageFrom->getResponse(),
$this->canceledResponse ?? $response,
$entryResults,
$referralResults,
),
...$messageFrom->controls()
->toArray()
...$messageFrom->controls()->toArray()
);
}

private function processSearchMessage(LdapMessageResponse $messageFrom): void
{
$response = $messageFrom->getResponse();

try {
if ($response instanceof SearchResultEntry) {
($this->entryHandler)(new EntryResult($messageFrom));
} elseif ($response instanceof SearchResultReference) {
($this->referralHandler)(new ReferralResult($messageFrom));
} elseif ($response instanceof IntermediateResponse && $this->intermediateHandler) {
($this->intermediateHandler)($messageFrom);
}
} catch (CancelRequestException $cancelException) {
// If the strategy is "continue", we only handle the first cancellation exception.
// The entry handler may continue to throw it, but we will only send one cancel request.
if (!$this->wasCancelHandled) {
$this->wasCancelHandled = true;
$this->canceledResponse = $this->requestCanceler->cancel($messageFrom->getMessageId());

throw $cancelException;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ private function isSyncComplete(LdapMessageResponse $response): bool
}
$this->updateCookie($syncDone->getCookie());

return $result->getResultCode() === ResultCode::SUCCESS;
return $result->getResultCode() === ResultCode::SUCCESS
|| $result->getResultCode() === ResultCode::CANCELED;
}

private function isRefreshRequired(LdapMessageResponse $response): bool
Expand Down
Loading

0 comments on commit 5cb6e57

Please sign in to comment.