1212use Closure ;
1313use OCP \Config \IUserConfig ;
1414use OCP \DB \ISchemaWrapper ;
15+ use OCP \DB \QueryBuilder \IQueryBuilder ;
1516use OCP \IAppConfig ;
1617use OCP \IDBConnection ;
1718use OCP \Migration \IOutput ;
3839 * rows are copied as-is and keep round-tripping through the listener. Do not sniff ciphertext.
3940 * - Idempotent: a key already present in the target is skipped (standard storage wins), so a
4041 * re-run after a transient failure resumes cleanly. All ExApp values are stored lazy.
42+ * - Rows are read in keyset-paginated batches so a large `preferences_ex` cannot OOM `occ upgrade`.
4143 */
4244class Version035000Date20260529120000 extends SimpleMigrationStep {
4345
@@ -48,6 +50,9 @@ class Version035000Date20260529120000 extends SimpleMigrationStep {
4850 */
4951 public const FAILED_FLAG = 'migration_035000_backfill_failed ' ;
5052
53+ /** Read the legacy tables in keyset-paginated batches of this many rows to bound memory usage. */
54+ private const BATCH_SIZE = 1000 ;
55+
5156 public function __construct (
5257 private IDBConnection $ connection ,
5358 private IAppConfig $ appConfig ,
@@ -68,7 +73,7 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array
6873 $ failed += $ this ->migratePreferences ($ output );
6974 }
7075
71- // Persist the outcome so the table-drop migration (PR #2) can gate on it. Stored lazy under
76+ // Persist the outcome so the table-drop migration can gate on it. Stored lazy under
7277 // AppAPI's own app id; a value > 0 means some rows are still only in the legacy tables.
7378 $ this ->appConfig ->setValueString ('app_api ' , self ::FAILED_FLAG , (string )$ failed , lazy: true );
7479 if ($ failed > 0 ) {
@@ -81,27 +86,31 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array
8186 private function migrateAppConfig (IOutput $ output ): int {
8287 $ migrated = $ skipped = $ failed = 0 ;
8388
84- foreach ($ this ->fetchRows ('appconfig_ex ' , ['id ' , 'appid ' , 'configkey ' , 'configvalue ' , 'sensitive ' ]) as $ row ) {
85- $ appId = (string )$ row ['appid ' ];
86- $ configKey = (string )$ row ['configkey ' ];
87- $ sensitive = (int )($ row ['sensitive ' ] ?? 0 ) === 1 ;
88-
89- try {
90- if ($ this ->appConfig ->hasKey ($ appId , $ configKey , null )) {
91- $ skipped ++;
92- continue ;
93- }
94- $ value = $ this ->decryptLegacyValue ((string )($ row ['configvalue ' ] ?? '' ), $ sensitive );
95- if ($ value === null ) {
96- $ output ->warning (sprintf ('Config migration: failed to decrypt sensitive value for app %s key %s (row id=%d) — skipping ' , $ appId , $ configKey , (int )$ row ['id ' ]));
89+ $ lastId = 0 ;
90+ while (($ rows = $ this ->fetchBatch ('appconfig_ex ' , ['appid ' , 'configkey ' , 'configvalue ' , 'sensitive ' ], $ lastId )) !== []) {
91+ foreach ($ rows as $ row ) {
92+ $ lastId = (int )$ row ['id ' ];
93+ $ appId = (string )$ row ['appid ' ];
94+ $ configKey = (string )$ row ['configkey ' ];
95+ $ sensitive = (int )($ row ['sensitive ' ] ?? 0 ) === 1 ;
96+
97+ try {
98+ if ($ this ->appConfig ->hasKey ($ appId , $ configKey , null )) {
99+ $ skipped ++;
100+ continue ;
101+ }
102+ $ value = $ this ->decryptLegacyValue ((string )($ row ['configvalue ' ] ?? '' ), $ sensitive );
103+ if ($ value === null ) {
104+ $ output ->warning (sprintf ('Config migration: failed to decrypt sensitive value for app %s key %s (row id=%d) — skipping ' , $ appId , $ configKey , $ lastId ));
105+ $ failed ++;
106+ continue ;
107+ }
108+ $ this ->appConfig ->setValueString ($ appId , $ configKey , $ value , lazy: true , sensitive: $ sensitive );
109+ $ migrated ++;
110+ } catch (Throwable $ e ) {
111+ $ output ->warning (sprintf ('Config migration: failed to migrate app %s key %s (row id=%d): %s — skipping ' , $ appId , $ configKey , $ lastId , $ e ->getMessage ()));
97112 $ failed ++;
98- continue ;
99113 }
100- $ this ->appConfig ->setValueString ($ appId , $ configKey , $ value , lazy: true , sensitive: $ sensitive );
101- $ migrated ++;
102- } catch (Throwable $ e ) {
103- $ output ->warning (sprintf ('Config migration: failed to migrate app %s key %s (row id=%d): %s — skipping ' , $ appId , $ configKey , (int )$ row ['id ' ], $ e ->getMessage ()));
104- $ failed ++;
105114 }
106115 }
107116
@@ -112,32 +121,36 @@ private function migrateAppConfig(IOutput $output): int {
112121 private function migratePreferences (IOutput $ output ): int {
113122 $ migrated = $ skipped = $ failed = 0 ;
114123
115- foreach ($ this ->fetchRows ('preferences_ex ' , ['id ' , 'userid ' , 'appid ' , 'configkey ' , 'configvalue ' , 'sensitive ' ]) as $ row ) {
116- $ userId = (string )$ row ['userid ' ];
117- $ appId = (string )$ row ['appid ' ];
118- $ configKey = (string )$ row ['configkey ' ];
119- $ sensitive = (int )($ row ['sensitive ' ] ?? 0 ) === 1 ;
120-
121- try {
122- if ($ this ->userConfig ->hasKey ($ userId , $ appId , $ configKey , null )) {
123- $ skipped ++;
124- continue ;
125- }
126- $ value = $ this ->decryptLegacyValue ((string )($ row ['configvalue ' ] ?? '' ), $ sensitive );
127- if ($ value === null ) {
128- $ output ->warning (sprintf ('Preferences migration: failed to decrypt sensitive value for user %s app %s key %s (row id=%d) — skipping ' , $ userId , $ appId , $ configKey , (int )$ row ['id ' ]));
124+ $ lastId = 0 ;
125+ while (($ rows = $ this ->fetchBatch ('preferences_ex ' , ['userid ' , 'appid ' , 'configkey ' , 'configvalue ' , 'sensitive ' ], $ lastId )) !== []) {
126+ foreach ($ rows as $ row ) {
127+ $ lastId = (int )$ row ['id ' ];
128+ $ userId = (string )$ row ['userid ' ];
129+ $ appId = (string )$ row ['appid ' ];
130+ $ configKey = (string )$ row ['configkey ' ];
131+ $ sensitive = (int )($ row ['sensitive ' ] ?? 0 ) === 1 ;
132+
133+ try {
134+ if ($ this ->userConfig ->hasKey ($ userId , $ appId , $ configKey , null )) {
135+ $ skipped ++;
136+ continue ;
137+ }
138+ $ value = $ this ->decryptLegacyValue ((string )($ row ['configvalue ' ] ?? '' ), $ sensitive );
139+ if ($ value === null ) {
140+ $ output ->warning (sprintf ('Preferences migration: failed to decrypt sensitive value for user %s app %s key %s (row id=%d) — skipping ' , $ userId , $ appId , $ configKey , $ lastId ));
141+ $ failed ++;
142+ continue ;
143+ }
144+ $ this ->userConfig ->setValueString (
145+ $ userId , $ appId , $ configKey , $ value ,
146+ lazy: true ,
147+ flags: $ sensitive ? IUserConfig::FLAG_SENSITIVE : 0 ,
148+ );
149+ $ migrated ++;
150+ } catch (Throwable $ e ) {
151+ $ output ->warning (sprintf ('Preferences migration: failed to migrate user %s app %s key %s (row id=%d): %s — skipping ' , $ userId , $ appId , $ configKey , $ lastId , $ e ->getMessage ()));
129152 $ failed ++;
130- continue ;
131153 }
132- $ this ->userConfig ->setValueString (
133- $ userId , $ appId , $ configKey , $ value ,
134- lazy: true ,
135- flags: $ sensitive ? IUserConfig::FLAG_SENSITIVE : 0 ,
136- );
137- $ migrated ++;
138- } catch (Throwable $ e ) {
139- $ output ->warning (sprintf ('Preferences migration: failed to migrate user %s app %s key %s (row id=%d): %s — skipping ' , $ userId , $ appId , $ configKey , (int )$ row ['id ' ], $ e ->getMessage ()));
140- $ failed ++;
141154 }
142155 }
143156
@@ -162,15 +175,21 @@ private function decryptLegacyValue(string $rawValue, bool $sensitive): ?string
162175 }
163176
164177 /**
165- * Materialize the full rowset up front: iterating a forward-only cursor while writing config
166- * (which touches the DB on the same connection) is undefined across drivers.
178+ * Fetch up to {@see BATCH_SIZE} rows whose id is greater than `$afterId`, ordered by id, then
179+ * close the cursor before the caller issues any writes. Keyset pagination keeps memory bounded on
180+ * large tables and avoids holding a forward-only cursor open while writing config on the same
181+ * connection. The backfill never modifies the legacy tables, so paging over them is stable.
167182 *
168183 * @param string[] $columns
169184 * @return list<array<string,mixed>>
170185 */
171- private function fetchRows (string $ table , array $ columns ): array {
186+ private function fetchBatch (string $ table , array $ columns, int $ afterId ): array {
172187 $ qb = $ this ->connection ->getQueryBuilder ();
173- $ qb ->select (...$ columns )->from ($ table );
188+ $ qb ->select ('id ' , ...$ columns )
189+ ->from ($ table )
190+ ->where ($ qb ->expr ()->gt ('id ' , $ qb ->createNamedParameter ($ afterId , IQueryBuilder::PARAM_INT )))
191+ ->orderBy ('id ' , 'ASC ' )
192+ ->setMaxResults (self ::BATCH_SIZE );
174193 $ cursor = $ qb ->executeQuery ();
175194 $ rows = $ cursor ->fetchAll ();
176195 $ cursor ->closeCursor ();
0 commit comments