aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2025-12-04 19:11:31 +0100
committerGravatar GitHub <noreply@github.com> 2025-12-04 19:11:31 +0100
commit78e40c6fe3afe7f815ef9d32646610e2d5436ba3 (patch)
tree44ac9cb2255b4253beabcce3d23d81d8508e5211 /app
parent794d56e10f694688d60a26d4848fd82027722838 (diff)
Scaling of user statistics (#8277)
Fix https://github.com/FreshRSS/FreshRSS/issues/8268 To better support user management on FreshRSS instance with many users. SQL speed improved. On a reduced test with 5 users, including some large accounts (PostgreSQL on a very tiny and slow server), improving from ~2.3s to ~1.8s, which gives ~20% speed improvement. Then tested with 1000 users, with only the default feed (on my old desktop computer): ```sh for i in {1..1000}; do ./cli/create-user.php --user=freshrss$i --password=freshrss; done app/actualize_script.php cli/access-permissions.sh ``` SQLite: ```console $ time cli/user-info.php | wc -l 1001 real 0m1.366s user 0m0.908s sys 0m0.475s ``` PostgreSQL: ```console $ time cli/user-info.php | wc -l 1001 real 0m28.498s user 0m12.137s sys 0m2.217s ``` MariaDB: ```console # time ./cli/user-info.php | wc -l 1001 real 0m49.485s user 0m1.276s sys 0m2.258s ``` Yes, SQLite is much faster - not a surprise for such use-cases, where the TCP connection is not re-used. I have added some CLI options to disable some statistics: ```sh cli/user-info.php --no-db-size --no-db-counts ``` For the Web UI, I have disabled detailed user statistics if it takes too long, and retrieve missing user statistics asynchronously via JavaScript. Lazy loading of the user details based on IntersectionObserver, with maximum 10 requests in parallel. Web UI tested on 1000 users as well. Checked with SeaMonkey.
Diffstat (limited to 'app')
-rw-r--r--app/Controllers/configureController.php2
-rw-r--r--app/Controllers/userController.php24
-rw-r--r--app/Models/DatabaseDAO.php9
-rw-r--r--app/Models/EntryDAO.php93
-rw-r--r--app/Models/View.php4
-rw-r--r--app/views/user/details.phtml12
-rw-r--r--app/views/user/manage.phtml11
7 files changed, 67 insertions, 88 deletions
diff --git a/app/Controllers/configureController.php b/app/Controllers/configureController.php
index 86943e663..8bc0d08c4 100644
--- a/app/Controllers/configureController.php
+++ b/app/Controllers/configureController.php
@@ -369,7 +369,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
$this->view->size_user = $databaseDAO->size();
if (FreshRSS_Auth::hasAccess('admin')) {
- $this->view->size_total = $databaseDAO->size(true);
+ $this->view->size_total = $databaseDAO->size(all: true);
}
FreshRSS_View::prependTitle(_t('conf.archiving.title') . ' · ');
diff --git a/app/Controllers/userController.php b/app/Controllers/userController.php
index 84ef85335..6f5b27280 100644
--- a/app/Controllers/userController.php
+++ b/app/Controllers/userController.php
@@ -328,8 +328,14 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
$this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation;
$this->view->current_user = Minz_Request::paramString('u');
+ $fast = false;
+ $startTime = time();
foreach (self::listUsers() as $user) {
- $this->view->users[$user] = $this->retrieveUserDetails($user);
+ if (!$fast && (time() - $startTime >= 3)) {
+ // Disable detailed user statistics if it takes too long, and will retrieve them asynchronously via JavaScript
+ $fast = true;
+ }
+ $this->view->users[$user] = $this->retrieveUserDetails($user, $fast);
}
}
@@ -806,11 +812,11 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
FreshRSS_View::prependTitle($username . ' · ' . _t('gen.menu.user_management') . ' · ');
}
- /** @return array{'feed_count':int,'article_count':int,'database_size':int,'language':string,'mail_login':string,'enabled':bool,'is_admin':bool,'last_user_activity':string,'is_default':bool} */
- private function retrieveUserDetails(string $username): array {
- $feedDAO = FreshRSS_Factory::createFeedDao($username);
- $entryDAO = FreshRSS_Factory::createEntryDao($username);
- $databaseDAO = FreshRSS_Factory::createDatabaseDAO($username);
+ /** @return array{feed_count:?int,article_count:?int,database_size:?int,language:string,mail_login:string,enabled:bool,is_admin:bool,last_user_activity:string,is_default:bool} */
+ private function retrieveUserDetails(string $username, bool $fast = false): array {
+ $feedDAO = $fast ? null : FreshRSS_Factory::createFeedDao($username);
+ $entryDAO = $fast ? null : FreshRSS_Factory::createEntryDao($username);
+ $databaseDAO = $fast ? null : FreshRSS_Factory::createDatabaseDAO($username);
$userConfiguration = FreshRSS_UserConfiguration::getForUser($username);
if ($userConfiguration === null) {
@@ -818,9 +824,9 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
}
return [
- 'feed_count' => $feedDAO->count(),
- 'article_count' => $entryDAO->count(),
- 'database_size' => $databaseDAO->size(),
+ 'feed_count' => isset($feedDAO) ? $feedDAO->count() : null,
+ 'article_count' => isset($entryDAO) ? $entryDAO->count() : null,
+ 'database_size' => isset($databaseDAO) ? $databaseDAO->size() : null,
'language' => $userConfiguration->language,
'mail_login' => $userConfiguration->mail_login,
'enabled' => $userConfiguration->enabled,
diff --git a/app/Models/DatabaseDAO.php b/app/Models/DatabaseDAO.php
index b34c0fc66..1a6a824e5 100644
--- a/app/Models/DatabaseDAO.php
+++ b/app/Models/DatabaseDAO.php
@@ -347,13 +347,8 @@ SQL;
//SQLite is the only one with database-level optimization, instead of at table level.
$this->optimize();
}
- } else {
- if ($databaseDAO->exits()) {
- $nbEntries = $entryDAO->countUnreadRead();
- if (isset($nbEntries['all']) && $nbEntries['all'] > 0) {
- $error = 'Error: Destination database already contains some entries!';
- }
- }
+ } elseif ($databaseDAO->exits() && $entryDAO->count() > 0) {
+ $error = 'Error: Destination database already contains some entries!';
}
break;
default:
diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php
index b4f7451c7..eb800ff1e 100644
--- a/app/Models/EntryDAO.php
+++ b/app/Models/EntryDAO.php
@@ -1733,29 +1733,35 @@ SQL;
}
}
- /** @return array<string,int> */
- public function countUnreadRead(): array {
+ /** @return array{all:int,unread:int,read:int,favorites:int} */
+ public function countAsStates(?int $minPriority = null): array {
+ $values = [];
$sql = <<<'SQL'
-SELECT COUNT(e.id) AS count FROM `_entry` e
- INNER JOIN `_feed` f ON e.id_feed=f.id
- WHERE f.priority > 0
-UNION
-SELECT COUNT(e.id) AS count FROM `_entry` e
- INNER JOIN `_feed` f ON e.id_feed=f.id
- WHERE f.priority > 0 AND e.is_read=0
-SQL;
- $res = $this->fetchColumn($sql, 0);
- if ($res === null) {
- return ['all' => -1, 'unread' => -1, 'read' => -1];
+ SELECT
+ COUNT(*) AS total,
+ COUNT(CASE WHEN e.is_read = 0 THEN 1 END) AS unread,
+ COUNT(CASE WHEN e.is_favorite = 1 THEN 1 END) AS favorites
+ FROM `_entry` e
+ SQL;
+ if ($minPriority !== null) {
+ $sql .= <<<'SQL'
+ INNER JOIN `_feed` f ON e.id_feed = f.id
+ WHERE f.priority > :priority
+ SQL;
+ $values[':priority'] = $minPriority;
}
- rsort($res);
- $all = (int)($res[0] ?? 0);
- $unread = (int)($res[1] ?? 0);
- return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
+ $res = $this->fetchAssoc($sql, $values);
+ if ($res === null || !isset($res[0])) {
+ return ['all' => -1, 'unread' => -1, 'read' => -1, 'favorites' => -1];
+ }
+ $all = (int)($res[0]['total'] ?? 0);
+ $unread = (int)($res[0]['unread'] ?? 0);
+ $favorites = (int)($res[0]['favorites'] ?? 0);
+ return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread, 'favorites' => $favorites];
}
public function count(?int $minPriority = null): int {
- $sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
+ $sql = 'SELECT COUNT(*) AS count FROM `_entry` e';
$values = [];
if ($minPriority !== null) {
$sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
@@ -1766,51 +1772,22 @@ SQL;
return isset($res[0]) ? (int)($res[0]) : -1;
}
- public function countNotRead(?int $minPriority = null): int {
- $sql = 'SELECT COUNT(e.id) AS count FROM `_entry` e';
- if ($minPriority !== null) {
- $sql .= ' INNER JOIN `_feed` f ON e.id_feed=f.id';
- }
- $sql .= ' WHERE e.is_read=0';
- $values = [];
- if ($minPriority !== null) {
- $sql .= ' AND f.priority > :priority';
- $values[':priority'] = $minPriority;
- }
- $res = $this->fetchColumn($sql, 0, $values);
- return isset($res[0]) ? (int)($res[0]) : -1;
- }
-
/** @return array{'all':int,'read':int,'unread':int} */
public function countUnreadReadFavorites(): array {
$sql = <<<'SQL'
-SELECT c FROM (
- SELECT COUNT(e1.id) AS c, 1 AS o
- FROM `_entry` AS e1
- JOIN `_feed` AS f1 ON e1.id_feed = f1.id
- WHERE e1.is_favorite = 1
- AND f1.priority >= :priority1
- UNION
- SELECT COUNT(e2.id) AS c, 2 AS o
- FROM `_entry` AS e2
- JOIN `_feed` AS f2 ON e2.id_feed = f2.id
- WHERE e2.is_favorite = 1
- AND e2.is_read = 0 AND f2.priority >= :priority2
- ) u
-ORDER BY o
-SQL;
- //Binding a value more than once is not standard and does not work with native prepared statements (e.g. MySQL) https://bugs.php.net/bug.php?id=40417
- $res = $this->fetchColumn($sql, 0, [
- ':priority1' => FreshRSS_Feed::PRIORITY_CATEGORY,
- ':priority2' => FreshRSS_Feed::PRIORITY_CATEGORY,
- ]);
- if ($res === null) {
+ SELECT
+ COUNT(*) AS total,
+ COUNT(CASE WHEN e.is_read = 0 THEN 1 END) AS unread
+ FROM `_entry` e
+ JOIN `_feed` f ON e.id_feed = f.id
+ WHERE e.is_favorite = 1 AND f.priority > :priority
+ SQL;
+ $res = $this->fetchAssoc($sql, [':priority' => FreshRSS_Feed::PRIORITY_HIDDEN]);
+ if ($res === null || !isset($res[0])) {
return ['all' => -1, 'unread' => -1, 'read' => -1];
}
-
- rsort($res);
- $all = (int)($res[0] ?? 0);
- $unread = (int)($res[1] ?? 0);
+ $all = (int)($res[0]['total'] ?? 0);
+ $unread = (int)($res[0]['unread'] ?? 0);
return ['all' => $all, 'unread' => $unread, 'read' => $all - $unread];
}
}
diff --git a/app/Models/View.php b/app/Models/View.php
index b7970b57e..d1d5d0d8e 100644
--- a/app/Models/View.php
+++ b/app/Models/View.php
@@ -44,12 +44,12 @@ class FreshRSS_View extends Minz_View {
public bool $signalError;
// Manage users
- /** @var array{feed_count:int,article_count:int,database_size:int,language:string,mail_login:string,enabled:bool,is_admin:bool,last_user_activity:string,is_default:bool} */
+ /** @var array{feed_count:?int,article_count:?int,database_size:?int,language:string,mail_login:string,enabled:bool,is_admin:bool,last_user_activity:string,is_default:bool} */
public array $details;
public bool $disable_aside;
public bool $show_email_field;
public string $username;
- /** @var array<array{language:string,enabled:bool,is_admin:bool,enabled:bool,article_count:int,database_size:int,last_user_activity:string,mail_login:string,feed_count:int,is_default:bool}> */
+ /** @var array<array{language:string,enabled:bool,is_admin:bool,enabled:bool,article_count:?int,database_size:?int,last_user_activity:string,mail_login:string,feed_count:?int,is_default:bool}> */
public array $users;
// Updates
diff --git a/app/views/user/details.phtml b/app/views/user/details.phtml
index 8bad08a81..9dd55d737 100644
--- a/app/views/user/details.phtml
+++ b/app/views/user/details.phtml
@@ -29,22 +29,22 @@
<div class="form-group">
<label class="group-name"><?= _t('admin.user.feed_count') ?></label>
- <div class="group-controls">
- <?= format_number($this->details['feed_count'] ?: 0) ?>
+ <div class="group-controls feed_count">
+ <?= is_numeric($this->details['feed_count']) ? format_number($this->details['feed_count']) : '?' ?>
</div>
</div>
<div class="form-group">
<label class="group-name"><?= _t('admin.user.article_count') ?></label>
- <div class="group-controls">
- <?= format_number($this->details['article_count'] ?: 0) ?>
+ <div class="group-controls article_count">
+ <?= is_numeric($this->details['article_count']) ? format_number($this->details['article_count']) : '?' ?>
</div>
</div>
<div class="form-group">
<label class="group-name"><?= _t('admin.user.database_size') ?></label>
- <div class="group-controls">
- <?= format_bytes($this->details['database_size']) ?>
+ <div class="group-controls database_size">
+ <?= is_numeric($this->details['database_size']) ? format_bytes($this->details['database_size']) : '?' ?>
</div>
</div>
diff --git a/app/views/user/manage.phtml b/app/views/user/manage.phtml
index db5b8c7dd..3f541c0f5 100644
--- a/app/views/user/manage.phtml
+++ b/app/views/user/manage.phtml
@@ -105,16 +105,17 @@
</thead>
<tbody>
<?php foreach ($this->users as $username => $values): ?>
- <tr <?= $values['is_default'] ? 'class="default-user"' : '' ?>>
+ <tr <?= $values['is_default'] ? 'class="default-user"' : '' ?>
+ <?= is_numeric($values['feed_count']) ? '' : 'data-need-ajax="1"' ?>>
<td><a href="<?= _url('user', 'details', 'username', $username) ?>" class="configure open-slider" ><?= _i('configure') ?></a></td>
- <td><?= $username ?></td>
+ <td class="username"><?= $username ?></td>
<td><?= $values['enabled'] ? '✔' : ' ' ?></td>
<td><?= $values['is_admin'] ? '✔' : ' ' ?></td>
<td><?= $values['mail_login'] ?></td>
<td><?= _t("gen.lang.{$values['language']}") ?></td>
- <td><?= format_number($values['feed_count']) ?></td>
- <td><?= format_number($values['article_count']) ?></td>
- <td><?= format_bytes($values['database_size']) ?></td>
+ <td class="feed-count"><?= is_numeric($values['feed_count']) ? format_number($values['feed_count']) : '?' ?></td>
+ <td class="article-count"><?= is_numeric($values['article_count']) ? format_number($values['article_count']) : '?' ?></td>
+ <td class="database-size"><?= is_numeric($values['database_size']) ? format_bytes($values['database_size']) : '?' ?></td>
<td><?= $values['last_user_activity'] ?></td>
</tr>
<?php endforeach ?>