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
22 changes: 16 additions & 6 deletions lib/Controller/OrganisationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use OCA\OpenRegister\Db\OrganisationMapper;
use OCA\OpenRegister\Db\TenantUsageMapper;
use OCA\OpenRegister\Service\OrganisationService;
use OCA\OpenRegister\Service\OrganisationUsageService;
use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCA\OpenRegister\Service\TenantLifecycleService;
use OCP\AppFramework\Controller;
Expand Down Expand Up @@ -127,6 +128,9 @@ class OrganisationController extends Controller {
* @param TenantUsageMapper $tenantUsageMapper Usage mapper
* @param IUserSession $userSession User session for authorization checks
* @param IGroupManager $groupManager Group manager for admin checks
* @param OrganisationUsageService $usageService Measures an organisation's usage
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList) DI constructor: each argument is a collaborator an endpoint uses.
*
* @spec openspec/specs/tenant-lifecycle/spec.md
*/
Expand All @@ -140,6 +144,7 @@ public function __construct(
TenantUsageMapper $tenantUsageMapper,
IUserSession $userSession,
IGroupManager $groupManager,
private readonly OrganisationUsageService $usageService,
) {
parent::__construct(appName: $appName, request: $request);
$this->organisationService = $organisationService;
Expand Down Expand Up @@ -262,7 +267,7 @@ public function index(): JSONResponse {
* active: bool|null, parent: null|string, children: array,
* quota: array{storage: int|null, bandwidth: int|null,
* requests: int|null, users: null, groups: null},
* usage: array{storage: 0, bandwidth: 0, requests: 0,
* usage: array{storage: null, bandwidth: null, requests: null,
* users: int<0, max>, groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string}|null}, array<never, never>>
*
Expand Down Expand Up @@ -332,7 +337,7 @@ public function setActive(string $uuid): JSONResponse {
* active: bool|null, parent: null|string, children: array,
* quota: array{storage: int|null, bandwidth: int|null,
* requests: int|null, users: null, groups: null},
* usage: array{storage: 0, bandwidth: 0, requests: 0,
* usage: array{storage: null, bandwidth: null, requests: null,
* users: int<0, max>, groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string}|null}, array<never, never>>
*
Expand Down Expand Up @@ -394,7 +399,7 @@ public function getActive(): JSONResponse {
* parent: null|string, children: array,
* quota: array{storage: int|null, bandwidth: int|null,
* requests: int|null, users: null, groups: null},
* usage: array{storage: 0, bandwidth: 0, requests: 0,
* usage: array{storage: null, bandwidth: null, requests: null,
* users: int<0, max>, groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string}}, array<never, never>>
*
Expand Down Expand Up @@ -635,7 +640,7 @@ public function leave(string $uuid): JSONResponse {
* parent: null|string, children: array,
* quota: array{storage: int|null, bandwidth: int|null,
* requests: int|null, users: null, groups: null},
* usage: array{storage: 0, bandwidth: 0, requests: 0,
* usage: array{storage: int|null, bandwidth: null, requests: int|null,
* users: int<0, max>, groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string}}, array<never, never>>
*
Expand All @@ -659,9 +664,14 @@ public function show(string $uuid): JSONResponse {
$children = $this->organisationMapper->findChildrenChain($uuid);
$organisation->setChildren($children);

// The measured usage replaces the entity's, which cannot measure
// storage or requests.
$data = $organisation->jsonSerialize();
$data['usage'] = $this->usageService->measure(organisation: $organisation);

return new JSONResponse(
data: [
'organisation' => $organisation->jsonSerialize(),
'organisation' => $data,
],
statusCode: Http::STATUS_OK
);
Expand Down Expand Up @@ -786,7 +796,7 @@ public function patch(string $uuid): JSONResponse {
* children: array,
* quota: array{storage: int|null, bandwidth: int|null,
* requests: int|null, users: null, groups: null},
* usage: array{storage: 0, bandwidth: 0, requests: 0,
* usage: array{storage: null, bandwidth: null, requests: null,
* users: int<0, max>, groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string}>,
* limit?: int<1, 100>, offset?: int<0, max>, count?: int<0, max>},
Expand Down
29 changes: 29 additions & 0 deletions lib/Db/FileMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,35 @@ public function getTotalFilesSize(): int {
return (int)($row['total_size'] ?? 0);
}//end getTotalFilesSize()

/**
* The summed size of the given files or folders.
*
* A folder's size in the file cache is the size of everything under it. A
* negative size means the entry was never scanned and is left out.
*
* @param list<int> $fileIds The file cache ids.
*
* @return int The total size in bytes, 0 for no ids.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
public function sumSizesByFileIds(array $fileIds): int {
$total = 0;
foreach (array_chunk(array_values(array_unique($fileIds)), 1000) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->selectAlias($qb->createFunction('SUM(fc.size)'), 'total_size')
->from('filecache', 'fc')
->where($qb->expr()->in('fc.fileid', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->gte('fc.size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
$total += (int)($row['total_size'] ?? 0);
}

return $total;
}//end sumSizesByFileIds()

/**
* Find files in Nextcloud that are not tracked in the extraction system yet
*
Expand Down
45 changes: 45 additions & 0 deletions lib/Db/MagicMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7594,6 +7594,51 @@ static function (array $first, array $second): int {
return $found;
}//end findDeletedAcrossAllMagicTables()

/**
* The file ids of the object folders that belong to one organisation.
*
* Scans every magic table for live rows whose `_organisation` is the given
* uuid and returns their distinct numeric `_folder` values. Not narrowed to
* a caller: it measures the organisation, and the caller decides who may
* see the measurement.
*
* @param string $organisationUuid The organisation uuid.
*
* @return list<int> The folder file ids.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
public function findFolderIdsByOrganisation(string $organisationUuid): array {
$organisationCol = 't.' . self::METADATA_PREFIX . 'organisation';
$folderCol = 't.' . self::METADATA_PREFIX . 'folder';
$deletedCol = 't.' . self::METADATA_PREFIX . 'deleted';
$folderIds = [];

foreach (array_keys($this->discoverMagicTables()) as $fullTableName) {
$bareTableName = substr($fullTableName, strlen($this->getTablePrefix()));
try {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct($folderCol)
->from($bareTableName, 't')
->where($qb->expr()->eq($organisationCol, $qb->createNamedParameter($organisationUuid)))
->andWhere($qb->expr()->isNotNull($folderCol))
->andWhere($qb->expr()->isNull($deletedCol));
$result = $qb->executeQuery();
while (($folder = $result->fetchOne()) !== false) {
if (is_numeric($folder) === true) {
$folderIds[(int)$folder] = true;
}
}

$result->closeCursor();
} catch (\Exception $e) {
continue;
}
}//end foreach

return array_keys($folderIds);
}//end findFolderIdsByOrganisation()

/**
* Count all soft-deleted objects across ALL magic tables.
*
Expand Down
18 changes: 9 additions & 9 deletions lib/Db/Organisation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1017,9 +1017,9 @@ public function setChildren(?array $children): static {
* groups: null
* },
* usage: array{
* storage: 0,
* bandwidth: 0,
* requests: 0,
* storage: null,
* bandwidth: null,
* requests: null,
* users: int<0, max>,
* groups: int<0, max>
* },
Expand Down Expand Up @@ -1072,13 +1072,13 @@ public function jsonSerialize(): array {
'groups' => null,
// To be set via admin configuration.
],
// Storage, bandwidth and requests are measured by
// OrganisationUsageService; the entity cannot measure them, so they
// are null here rather than a 0 that reads as "nothing used".
'usage' => [
'storage' => 0,
// To be calculated from actual usage.
'bandwidth' => 0,
// To be calculated from actual usage.
'requests' => 0,
// To be calculated from actual usage.
'storage' => null,
'bandwidth' => null,
'requests' => null,
'users' => count($users),
'groups' => count($groups),
],
Expand Down
161 changes: 161 additions & 0 deletions lib/Service/OrganisationUsageService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

/**
* Organisation Usage Service
*
* Measures what an organisation uses: its members, the storage of the files on
* its objects, and the requests the quota middleware counted this hour.
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Service
* @package OCA\OpenRegister\Service
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://OpenRegister.app
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/

declare(strict_types=1);

namespace OCA\OpenRegister\Service;

use DateTime;
use Exception;
use OCA\OpenRegister\Db\FileMapper;
use OCA\OpenRegister\Db\MagicMapper;
use OCA\OpenRegister\Db\Organisation;
use Psr\Log\LoggerInterface;

/**
* Measures an organisation's usage.
*
* A figure that cannot be measured is null, never 0: a 0 reads as "nothing
* used", which is a claim this service only makes when it counted.
*/
class OrganisationUsageService {
/**
* Constructor.
*
* @param MagicMapper $magicMapper Reads the folders of the organisation's objects.
* @param FileMapper $fileMapper Sums file cache sizes.
* @param LoggerInterface $logger Logger.
*/
public function __construct(
private readonly MagicMapper $magicMapper,
private readonly FileMapper $fileMapper,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Measure the organisation's usage.
*
* - `users`, `groups`: the organisation's members and groups.
* - `storage`: bytes of the files in the folders of the organisation's live
* objects; null when it could not be read.
* - `requests`: the requests counted for it this hour by the quota
* middleware, which counts only for an organisation with a request quota
* and only with APCu; null otherwise.
* - `bandwidth`: null. The middleware counts JSON response bodies only,
* which is not the organisation's bandwidth.
*
* @param Organisation $organisation The organisation.
*
* @return array{storage: int|null, bandwidth: null, requests: int|null, users: int, groups: int}
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
public function measure(Organisation $organisation): array {
return [
'storage' => $this->measureStorage(organisation: $organisation),
'bandwidth' => null,
'requests' => $this->measureRequests(organisation: $organisation),
'users' => count($organisation->getUserIds()),
'groups' => count($organisation->getGroups()),
];
}//end measure()

/**
* The bytes stored in the folders of the organisation's live objects.
*
* @param Organisation $organisation The organisation.
*
* @return int|null The bytes, or null when they could not be read.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
private function measureStorage(Organisation $organisation): ?int {
$uuid = $organisation->getUuid();
if ($uuid === null || $uuid === '') {
return null;
}

try {
$folderIds = $this->magicMapper->findFolderIdsByOrganisation(organisationUuid: $uuid);
return $this->fileMapper->sumSizesByFileIds(fileIds: $folderIds);
} catch (Exception $e) {
$this->logger->warning(
message: '[OrganisationUsageService] Could not measure storage',
context: ['uuid' => $uuid, 'error' => $e->getMessage()]
);
return null;
}
}//end measureStorage()

/**
* The requests counted for the organisation this hour.
*
* @param Organisation $organisation The organisation.
*
* @return int|null The count, or null when nothing counts them.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
private function measureRequests(Organisation $organisation): ?int {
$uuid = $organisation->getUuid();
if ($uuid === null || $uuid === '' || $organisation->getRequestQuota() === null
|| $this->apcuAvailable() === false
) {
return null;
}

$hourBucket = (new DateTime())->format('YmdH');
return ($this->apcuFetchInt(key: "or_quota_{$uuid}_{$hourBucket}") ?? 0);
}//end measureRequests()

/**
* Whether APCu is enabled.
*
* @return bool True when APCu can be read.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
protected function apcuAvailable(): bool {
return function_exists('apcu_enabled') === true && apcu_enabled() === true;
}//end apcuAvailable()

/**
* Read an integer counter from APCu.
*
* @param string $key The key.
*
* @return int|null The value, or null when the key is not set.
*
* @spec openspec/changes/organisation-usage-measured/specs/organisation-usage-measured/spec.md
*/
protected function apcuFetchInt(string $key): ?int {
$success = false;
$value = apcu_fetch($key, $success);
if ($success !== true) {
return null;
}

return (int)$value;
}//end apcuFetchInt()
}//end class
29 changes: 29 additions & 0 deletions openspec/changes/organisation-usage-measured/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Proposal: organisation-usage-measured

## Why

An organisation's `usage` block reported `storage: 0`, `bandwidth: 0` and
`requests: 0` for every organisation, always. A 0 reads as "nothing used", so
a quota card drawn from it told every tenant they used nothing. Only `users`
and `groups` were real.

## What Changes

- `GET /api/organisations/{uuid}` measures usage through a new
`OrganisationUsageService`:
- `users`, `groups`: the organisation's members and groups (unchanged).
- `storage`: the summed file cache size of the folders of the
organisation's live objects across all magic tables; null when it cannot
be read.
- `requests`: this hour's counter of the quota middleware. The middleware
only counts for an organisation with a request quota, and only with APCu,
so it is null otherwise.
- `bandwidth`: null. The middleware counts JSON response bodies only, which
is not bandwidth.
- Everywhere else the entity is serialised (lists, search, active),
`storage`, `bandwidth` and `requests` are null instead of 0: the entity
cannot measure them.
- `MagicMapper::findFolderIdsByOrganisation()` and
`FileMapper::sumSizesByFileIds()`.

Measuring runs only after the existing access check in `show`.
Loading
Loading