db:schema:check: tables of apps whose code was removed (but still registered) are reported as blocking findings instead of non-blocking
Bug description
occ db:schema:check is designed to only treat findings from core and
enabled apps as blocking, while findings belonging to a disabled app are
collected separately and excluded from the exit code
(SchemaChecker::partitionFindings()).
However, when an app is registered as installed (has an installed_version
app-config entry) but its code directory no longer exists — e.g. it was
previously used and its files were removed without a full occ app:remove,
or its code became unavailable during a major-version upgrade while its
appconfig/tables were left in place — SchemaChecker::applyDisabledMigrations()
hits AppPathNotFoundException and simply returns without replaying any
migrations for that app:
try {
$appPath = $this->appManager->getAppPath($app);
} catch (AppPathNotFoundException) {
// Installed, but code is gone: no migrations to replay.
return;
}Because no tables get added to the in-memory expected schema for that app,
every live table still owned by it is reported as unexpected_table and —
critically — is not attributed to any app ($disabledAppTableOwners stays
empty for it). In getFindings() this makes enabled evaluate to true:
$finding['enabled'] = $app === null || $app === 'core' || isset($enabledApps[$app]);So orphaned tables from a long-gone app end up in the blocking bucket, printed as plain findings and affecting the exit code — exactly like a real core/enabled-app schema problem — instead of the non-blocking "Disabled apps" section the command is explicitly designed to produce for this case.
The same silent-failure path (catch (\Throwable) { return; } around
applyMigrations() inside applyDisabledMigrations()) can also swallow a
migration class that fails to load because it references other classes from
the same app that aren't autoloaded (disabled apps only get their
lib/Migration/*.php files require_onced directly, not the full PSR-4
autoload). This produces the same misclassification for a present but
disabled app, without any indication of why.
A related, separate false positive (same command)
Independent of the above: occ db:schema:check can report a missing_index
for an index a shipped app has intentionally made redundant via
AddMissingIndicesEvent::replaceIndex(), when no migration ever formally
drops the old index.
Example: the activity app's initial migration
(Version2006Date20170808154933) creates both activity_object
(object_type, object_id) and activity_object_user
(affecteduser, object_type, object_id, timestamp). A later
AddMissingIndicesListener calls:
$event->replaceIndex('activity', ['activity_object'], 'activity_object_user', [...], false);...to retire activity_object in favor of the superseding
activity_object_user. Since no migration was ever added to drop
activity_object, db:schema:check's migration replay still expects it,
and reports oc_activity: missing index 'activity_object' on any instance
where that index is already gone (e.g. via historical cleanup, or simply
never created because only the superseding migration entry applies) — even
though this is the intended, up-to-date state.
Confirmed on a test instance that the index is absent and its replacement is present:
SELECT indexname, indexdef FROM pg_indexes
WHERE tablename = 'oc_activity' AND indexname LIKE 'activity_object%';
indexname | indexdef
-----------------------+-------------------------------------------------------------------------------------------------------------------------
activity_object_user | CREATE INDEX activity_object_user ON public.oc_activity USING btree (affecteduser, object_type, object_id, "timestamp")
(1 row)db:add-missing-indices correctly does nothing in this state (the
replacement index already exists), consistent with this being a stale
expectation in db:schema:check rather than an actual gap.
Steps to reproduce
- Install and enable an app; let it create its tables.
- Remove the app's code from the apps directory without running
occ app:remove(or let it become unavailable through some other means), leaving itsappconfiginstalled_versionentry and its tables intact. - Run
occ db:schema:check.
Alternative reproduction for the index case:
- On an instance where the
activityapp'soc_activitytable hasactivity_object_userbut notactivity_object(e.g. after runningocc db:add-missing-indices, or on any instance where the old index was already cleaned up historically), runocc db:schema:check.
Expected behavior
- Tables belonging to an app that is "installed" per app-config but has no resolvable code path should be treated like any other disabled-app finding: collected separately, excluded from the exit code, and ideally flagged explicitly as "app code missing" rather than silently merged into the same bucket as core/enabled-app problems.
- An index retired exclusively through
AddMissingIndicesEvent::replaceIndex()should not be permanently reported asmissing_indexbydb:schema:checkonce the replacement index is present.
Actual behavior
Both cases produce plain, unlabeled unexpected_table / missing_index
findings in the blocking output of occ db:schema:check, indistinguishable
from genuine schema drift, with no indication that the underlying cause is
a removed app or an intentionally superseded index.
Environment
- Nextcloud Server version: 35.0.0
- Database: PostgreSQL
- Reproducible independently of OS/web server/PHP version — the issue is in
SchemaChecker's logic and theactivityapp's own migration/listener setup.
Additional context
Traced via the following source files:
lib/private/DB/SchemaChecker.php(getFindings(),applyDisabledMigrations(),partitionFindings())core/Command/Db/AddMissingIndices.phpnextcloud/activity:lib/Listener/AddMissingIndicesListener.php,lib/Migration/Version2006Date20170808154933.php
Happy to provide more detail or test a patch.
Source: nextcloud/server