Hoe een vier jaar oude database duizenden mensen hun app liet crashen.
Na maanden werken was het eindelijk zover om de Android app te releasen naar de vele honderdduizenden gebruikers. De app kent reeds een lange bestaansgeschiedenis, en was al in omloop in de tijd toen "Windows Phone" nog relevant was. Omdat er destijds drie platformen ondersteund moesten worden is er gekozen om de app cross-platform via Xamarin te bouwen. Toen een goede keuze, omdat er slechts één codebase nodig is om alle drie de platformen te kunnen faciliteren. Met het wegvallen van Windows Phone is in 2018 de app over gegaan op twee native builds. Dat wil zeggen dat de app apart voor iOS en Android compleet opnieuw gebouwd is. Dit zodat er efficiëntie slagen en specifiekere toepassingen voor elk van deze platformen gerealiseerd kunnen worden. Omdat de app in 2018 al veel gebruikers had, die onder andere favorieten hadden opgeslagen in een SQLite database, is er besloten om de app compleet opnieuw op te bouwen i.c.m. de reeds bestaande database. Op deze manier merkt de gebruiker niet veel van de technische veranderingen en zal de klantenservice van de eigenaar van de app niet overspoeld worden met vragen. Deze oplossing werkte jaren goed, ook na meerdere app updates met oplossingen voor fouten en nieuwe functionaliteiten. Echter ging het na een uitrol in december 2021 mis.
De foutNadat de app naar de eerste gebruikers uitgerold werd kwamen de foutmeldingen snel binnendruppelen in de Google Play Console. De foutmelding ziet er als volgt uit:
exception.class.missing._Unknown_: TableInfo{name='FavoriteVehicle', columns={Description=Column{name='Description', type='NVARCHAR (255)', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='null'}, VehicleType=Column{name='VehicleType', type='NVARCHAR (100)', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='null'}, License=Column{name='License', type='NVARCHAR (8)', affinity='2', notNull=false, primaryKeyPosition=1, defaultValue='null'}, LastUpdated=Column{name='LastUpdated', type='DATETIME', affinity='1', notNull=true, primaryKeyPosition=0, defaultValue='null'}, Name=Column{name='Name', type='NVARCHAR (255)', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='null'}}, foreignKeys=[], indices=[]} at androidx.room.RoomOpenHelper.onUpgrade (RoomOpenHelper.java) at androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.onUpgrade (FrameworkSQLiteOpenHelper.java) at android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked (SQLiteOpenHelper.java:489) at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase (SQLiteOpenHelper.java:387) at androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getWritableSupportData base (FrameworkSQLiteOpenHelper.java:4) at androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper.getWritableDatabase (FrameworkSQLiteOpenHelper.java:4) at androidx.room.RoomDatabase.inTransaction (RoomDatabase.java:2) at androidx.room.RoomDatabase.assertNotSuspendingTransaction (RoomDatabase.java) at dev.roffel.example.cars.getAll (Unknown Source:9) at at dev.roffel.example.cars.FavoriteVehicleViewModel$fetch$1.run (RecentCarsViewModel.java)
Het vreemde is dat zowel de medewerkers van de appontwikkelaar als medewerkers van de opdrachtgever de fout niet kunnen reproduceren. Wel zijn er meer dan 1000 gebruikers die last hebben van dit probleem.
Vanwege het grote aantal foutmeldingen zonder tot nu toe voor de hand liggende reden is er reden om een uitgebreide diagnose uit te voeren. Een paar dingen die tussen de meldingen opvallen:
- Veel foutmeldingen komen vanaf Samsung apparaten → Dit zegt niet veel want dit is ook wat het meeste in omloop is.
- De meeste foutmeldingen komen van apparaten met Android 11 of 12 → Kan wijzen op een verandering in de targetSdk
- De foutmeldingen komen veelal van apparaten uit 2018 en 2019 → 2018 Was het jaar dat de app over ging naar native
Diagnostisering en oorzaak
Het uitzoeken waar de fout vandaan komt kostte het meeste tijd. De voornaamste reden hiervoor is dat de fout “unknown” (nl: onbekend) is. De fout geeft wel aan dat er ergens VARCHAR-velden gebruikt worden terwijl de app TEXT-velden verwacht.
In de broncode van de app is echter nergens gebruik gemaakt van een VARCHAR-veld. Ook in de geschiedenis van de code, die inzichtelijk is via GIT-versiemanagementsoftware, is geen spoor te vinden van een VARCHAR-veld. Zonder bron is het moeilijk om een softwareprobleem op te lossen.
Omdat de app al langer in omgang is dan wat er in GIT geregistreerd is, is er besloten om alle versies van de app uit de Google Play bibliotheek te downloaden. Vervolgens is elke app, in volgorde van uitgave, geïnstalleerd op de telefoon. Wanneer de app al geïnstalleerd is zal dit leiden tot een update.
Hierdoor werd er gestart met een zogenoemde Xamarin versie van de app. Deze app werd geüpdatet tot en met de meest recente (native) versie. In alle versies bleef de app met de Xamarin database werken. Echter crashte de meest recente versie van de app zodra deze werd geopend met een onderliggende database uit de tijd dat de app via Xamarin werd geïnstalleerd.
In de eerste niet Xamarin versie van de app vindt er een migratieproces plaats om alle data compatibel te maken met Android Native. Dit beslaat ook de database. Echter worden hier VARCHAR-velden niet naar TEXT over gezet. Nieuwe installaties krijgen wel de TEXT-velden. Dit omdat dit gedefinieerd is in de basisdefinitie.
In de nieuwste versie van de app is nieuwe technologie gebruikt die alleen beschikbaar is voor Android 11 en nieuwer. Daarom is de targetSDK, de compatibiliteitslaag, geüpdatet naar officiële Android 12 ondersteuning. Klaarblijkelijk gebruikt Android 12 Strict SQLite. Dit is hetzelfde als SQLite, echter met strenger afgedwongen regels. Wanneer een kolomtype niet juist overeenkomt met de configuratie zal en een fout optreden.
Een check als bovenstaand omschreven werkt vrij simpel. Het Androidbesturingssysteem pakt de kolomdefinitie, in dit geval “TEXT”, en vergelijkt of deze precies gelijk is aan de definitie van de kolom in de database. In dit geval “VARCHAR”. Omdat deze waarden niet gelijk zijn zal er een fout optreden. Zelfs als dit in de praktijk niet uit moet maken omdat zowel VARCHAR als TEXT dezelfde tekensets gebruiken.
De oplossing
Het aanpassen van het kolomtype in SQLite is niet mogelijk. Om dit probleem op te lossen, moeten de volgende stappen worden gevolgd:
- Maak een nieuwe tabel met dezelfde velden. Zorg dat de primaire sleutel (engels: primary key, pk) altijd geannoteerd is al “NOT NULL” en dat de kolomtypen van het type “TEXT” zijn. Geef deze een andere naam dan de originele tabel
- Gebruik een select statement dat alle velden selecteert en wegschrijft in de nieuwe tabel
- Verwijder de oude tabel
- Hernoem de nieuwe tabel naar de naam van de oude tabel
- Hoog de database versie op
Voorkomen is beter dan genezen
Om te voorkomen dat dit probleem zich in de toekomst voordoet, worden er exports gemaakt van alle databaseversies bij elke app-update. Deze databases worden vervolgens geladen in unit tests om te controleren of de database de CRUD-operaties (Create, Read, Update, Delete) kan uitvoeren en of een update naar een nieuwe versie van de app/database-definitie succesvol kan worden uitgevoerd. Door deze tests automatisch uit te voeren bij elke code-wijziging in het versiebeheersysteem, kan tijdig worden gedetecteerd of de database in de app nog naar behoren werkt voordat gebruikers ermee te maken krijgen.