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
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,16 @@ private function filteredEntryStream(
continue;
}

yield $projection->project($filtered);
$emitted++;
$state->entriesReturned++;

// A further match past the cap proves overflow: signal without emitting it.
if ($sizeLimit > 0 && $emitted >= $sizeLimit) {
$state->resultCode = ResultCode::SIZE_LIMIT_EXCEEDED;

return;
}

yield $projection->project($filtered);
$emitted++;
$state->entriesReturned++;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ public function list(StorageListOptions $options): EntryStream

$isPreFiltered = $filterResult !== null && $filterResult->isExact;

// Exact is bound by sizeLimit. Inexact is PHP re-evaluated: cap candidate transfer at lookthrough+1.
// Exact is bound by the client sizeLimit; otherwise cap candidate transfer at lookthrough+1.
$sqlLimit = match (true) {
$isPreFiltered && $options->sizeLimit > 0 => $options->sizeLimit,
!$isPreFiltered && $options->lookthroughLimit > 0 => $options->lookthroughLimit + 1,
$options->lookthroughLimit > 0 => $options->lookthroughLimit + 1,
default => null,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,19 @@
*/
final class OperationRollupCoordinator
{
/**
* Batch sends by op count and elapsed time so a fast workload does not backpressure on the blocking channel.
*/
private const FLUSH_OPS = 256;

private const FLUSH_INTERVAL_SECONDS = 0.1;

private ?ChildChannel $boundChannel = null;

private int $opsSinceSend = 0;

private float $lastSendAt = 0.0;

public function __construct(
private readonly MetricsRollupInterface $recorder,
private readonly ChannelMessageFactory $messageFactory = new MetricsDeltaMessageFactory(),
Expand All @@ -42,22 +53,28 @@ public function enterChild(ChildChannel $channel): void
{
$this->recorder->resetDelta();
$this->boundChannel = $channel;
$this->opsSinceSend = 0;
$this->lastSendAt = microtime(true);
}

/**
* In the child: report the metrics recorded since the last flush.
* In the child: report accumulated metrics once the batch reaches its op count or time bound.
*/
public function flush(): void
{
$this->boundChannel?->send(new MetricsDeltaMessage($this->recorder->takeDelta()));
$this->opsSinceSend++;

if ($this->batchIsReady()) {
$this->sendDelta();
}
}

/**
* In the child: send a final flush, then close the write end so the parent reads EOF.
* In the child: send the final accumulated delta, then close the write end so the parent reads EOF.
*/
public function finish(): void
{
$this->flush();
$this->sendDelta();
$this->boundChannel?->closeWrite();
}

Expand All @@ -72,4 +89,20 @@ public function collect(ChildChannel $channel): void
}
}
}

/**
* Whether the accumulated batch has reached its op-count or time bound and should be sent.
*/
private function batchIsReady(): bool
{
return $this->opsSinceSend >= self::FLUSH_OPS
|| (microtime(true) - $this->lastSendAt) >= self::FLUSH_INTERVAL_SECONDS;
}

private function sendDelta(): void
{
$this->boundChannel?->send(new MetricsDeltaMessage($this->recorder->takeDelta()));
$this->opsSinceSend = 0;
$this->lastSendAt = microtime(true);
}
}
13 changes: 12 additions & 1 deletion tests/performance/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ final class Config

public const DEFAULT_SEARCH_SUB_SIZE_LIMIT = 500;

/**
* Mirrors the realistic ServerOptions default so load tests run bounded rather than with lookthrough disabled.
*/
public const DEFAULT_MAX_SEARCH_LOOKTHROUGH = 5000;

