From 679b7593219c6158a691764edb0b5584b9847091 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Tue, 15 Sep 2026 16:06:50 +0100
Subject: [PATCH 1/3] feat: Move user-import, token, and event cleanup to
housekeeping
Preserve import reap semantics with item-level logs, add reapers for expired access and legacy 2FA tokens, and rotate user events after 730 days (0 disables it).
Co-authored-by: Cursor
(cherry picked from commit 33b1e2c87962c2d4bb68a8021caafb4d8693de55)
---
composer.json | 1 +
src/Console/Command/User/Import/Clean.php | 246 +++------------------
src/Cron/Task/User/Import/Clean.php | 39 ----
src/Housekeeping/AccessTokens.php | 49 +++++
src/Housekeeping/TwoFactorTokens.php | 87 ++++++++
src/Housekeeping/UserEvents.php | 86 ++++++++
src/Housekeeping/UserImports.php | 255 ++++++++++++++++++++++
7 files changed, 503 insertions(+), 260 deletions(-)
delete mode 100644 src/Cron/Task/User/Import/Clean.php
create mode 100644 src/Housekeeping/AccessTokens.php
create mode 100644 src/Housekeeping/TwoFactorTokens.php
create mode 100644 src/Housekeeping/UserEvents.php
create mode 100644 src/Housekeeping/UserImports.php
diff --git a/composer.json b/composer.json
index e118399d..eff11f05 100644
--- a/composer.json
+++ b/composer.json
@@ -38,6 +38,7 @@
"nails/module-cron": "dev-feature/pre-new-admin",
"nails/module-email": "dev-feature/pre-new-admin",
"nails/module-form-builder": "dev-feature/pre-new-admin",
+ "nails/module-housekeeping": "dev-feature/pre-new-admin",
"sonata-project/google-authenticator": "~2.3.0",
"wikimedia/common-passwords": "^v0.4",
"lbuchs/webauthn": "^2.2",
diff --git a/src/Console/Command/User/Import/Clean.php b/src/Console/Command/User/Import/Clean.php
index 4b8d3afc..7147b92f 100644
--- a/src/Console/Command/User/Import/Clean.php
+++ b/src/Console/Command/User/Import/Clean.php
@@ -2,59 +2,19 @@
namespace Nails\Auth\Console\Command\User\Import;
-use DateInterval;
-use Nails\Auth\Constants;
-use Nails\Auth\Enum\User\Import\Status;
-use Nails\Auth\Model;
-use Nails\Auth\Resource;
-use Nails\Common\Exception\FactoryException;
-use Nails\Common\Exception\ModelException;
-use Nails\Common\Service\Database;
-use Nails\Config;
+use Nails\Auth\Housekeeping\UserImports;
+use Nails\Components;
use Nails\Console\Command\Base;
use Nails\Factory;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
-use Throwable;
/**
- * Class Clean
- *
- * @package Nails\Auth\Console\Command\User\Import
+ * @deprecated Use housekeeping:run --routine=Nails\Auth\Housekeeping\UserImports
*/
class Clean extends Base
{
- /**
- * How long, in seconds, a claim may go without the cursor moving before it
- * is considered orphaned
- *
- * @var int
- */
- const STALE_CLAIM = 900;
-
- /**
- * How long, in seconds, an unapproved job is kept before it is reaped
- *
- * @var int
- */
- const DRAFT_TTL = 86400;
-
- /**
- * How long, in seconds, a finished job is kept before it is rotated out
- *
- * @var int
- */
- const RETENTION = 2592000;
-
- /**
- * The maximum number of jobs to delete in a single run
- *
- * @var int
- */
- const MAX_PER_RUN = 100;
-
- // --------------------------------------------------------------------------
-
/**
* Configure the command
*/
@@ -62,11 +22,15 @@ protected function configure(): void
{
$this
->setName('auth:user:import:clean')
- ->setDescription('Releases orphaned user import jobs and reaps old ones');
+ ->setDescription('[DEPRECATED] Releases orphaned user import jobs and reaps old ones')
+ ->addOption(
+ 'dry-run',
+ null,
+ InputOption::VALUE_NONE,
+ 'Log what would be changed without writing'
+ );
}
- // --------------------------------------------------------------------------
-
/**
* Executes the command
*
@@ -77,185 +41,25 @@ protected function execute(InputInterface $oInput, OutputInterface $oOutput): in
{
parent::execute($oInput, $oOutput);
- try {
+ $this->banner('User Import: Clean (deprecated)');
- $this->banner('User Import: Clean');
- $this
- ->releaseOrphans()
- ->reapDrafts()
- ->rotateFinished();
+ if (!Components::exists('nails/module-housekeeping')) {
+ $oOutput->writeln('This command now requires nails/module-housekeeping.');
+ $oOutput->writeln('Install it with composer require nails/module-housekeeping');
+ $oOutput->writeln('then run nails housekeeping:run --routine=' . UserImports::class . '');
- } catch (Throwable $e) {
- return $this->abort(
- self::EXIT_CODE_FAILURE,
- [$e->getMessage()]
- );
+ return static::EXIT_CODE_FAILURE;
}
- $oOutput->writeln('');
- $oOutput->writeln('Complete!');
-
- return self::EXIT_CODE_SUCCESS;
- }
-
- // --------------------------------------------------------------------------
-
- /**
- * Releases claims held by processes which are no longer with us
- *
- * The status is deliberately left alone; a job resumes from its cursor, and
- * sending it back to PENDING would restart it from the top and re-validate
- * rows whose users have since been created.
- *
- * @throws FactoryException
- * @throws ModelException
- */
- protected function releaseOrphans(): self
- {
- $this->oOutput->writeln('Releasing orphaned claims');
-
- /** @var Model\User\Import $oModel */
- $oModel = Factory::model('UserImport', Constants::MODULE_SLUG);
- /** @var Database $oDb */
- $oDb = Factory::service('Database');
-
- $oDb
- ->set('claim_token', null)
- ->set('claimed', null)
- ->where('claim_token IS NOT NULL', null, false)
- ->where('claimed <', $this->getCutOff('AUTH_USER_IMPORT_STALE_CLAIM', static::STALE_CLAIM))
- ->update($oModel->getTableName());
-
- $this->oOutput->writeln(sprintf(
- 'Released %s',
- $oDb->affected_rows()
- ));
-
- return $this;
- }
-
- // --------------------------------------------------------------------------
-
- /**
- * Deletes uploads which were never approved
- *
- * @throws FactoryException
- * @throws ModelException
- */
- protected function reapDrafts(): self
- {
- $this->oOutput->writeln('');
- $this->oOutput->writeln('Reaping abandoned drafts');
-
- $iDeleted = $this->deleteJobs(
- [Status::DRAFT],
- $this->getCutOff('AUTH_USER_IMPORT_DRAFT_TTL', static::DRAFT_TTL)
- );
-
- $this->oOutput->writeln(sprintf('Deleted %s', $iDeleted));
-
- return $this;
- }
-
- // --------------------------------------------------------------------------
-
- /**
- * Deletes jobs which finished long enough ago that nobody is coming back for them
- *
- * @throws FactoryException
- * @throws ModelException
- */
- protected function rotateFinished(): self
- {
- $this->oOutput->writeln('');
- $this->oOutput->writeln('Rotating finished jobs');
-
- $iDeleted = $this->deleteJobs(
- Status::terminal(),
- $this->getCutOff('AUTH_USER_IMPORT_RETENTION', static::RETENTION)
+ /** @var \Nails\Housekeeping\Service\Orchestrator $oOrchestrator */
+ $oOrchestrator = Factory::service('Orchestrator', 'nails/module-housekeeping');
+ $oResult = $oOrchestrator->runRoutine(
+ UserImports::class,
+ (bool) $oInput->getOption('dry-run'),
+ true,
+ $oOutput
);
- $this->oOutput->writeln(sprintf('Deleted %s', $iDeleted));
-
- return $this;
- }
-
- // --------------------------------------------------------------------------
-
- /**
- * Deletes jobs of the given statuses which were last modified before the cut off
- *
- * @param Status[] $aStatuses
- *
- * @throws FactoryException
- * @throws ModelException
- */
- protected function deleteJobs(array $aStatuses, string $sCutOff): int
- {
- /** @var Model\User\Import $oModel */
- $oModel = Factory::model('UserImport', Constants::MODULE_SLUG);
- /** @var Database $oDb */
- $oDb = Factory::service('Database');
-
- $aRows = $oDb
- ->select('id')
- ->where_in('status', Status::values($aStatuses))
- ->where('modified <', $sCutOff)
- ->order_by('id', 'asc')
- ->limit(static::MAX_PER_RUN)
- ->get($oModel->getTableName())
- ->result();
-
- $iDeleted = 0;
-
- foreach ($aRows as $oRow) {
-
- /** @var Resource\User\Import|null $oImport */
- $oImport = $oModel->getById((int) $oRow->id);
- if (empty($oImport)) {
- continue;
- }
-
- // The job goes first; the CDN objects cascade onto it, and a
- // half-deleted job is worse than a lingering file.
- if (!$oModel->delete($oImport->id)) {
- $this->oOutput->writeln(sprintf(
- '↳ Failed to delete import #%s; %s',
- $oImport->id,
- $oModel->lastError()
- ));
- continue;
- }
-
- foreach ($oModel->destroyObjects($oImport) as $iObjectId => $sError) {
- $this->oOutput->writeln(sprintf(
- '↳ Failed to destroy CDN object #%s; %s',
- $iObjectId,
- $sError
- ));
- }
-
- $iDeleted++;
- }
-
- return $iDeleted;
- }
-
- // --------------------------------------------------------------------------
-
- /**
- * Returns the datetime $sConfigKey seconds ago
- *
- * @throws FactoryException
- */
- protected function getCutOff(string $sConfigKey, int $iDefault): string
- {
- $iSeconds = (int) Config::get($sConfigKey, $iDefault) ?: $iDefault;
-
- /** @var \DateTime $oCutOff */
- $oCutOff = Factory::factory('DateTime');
- $oCutOff->sub(new DateInterval('PT' . $iSeconds . 'S'));
-
- return $oCutOff->format('Y-m-d H:i:s');
+ return $oResult->isSuccess() ? static::EXIT_CODE_SUCCESS : static::EXIT_CODE_FAILURE;
}
}
diff --git a/src/Cron/Task/User/Import/Clean.php b/src/Cron/Task/User/Import/Clean.php
deleted file mode 100644
index 6aec2a25..00000000
--- a/src/Cron/Task/User/Import/Clean.php
+++ /dev/null
@@ -1,39 +0,0 @@
-
+ */
+ protected function where(): array
+ {
+ /** @var \DateTime $oNow */
+ $oNow = Factory::factory('DateTime');
+
+ return [
+ ['expires <', $oNow->format('Y-m-d H:i:s')],
+ ];
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function auditColumns(): array
+ {
+ return ['id', 'user_id', 'expires'];
+ }
+
+ protected function optimizeAfter(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/Housekeeping/TwoFactorTokens.php b/src/Housekeeping/TwoFactorTokens.php
new file mode 100644
index 00000000..4db0310d
--- /dev/null
+++ b/src/Housekeeping/TwoFactorTokens.php
@@ -0,0 +1,87 @@
+format('Y-m-d H:i:s');
+ $iBatchSize = 200;
+ $iProcessed = 0;
+ $iLastId = 0;
+
+ $oContext
+ ->writeln(sprintf('Deleting from %s in batches of %d', $sTable, $iBatchSize))
+ ->log(sprintf(
+ 'TABLE %s batch_size=%d dry_run=%s',
+ $sTable,
+ $iBatchSize,
+ $oContext->isDryRun() ? 'true' : 'false'
+ ));
+
+ while (true) {
+ $aRows = $oDb
+ ->select('id, user_id, expires')
+ ->where('expires <', $sCutOff)
+ ->where('id >', $iLastId)
+ ->order_by('id', 'asc')
+ ->limit($iBatchSize)
+ ->get($sTable)
+ ->result();
+
+ if (empty($aRows)) {
+ break;
+ }
+
+ $aIds = [];
+ foreach ($aRows as $oRow) {
+ $iId = (int) $oRow->id;
+ $iLastId = $iId;
+ $aIds[] = $iId;
+ $sAudit = sprintf(
+ 'id=%d user_id=%s expires=%s',
+ $iId,
+ $oRow->user_id === null ? 'null' : (string) $oRow->user_id,
+ (string) $oRow->expires
+ );
+ $oContext
+ ->log('DELETE ' . $sAudit)
+ ->writeln(' ↳ ' . $sAudit);
+ }
+
+ if (!$oContext->isDryRun()) {
+ $oDb
+ ->where_in('id', $aIds)
+ ->delete($sTable);
+ }
+
+ $iProcessed += count($aIds);
+ }
+
+ $oContext->writeln(sprintf(
+ '%s %s',
+ number_format($iProcessed),
+ $oContext->isDryRun() ? 'would be deleted' : 'deleted'
+ ));
+
+ return Result::ok($iProcessed);
+ }
+}
diff --git a/src/Housekeeping/UserEvents.php b/src/Housekeeping/UserEvents.php
new file mode 100644
index 00000000..6bad3ac2
--- /dev/null
+++ b/src/Housekeeping/UserEvents.php
@@ -0,0 +1,86 @@
+
+ */
+ protected function where(): array
+ {
+ $iDays = $this->retentionDays();
+ if ($iDays < 1) {
+ return [['id' => 0]];
+ }
+
+ /** @var \DateTime $oNow */
+ $oNow = Factory::factory('DateTime');
+ $oNow->sub(new \DateInterval('P' . $iDays . 'D'));
+
+ return [
+ ['created <', $oNow->format('Y-m-d H:i:s')],
+ ];
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function auditColumns(): array
+ {
+ return ['id', 'created_by', 'type', 'created'];
+ }
+
+ protected function optimizeAfter(): bool
+ {
+ return true;
+ }
+
+ public function execute(Context $oContext): Result
+ {
+ $iDays = $this->retentionDays();
+ if ($iDays < 1) {
+ $oContext
+ ->writeln('User event cleanup disabled')
+ ->log('DISABLED AUTH_USER_EVENT_RETENTION_DAYS=0');
+
+ return Result::ok(0, 'User event cleanup disabled');
+ }
+
+ $oContext->writeln('Retention policy: ' . $iDays . ' days');
+
+ return $this->deleteModelRows($oContext);
+ }
+
+ protected function retentionDays(): int
+ {
+ return (int) Config::get('AUTH_USER_EVENT_RETENTION_DAYS', static::DEFAULT_RETENTION_DAYS);
+ }
+}
diff --git a/src/Housekeeping/UserImports.php b/src/Housekeeping/UserImports.php
new file mode 100644
index 00000000..81b0dd01
--- /dev/null
+++ b/src/Housekeeping/UserImports.php
@@ -0,0 +1,255 @@
+releaseOrphans($oContext);
+ $iDrafts = $this->reapDrafts($oContext);
+ $iFinished = $this->rotateFinished($oContext);
+
+ return Result::ok($iReleased + $iDrafts + $iFinished);
+ }
+
+ /**
+ * Releases claims held by processes which are no longer with us.
+ *
+ * The status is deliberately left alone; a job resumes from its cursor, and
+ * sending it back to PENDING would restart it from the top and re-validate
+ * rows whose users have since been created.
+ */
+ protected function releaseOrphans(Context $oContext): int
+ {
+ $oContext->writeln('Releasing orphaned claims');
+
+ /** @var Model\User\Import $oModel */
+ $oModel = Factory::model('UserImport', Constants::MODULE_SLUG);
+ /** @var Database $oDb */
+ $oDb = Factory::service('Database');
+
+ $sCutOff = $this->getCutOff('AUTH_USER_IMPORT_STALE_CLAIM', static::STALE_CLAIM);
+ $iProcessed = 0;
+ $iLastId = 0;
+
+ while (true) {
+ $aRows = $oDb
+ ->select('id, status, modified, claimed')
+ ->where('claim_token IS NOT NULL', null, false)
+ ->where('claimed <', $sCutOff)
+ ->where('id >', $iLastId)
+ ->order_by('id', 'asc')
+ ->limit(200)
+ ->get($oModel->getTableName())
+ ->result();
+
+ if (empty($aRows)) {
+ break;
+ }
+
+ $aIds = [];
+ foreach ($aRows as $oRow) {
+ $iId = (int) $oRow->id;
+ $iLastId = $iId;
+ $aIds[] = $iId;
+ $sAudit = sprintf(
+ 'id=%d status=%s modified=%s claimed=%s',
+ $iId,
+ (string) $oRow->status,
+ (string) $oRow->modified,
+ (string) $oRow->claimed
+ );
+ $oContext
+ ->log('RELEASE ' . $sAudit)
+ ->writeln(' ↳ ' . $sAudit);
+ }
+
+ if (!$oContext->isDryRun()) {
+ $oDb
+ ->set('claim_token', null)
+ ->set('claimed', null)
+ ->where_in('id', $aIds)
+ ->update($oModel->getTableName());
+ }
+
+ $iProcessed += count($aIds);
+ }
+
+ $oContext->writeln(sprintf(
+ 'Released %s',
+ number_format($iProcessed)
+ ));
+
+ return $iProcessed;
+ }
+
+ protected function reapDrafts(Context $oContext): int
+ {
+ $oContext->writeln('');
+ $oContext->writeln('Reaping abandoned drafts');
+
+ $iDeleted = $this->deleteJobs(
+ $oContext,
+ [Status::DRAFT],
+ $this->getCutOff('AUTH_USER_IMPORT_DRAFT_TTL', static::DRAFT_TTL)
+ );
+
+ $oContext->writeln(sprintf('Deleted %s', number_format($iDeleted)));
+
+ return $iDeleted;
+ }
+
+ protected function rotateFinished(Context $oContext): int
+ {
+ $oContext->writeln('');
+ $oContext->writeln('Rotating finished jobs');
+
+ $iDeleted = $this->deleteJobs(
+ $oContext,
+ Status::terminal(),
+ $this->getCutOff('AUTH_USER_IMPORT_RETENTION', static::RETENTION)
+ );
+
+ $oContext->writeln(sprintf('Deleted %s', number_format($iDeleted)));
+
+ return $iDeleted;
+ }
+
+ /**
+ * @param Status[] $aStatuses
+ */
+ protected function deleteJobs(Context $oContext, array $aStatuses, string $sCutOff): int
+ {
+ /** @var Model\User\Import $oModel */
+ $oModel = Factory::model('UserImport', Constants::MODULE_SLUG);
+ /** @var Database $oDb */
+ $oDb = Factory::service('Database');
+
+ $aRows = $oDb
+ ->select('id')
+ ->where_in('status', Status::values($aStatuses))
+ ->where('modified <', $sCutOff)
+ ->order_by('id', 'asc')
+ ->limit(static::MAX_PER_RUN)
+ ->get($oModel->getTableName())
+ ->result();
+
+ $iDeleted = 0;
+
+ foreach ($aRows as $oRow) {
+ /** @var Resource\User\Import|null $oImport */
+ $oImport = $oModel->getById((int) $oRow->id);
+ if (empty($oImport)) {
+ continue;
+ }
+
+ $sStatus = $oImport->status instanceof Status
+ ? $oImport->status->value
+ : (string) $oImport->status;
+ $sAudit = sprintf(
+ 'id=%d status=%s modified=%s',
+ (int) $oImport->id,
+ $sStatus,
+ $this->stringifyDate($oImport->modified)
+ );
+
+ $oContext
+ ->log('DELETE ' . $sAudit)
+ ->writeln(' ↳ ' . $sAudit);
+
+ if ($oContext->isDryRun()) {
+ $iDeleted++;
+ continue;
+ }
+
+ // The job goes first; the CDN objects cascade onto it, and a
+ // half-deleted job is worse than a lingering file.
+ if (!$oModel->delete($oImport->id)) {
+ $oContext
+ ->log('ERROR id=' . $oImport->id . ' ' . $oModel->lastError())
+ ->writeln(sprintf(
+ '↳ Failed to delete import #%s; %s',
+ $oImport->id,
+ $oModel->lastError()
+ ));
+ continue;
+ }
+
+ foreach ($oModel->destroyObjects($oImport) as $iObjectId => $sError) {
+ $oContext
+ ->log('ERROR cdn_object=' . $iObjectId . ' ' . $sError)
+ ->writeln(sprintf(
+ '↳ Failed to destroy CDN object #%s; %s',
+ $iObjectId,
+ $sError
+ ));
+ }
+
+ $iDeleted++;
+ }
+
+ return $iDeleted;
+ }
+
+ protected function getCutOff(string $sConfigKey, int $iDefault): string
+ {
+ $iSeconds = (int) Config::get($sConfigKey, $iDefault) ?: $iDefault;
+
+ /** @var \DateTime $oCutOff */
+ $oCutOff = Factory::factory('DateTime');
+ $oCutOff->sub(new DateInterval('PT' . $iSeconds . 'S'));
+
+ return $oCutOff->format('Y-m-d H:i:s');
+ }
+
+ protected function stringifyDate(mixed $mValue): string
+ {
+ if ($mValue === null) {
+ return 'null';
+ }
+
+ if ($mValue instanceof \DateTimeInterface) {
+ return $mValue->format('Y-m-d H:i:s');
+ }
+
+ return (string) $mValue;
+ }
+}
From 7074ee4cdc1035558725975c416e3350e1e9d18f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Tue, 15 Sep 2026 16:14:41 +0100
Subject: [PATCH 2/3] feat: Remove the auth:user:import:clean console command
Cron was the only caller, and that task is already gone. Run Nails\Auth\Housekeeping\UserImports through housekeeping:run.
Co-authored-by: Cursor
(cherry picked from commit 948da268faf83fa8e89c7160bb04845b62307dd5)
---
src/Api/Controller/Import.php | 2 +-
src/Console/Command/User/Import/Clean.php | 65 -----------------------
src/Model/User/Import.php | 2 +-
src/Service/User/Import/Processor.php | 2 +-
4 files changed, 3 insertions(+), 68 deletions(-)
delete mode 100644 src/Console/Command/User/Import/Clean.php
diff --git a/src/Api/Controller/Import.php b/src/Api/Controller/Import.php
index dc2090bd..c9f1a43b 100644
--- a/src/Api/Controller/Import.php
+++ b/src/Api/Controller/Import.php
@@ -241,7 +241,7 @@ protected function delete(ApiResponse $oApiResponse, Entity $oItem): void
*
* A failure here is logged rather than raised: the row has gone and the
* delete has genuinely succeeded, so there is nothing for the caller to
- * retry. It cannot be left silent though - `auth:user:import:clean`
+ * retry. It cannot be left silent though - `Nails\Auth\Housekeeping\UserImports`
* reaps by walking job rows, so an object orphaned here is unreachable
* by anything else, and the log line is the only thread back to what it
* was for.
diff --git a/src/Console/Command/User/Import/Clean.php b/src/Console/Command/User/Import/Clean.php
deleted file mode 100644
index 7147b92f..00000000
--- a/src/Console/Command/User/Import/Clean.php
+++ /dev/null
@@ -1,65 +0,0 @@
-setName('auth:user:import:clean')
- ->setDescription('[DEPRECATED] Releases orphaned user import jobs and reaps old ones')
- ->addOption(
- 'dry-run',
- null,
- InputOption::VALUE_NONE,
- 'Log what would be changed without writing'
- );
- }
-
- /**
- * Executes the command
- *
- * @param InputInterface $oInput The Input Interface provided by Symfony
- * @param OutputInterface $oOutput The Output Interface provided by Symfony
- */
- protected function execute(InputInterface $oInput, OutputInterface $oOutput): int
- {
- parent::execute($oInput, $oOutput);
-
- $this->banner('User Import: Clean (deprecated)');
-
- if (!Components::exists('nails/module-housekeeping')) {
- $oOutput->writeln('This command now requires nails/module-housekeeping.');
- $oOutput->writeln('Install it with composer require nails/module-housekeeping');
- $oOutput->writeln('then run nails housekeeping:run --routine=' . UserImports::class . '');
-
- return static::EXIT_CODE_FAILURE;
- }
-
- /** @var \Nails\Housekeeping\Service\Orchestrator $oOrchestrator */
- $oOrchestrator = Factory::service('Orchestrator', 'nails/module-housekeeping');
- $oResult = $oOrchestrator->runRoutine(
- UserImports::class,
- (bool) $oInput->getOption('dry-run'),
- true,
- $oOutput
- );
-
- return $oResult->isSuccess() ? static::EXIT_CODE_SUCCESS : static::EXIT_CODE_FAILURE;
- }
-}
diff --git a/src/Model/User/Import.php b/src/Model/User/Import.php
index 7d8d13e6..1179e0fa 100644
--- a/src/Model/User/Import.php
+++ b/src/Model/User/Import.php
@@ -224,7 +224,7 @@ protected function countItems(int $iId, ?ItemStatus $oStatus = null): int
*
* Failures are collected rather than thrown: the row is already gone, so a
* file which outlives it is litter, not an error. Note that
- * `auth:user:import:clean` walks job rows, so it will never reap these.
+ * `Nails\Auth\Housekeeping\UserImports` walks job rows, so it will never reap these.
* objectDestroy() signals failure both by returning false - for a missing
* object, a driver failure, or a rolled back transaction - and by throwing,
* so both are handled.
diff --git a/src/Service/User/Import/Processor.php b/src/Service/User/Import/Processor.php
index fd75d25b..d5fb53f9 100644
--- a/src/Service/User/Import/Processor.php
+++ b/src/Service/User/Import/Processor.php
@@ -1103,7 +1103,7 @@ protected function clearPreviousAttempt(Resource\User\Import $oImport): void
}
/**
- * begin() is about to null log_id, and auth:user:import:clean only reaps
+ * begin() is about to null log_id, and UserImports housekeeping only reaps
* objects it can still reach from a job row, so the previous log is
* destroyed here or not at all.
*/
From 6f1b9c17f71854dceed2050ca7c5750daaf2e5f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20de=20la=20Pen=CC=83a?=
Date: Tue, 15 Sep 2026 16:22:17 +0100
Subject: [PATCH 3/3] fix: Do not invent a user-event retention default
AUTH_USER_EVENT_RETENTION_DAYS is off unless the app sets it. A two-year window is an app policy, not a module default.
Co-authored-by: Cursor
(cherry picked from commit b6b4eadce545aee43723eb009f3cd8a30bf4f792)
---
src/Housekeeping/UserEvents.php | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/src/Housekeeping/UserEvents.php b/src/Housekeeping/UserEvents.php
index 6bad3ac2..56534e78 100644
--- a/src/Housekeeping/UserEvents.php
+++ b/src/Housekeeping/UserEvents.php
@@ -21,11 +21,6 @@ class UserEvents extends Base
const DESCRIPTION = 'Deletes user event log rows older than AUTH_USER_EVENT_RETENTION_DAYS';
const CRON_EXPRESSION = '@daily';
- /**
- * Default retention in days. 0 disables deletion.
- */
- const DEFAULT_RETENTION_DAYS = 730;
-
protected function model(): ModelBase
{
return Factory::model('UserEvent', Constants::MODULE_SLUG);
@@ -81,6 +76,6 @@ public function execute(Context $oContext): Result
protected function retentionDays(): int
{
- return (int) Config::get('AUTH_USER_EVENT_RETENTION_DAYS', static::DEFAULT_RETENTION_DAYS);
+ return (int) Config::get('AUTH_USER_EVENT_RETENTION_DAYS', 0);
}
}