public function __construct(
public readonly string $backend,
public readonly string $runner,
Expand All @@ -86,7 +91,11 @@ public function __construct(
public readonly string $writeBase = self::DEFAULT_WRITE_BASE,
public readonly bool $jit = true,
public readonly int $searchSubSizeLimit = self::DEFAULT_SEARCH_SUB_SIZE_LIMIT,
public readonly bool $monitor = false,
public readonly bool $monitor = true,
public readonly ?string $searchAttributes = null,
public readonly bool $attributesOnly = false,
public readonly int $seedAttributes = 0,
public readonly int $maxSearchLookthrough = self::DEFAULT_MAX_SEARCH_LOOKTHROUGH,
) {
$this->assertEnum('backend', $backend, self::BACKENDS);
$this->assertEnum('runner', $runner, self::RUNNERS);
Expand All @@ -97,6 +106,8 @@ public function __construct(
$this->assertNonNegative('warmup', $warmup);
$this->assertNonNegative('seed-entries', $seedEntries);
$this->assertNonNegative('search-sub-size-limit', $searchSubSizeLimit);
$this->assertNonNegative('seed-attributes', $seedAttributes);
$this->assertNonNegative('max-search-lookthrough', $maxSearchLookthrough);

if ($duration !== null) {
$this->assertPositive('duration', $duration);
Expand Down
61 changes: 55 additions & 6 deletions tests/performance/LoadTestCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use FreeDSx\Ldap\Search\Filters;
use InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
Expand Down Expand Up @@ -202,15 +203,42 @@ protected function configure(): void
->addOption(
'monitor',
null,
InputOption::VALUE_NONE,
'Enable cn=monitor on the spawned server and print its counters after the run for cross-checking.',
InputOption::VALUE_NEGATABLE,
'Print server-side cn=monitor counters after the run (on by default; use --no-monitor to disable).',
true,
)
->addOption(
'search-sub-size-limit',
null,
InputOption::VALUE_REQUIRED,
'Per-request size limit applied to search-sub ops (0 = unlimited)',
(string) Config::DEFAULT_SEARCH_SUB_SIZE_LIMIT,
)
->addOption(
'search-attributes',
null,
InputOption::VALUE_REQUIRED,
'Attributes requested by search ops: a CSV list, ALL (default), or 1.1 for no attributes.',
)
->addOption(
'attributes-only',
null,
InputOption::VALUE_NONE,
'Request attribute descriptions without values (attributesOnly) on search ops.',
)
->addOption(
'seed-attributes',
null,
InputOption::VALUE_REQUIRED,
'Filler attributes added to each seeded entry to widen the return path (0 = none).',
'0',
)
->addOption(
'max-search-lookthrough',
null,
InputOption::VALUE_REQUIRED,
'Max entries examined per search before adminLimitExceeded (0 = unbounded; default mirrors the server).',
(string) Config::DEFAULT_MAX_SEARCH_LOOKTHROUGH,
);
}

Expand Down Expand Up @@ -349,6 +377,7 @@ private function renderMonitorEntry(
'operationsCompleted',
'operationsFailed',
'operationsByType',
'operationsAvgLatencyMsByType',
'operationsByResultCode',
'bindsByMethod',
'searchesByScope',
Expand All @@ -357,17 +386,24 @@ private function renderMonitorEntry(
'trafficBytesReceived',
'trafficEntriesReturned',
];

$table = new Table($output);
$table->setHeaders([
'Stat',
'Value',
]);
foreach ($attributes as $name) {
if (!isset($entry[$name])) {
continue;
}

$output->writeln(sprintf(
' %s: %s',
// Multi-valued stats (per-op counts/latencies) read better one pair per line in the cell.
$table->addRow([
$name,
implode(', ', $entry[$name]),
));
implode("\n", $entry[$name]),
]);
}
$table->render();
}

private function buildConfig(InputInterface $input): Config
Expand Down Expand Up @@ -410,9 +446,22 @@ private function buildConfig(InputInterface $input): Config
jit: !(bool) $input->getOption('no-jit') && $runner !== 'pcntl',
searchSubSizeLimit: $this->requireInt($input, 'search-sub-size-limit'),
monitor: (bool) $input->getOption('monitor'),
searchAttributes: $this->optionalString($input, 'search-attributes'),
attributesOnly: (bool) $input->getOption('attributes-only'),
seedAttributes: $this->requireInt($input, 'seed-attributes'),
maxSearchLookthrough: $this->requireInt($input, 'max-search-lookthrough'),
);
}

private function optionalString(InputInterface $input, string $name): ?string
{
$value = $input->getOption($name);

return is_string($value) && $value !== ''
? $value
: null;
}

private function requireString(InputInterface $input, string $name): string
{
$value = $input->getOption($name);
Expand Down
2 changes: 2 additions & 0 deletions tests/performance/Server/ServerManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public function start(): void
$command[] = '--runner=' . $this->config->runner;
$command[] = '--port=' . $this->config->port;
$command[] = '--seed-entries=' . $this->config->seedEntries;
$command[] = '--seed-attributes=' . $this->config->seedAttributes;
$command[] = '--max-search-lookthrough=' . $this->config->maxSearchLookthrough;

if ($this->config->monitor) {
$command[] = '--monitor';
Expand Down
46 changes: 36 additions & 10 deletions tests/performance/Workload/Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Exception\OperationException;
use FreeDSx\Ldap\LdapClient;
use FreeDSx\Ldap\Operation\Request\SearchRequest;
use FreeDSx\Ldap\Operation\ResultCode;
use FreeDSx\Ldap\Operations;
use FreeDSx\Ldap\Search\Filter\FilterInterface;
Expand Down Expand Up @@ -197,7 +198,7 @@ private function doBind(LdapClient $client): void

private function doSearchRead(LdapClient $client): void
{
$request = Operations::search(Filters::present('objectClass'))
$request = $this->newSearch(Filters::present('objectClass'))
->base($this->randomReadDn())
->useBaseScope();

Expand All @@ -206,7 +207,7 @@ private function doSearchRead(LdapClient $client): void

private function doSearchEq(LdapClient $client): void
{
$request = Operations::search($this->randomEqualityFilter())
$request = $this->newSearch($this->randomEqualityFilter())
->base($this->config->baseDn)
->useSubtreeScope();

Expand All @@ -219,7 +220,7 @@ private function doSearchSub(LdapClient $client): void
? Filters::startsWith('cn', 'seed-')
: Filters::startsWith('cn', '');

$request = Operations::search($filter)
$request = $this->newSearch($filter)
->base($this->config->baseDn)
->useSubtreeScope();

Expand All @@ -232,9 +233,9 @@ private function doSearchSub(LdapClient $client): void

private function doSearchList(LdapClient $client): void
{
$request = Operations::search(Filters::equal('objectClass', 'inetOrgPerson'))
$request = $this->newSearch(Filters::equal('objectClass', 'inetOrgPerson'))
->base($this->config->writeBase)
->useSubtreeScope();
->useSingleLevelScope();

$client->search($request);
}
Expand All @@ -246,7 +247,7 @@ private function doSearchSubstr(LdapClient $client): void
: Filters::contains('cn', 'eed');

$client->search(
Operations::search($filter)
$this->newSearch($filter)
->base($this->config->baseDn)
->useSubtreeScope(),
);
Expand All @@ -259,7 +260,7 @@ private function doSearchSuffix(LdapClient $client): void
: Filters::endsWith('cn', 'e');

$client->search(
Operations::search($filter)
$this->newSearch($filter)
->base($this->config->baseDn)
->useSubtreeScope(),
);
Expand All @@ -272,7 +273,7 @@ private function doSearchRange(LdapClient $client): void
: 1000;

$client->search(
Operations::search(Filters::greaterThanOrEqual('uidNumber', (string) $threshold))
$this->newSearch(Filters::greaterThanOrEqual('uidNumber', (string) $threshold))
->base($this->config->baseDn)
->useSubtreeScope(),
);
Expand All @@ -289,7 +290,7 @@ private function doSearchAnd(LdapClient $client): void
);

$client->search(
Operations::search($filter)
$this->newSearch($filter)
->base($this->config->baseDn)
->useSubtreeScope(),
);
Expand All @@ -306,12 +307,37 @@ private function doSearchOr(LdapClient $client): void
);

$client->search(
Operations::search($filter)
$this->newSearch($filter)
->base($this->config->baseDn)
->useSubtreeScope(),
);
}

/**
* Builds a search request, applying the configured attribute selection so the return path can be varied.
*/
private function newSearch(FilterInterface $filter): SearchRequest
{
$request = Operations::search($filter);
$attributes = $this->config->searchAttributes;

if ($attributes !== null && strcasecmp($attributes, 'ALL') !== 0) {
$request->select(...array_map(
'trim',
explode(
',',
$attributes,
),
));
}

if ($this->config->attributesOnly) {
$request->setAttributesOnly(true);
}

return $request;
}

private function randomSeedIdx(): int
{
return mt_rand(
Expand Down
Loading
Loading