Lulucat

ഞങ്ങൾ ഇതുവരെ ഒരു ഡാറ്റാബേസ് ഉപയോഗിക്കാത്തത് എന്തുകൊണ്ട്

Gaoge ZhangGaoge Zhang

ഞങ്ങളുടെ iPad കൈയെഴുത്ത് ആപ്പിനായി Turso/libSQL വിലയിരുത്തി, എല്ലാം അളന്ന്, ഒടുവിൽ flat snapshot files തിരഞ്ഞെടുത്തു. ഈ workload-ന് ഒരു ഡാറ്റാബേസ് ആവശ്യമില്ല — ഓരോ ഘട്ടവും ഇതിനകം നിലനിൽക്കുന്ന പ്രശ്നങ്ങൾക്ക് മാത്രം ചെലവഴിക്കണം.

Lulucat Notes iPad-നുള്ള ഒരു കൈയെഴുത്ത് ആപ്പാണ്. കഴിഞ്ഞ ആഴ്ച വരെ ഇതിന് ഒരു canvas മാത്രമേ ഉണ്ടായിരുന്നുള്ളൂ; രണ്ടാമത്തെ note എന്ന ആശയം പോലും ഉണ്ടായിരുന്നില്ല. ഇപ്പോൾ ഒന്നിലധികം documents ഉള്ള, ഓരോന്നിലും ഒന്നിലധികം pages ഉള്ള ഒരു note library ചേർക്കാനിരിക്കുകയായിരുന്നു — അപ്പോൾ ആദ്യം ഉയർന്ന architectural ചോദ്യം storage ആയിരുന്നു.

ഒരു database വ്യക്തമായ ഉത്തരമെന്നുപോലെ തോന്നി. Notes apps structured data store ചെയ്യുന്നു. Structured data databases-ലാണ് പോകുന്നത്. ഞങ്ങൾ Tursoയും അതിന്റെ Swift SDKയും വിലയിരുത്തി, iOS simulator-ൽ പ്രവർത്തിപ്പിച്ചു, യഥാർത്ഥ stroke data benchmark ചെയ്തു, പിന്നെ അത് ഉപയോഗിക്കേണ്ടതില്ലെന്ന് തീരുമാനിച്ചു.

പകരം flat files തിരഞ്ഞെടുത്തു. ഞങ്ങൾ എന്താണ് കണ്ടെത്തിയത്, ആ തീരുമാനം എടുത്തത് എന്തുകൊണ്ടാണ് എന്നത് ഇതാ.

മരപ്പട്ടികയിൽ കൈയെഴുത്ത് കുറിപ്പുകളും പേനയും ഉള്ള തുറന്ന നോട്ട്‌ബുക്ക്.

Gabriel Cox-ന്റെ ഫോട്ടോ, Unsplash-ൽ. Unsplash License.

ആപ്പ് data ഉപയോഗിച്ച് യഥാർത്ഥത്തിൽ എന്താണ് ചെയ്യുന്നത്

ഒരു കൈയെഴുത്ത് ആപ്പിന്റെ data access pattern ഇടുങ്ങിയതും പ്രവചിക്കാവുന്നതുമാണ്. വായിക്കുക എന്നത് ഒരു page തുറന്ന് അതിലെ എല്ലാ elements-ഉം — എല്ലാ strokes-ഉം എല്ലാ images-ഉം — ഒരേസമയം memory-യിലേക്ക് load ചെയ്യുക എന്നതാണ്. Canvas എല്ലാം കൈവശം വയ്ക്കുന്നു; അത് ഒരിക്കലും partial query പ്രവർത്തിപ്പിക്കുന്നില്ല. എഴുതുക എന്നത് ഒരു pen stroke പൂർത്തിയാക്കി page-ലേക്ക് ഒരു element append ചെയ്യുക എന്നതാണ്. അപൂർവ സന്ദർഭങ്ങളിൽ user ഒരു stroke-ന്റെ ഭാഗം erase ചെയ്യുകയോ selection move ചെയ്യുകയോ എന്തെങ്കിലും delete ചെയ്യുകയോ ചെയ്യും; പക്ഷേ അവയും single-page, single-element operations തന്നെയാണ്.

Concurrent access ഇല്ല. ഒരാൾ ഒരേ സമയം ഒരു document-ന്റെ ഒരു page-ലാണ് എഴുതുന്നത്. Cross-document search ഇല്ല — ഓരോ document-നും title, timestamp, page count, cover thumbnail എന്നിവ മാത്രം note library-യ്ക്ക് ആവശ്യമാണ്; ഇവയിൽ ഒന്നിനും page content വായിക്കേണ്ടതില്ല.

Queries, indexes, concurrency coordination എന്നിവയ്ക്കുവേണ്ടിയാണ് databases നിർമ്മിച്ചിരിക്കുന്നത്. ഞങ്ങളുടെ ആപ്പ് ഈ മൂന്നിലൊന്നും ഉപയോഗിക്കുന്നില്ല.

Turso-യുടെ വിലയിരുത്തൽ

Turso-യുടെ libSQL engine-നുള്ള ഔദ്യോഗിക Swift SDK ആയ libsql-swift ഞങ്ങൾ വിലയിരുത്തി.

SDK പ്രവർത്തിക്കുന്നു. അതിന്റെ എല്ലാ 9 test cases-ഉം pass ചെയ്യുന്നു. ആപ്പിന്റെ ഒരു copy-യിലേക്ക് അത് integrate ചെയ്തു, iOS simulator-നായി build ചെയ്തു, launch ചെയ്തു, app sandbox-ൽ ഒരു local database ഉണ്ടാക്കി. ഒരൊറ്റ transaction-ൽ, ഓരോന്നിലും 3,400 sampling points ഉള്ള 100 strokes എഴുതി — 4,080,000 bytes BLOB data. ഞങ്ങളുടെ development Mac-ൽ ഇതിന് ഏകദേശം 0.019 seconds എടുത്തു.

PRAGMA wal_checkpoint(TRUNCATE) പ്രവർത്തിപ്പിച്ച ശേഷം WAL file zero ആയി ചുരുങ്ങി. തുടർന്ന് പ്രധാന .db file മാത്രം മറ്റൊരു location-ലേക്ക് copy ചെയ്ത്, അത് open ചെയ്ത്, എല്ലാ data-യും വീണ്ടും വായിക്കാനായി. Engine സ്വയം sound ആണ്.

SDK-യ്ക്ക് costs ഉണ്ട്. CLibsql.xcframework-ന് 161 MB വലുപ്പമുണ്ട്. Link ചെയ്ത ശേഷം ഞങ്ങളുടെ Debug simulator build ഏകദേശം 1.9 MB-ൽ നിന്ന് ഏകദേശം 8.2 MB ആയി. API synchronous-യും blocking-ഉം ആണ്; Swift Concurrency wrappers ഇല്ല. Explicit close() method ഇല്ല. Transaction.commit() throw ചെയ്യുന്നില്ല — underlying C API void return ചെയ്യുന്നു. Repository-യുടെ README SDK-യെ “technical preview” എന്ന് label ചെയ്യുന്നു; ഞങ്ങളുടെ evaluation-ന് ഏകദേശം ഒരു വർഷം മുമ്പ്, July 2025-ലായിരുന്നു ഏറ്റവും പുതിയ commit.

Turso ecosystem-ൽ ഒരു gap ഉണ്ട്. പുതിയ projects-ക്കായി Turso ഇപ്പോൾ പുതിയ “Turso Database” engine-ഉം “Turso Sync” protocol-ഉം ശുപാർശ ചെയ്യുന്നു. Turso Sync-ന് TypeScript, Python, Go, Rust എന്നിവയ്ക്കായി client SDKs ഉണ്ട്. Swift-നായി ഒന്നില്ല. പഴയ Embedded Replica mode libsql-swift-ൽ നിലവിലുണ്ട്, പക്ഷേ പൂർണ്ണമായും local-first ആയ mobile app-ന് ആവശ്യമായ offline parameter അതിന്റെ Swift initializer expose ചെയ്യുന്നില്ല. ഇന്ന് libsql-swift സ്വീകരിച്ചാൽ ഞങ്ങൾക്ക് ഒരു local SQLite fork ലഭിക്കും; Turso-യെ വ്യത്യസ്തമാക്കുന്ന synchronization capabilities ലഭിക്കില്ല.

ഇപ്പോൾ ഒരു database ഞങ്ങൾക്ക് ഉണ്ടാക്കുന്ന ചെലവ്

SDK mature ആയിരുന്നാലും, ഞങ്ങളുടെ workload-ന് ഒന്നും വാങ്ങിക്കൊടുക്കാത്ത costs ഞങ്ങൾ തുടർന്നും വഹിക്കേണ്ടിവരും:

WAL sidecar ഫയലുകളുടെ മാനേജ്മെന്റ്. പ്രവർത്തിക്കുന്ന database -wal, -shm companion files സൃഷ്ടിക്കുന്നു. ഒരു document copy ചെയ്യാൻ ആദ്യം checkpoint ചെയ്യുകയോ, അല്ലെങ്കിൽ മൂന്ന് files-ഉം atomically copy ചെയ്യുകയോ വേണം. ഒരു .lnote package Files-ലേക്കോ AirDrop-ലേക്കോ export ചെയ്യാൻ user-ന് കാണാനാകാത്തതും developer മറക്കാൻ പാടില്ലാത്തതുമായ ഒരു pre-export step ഇനി ആവശ്യമായി വരും.

ഒരു adapter layer. Strokes BLOB-കളാക്കി serialize ചെയ്ത് വീണ്ടും deserialize ചെയ്യണം. Page elements-ന് canvas നേരിട്ട് render ചെയ്യുന്ന natural array order ഉണ്ട്; ഒരു database row ordering-ഉം z-index columns-ഉം കൊണ്ടുവരും. ഒരേ data-യുടെ രണ്ട് representations തമ്മിൽ ഒരു translation layer എഴുതുകയും ഓരോ schema change-ലും അത് maintain ചെയ്യുകയും വേണം.

161 MB വലുപ്പമുള്ള dependency. Debug build 2 MB-ൽ താഴെയുള്ള ഒരു ആപ്പിന്, ആപ്പിനേക്കാൾ 80× വലുതായ dependency ശ്രദ്ധിക്കേണ്ട ഒരു cost ആണ് — പ്രത്യേകിച്ച് ഒരു വർഷമായി inactivity ഉള്ള “technical preview” എന്ന് label ചെയ്ത dependency ആണെങ്കിൽ.

ഈ costs hypothetical അല്ല. Dependency link ചെയ്യുന്ന നിമിഷം മുതൽ അവ ആരംഭിക്കുന്നു. ഞങ്ങളുടെ app ഉപയോഗിക്കാത്ത capabilities — querying, indexing, concurrent writes — ആണ് അവ വാങ്ങിത്തരുന്നത്.

ഞങ്ങൾ പുറത്തിറക്കിയ പരിഹാരം: snapshot file packages

ഒരു .lnote document ഒരു directory package ആണ്:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json ആണ് document structure-ന്റെ source of truth: അതിന്റെ ID, title, timestamps, കൂടാതെ ഓരോ page-ന്റെയും canvas size, timestamps, element count എന്നിവയുള്ള ordered list of pages. Page content files app ഇതിനകം ഉപയോഗിക്കുന്ന അതേ quantized integer encoding-ൽ element array store ചെയ്യുന്നു — coordinates-ഉം radii-ഉം 0.1-point precision-ൽ, pressure thousandths-ൽ, timestamps relative milliseconds-ൽ.

Note library manifest.json-ഉം cover thumbnails-ഉം മാത്രം വായിക്കുന്നു. document.json-നെയോ ഏതെങ്കിലും page content-നെയോ അത് ഒരിക്കലും parse ചെയ്യുന്നില്ല. ഒരു page തുറക്കുമ്പോൾ ഒരു .content file load ചെയ്യും. Stroke data-യെ സ്പർശിക്കുന്ന ഏക file read അതാണ്.

പേജുകൾ write amplification പരിഹരിക്കുന്നു

ഒരു കൈയെഴുത്ത് app-ന് page എന്ന concept ഇതിനകം ഉണ്ട് — user ചിന്തിക്കുന്ന unit അതാണ്, swipe ചെയ്ത് മാറുന്ന വസ്തുവും അതാണ്. Page-നെ persistence unit ആക്കിയാൽ auto-save മാറിയ pages മാത്രം rewrite ചെയ്യും.

ഒരു കൈയെഴുത്ത് page — പറയുകയാണെങ്കിൽ 1,000 മുതൽ 2,000 strokes — ഞങ്ങളുടെ quantized format-ൽ ഏകദേശം 3 മുതൽ 5 MB വരെ എടുക്കും. 3,400 sampling points ഉള്ള ഒരു 21-stroke recording quantize ചെയ്താൽ ഏകദേശം 55 KB ആകും. ആധുനിക hardware-ൽ ഒരു page snapshot flash storage-ലേക്ക് എഴുതാൻ 10 മുതൽ 20 milliseconds എടുക്കും. 0.5-second debounce ഉപയോഗിക്കുമ്പോൾ saves user-ന് കാണാനാകില്ല.

Save cost document-ലുള്ള മൊത്തം pages-ന്റെ എണ്ണത്തോടല്ല, ഇപ്പോഴത്തെ page-ൽ എഴുതിയിരിക്കുന്ന അളവിനോടാണ് scale ചെയ്യുന്നത്. 200-page notebook, 2-page notebook പോലെ തന്നെ വേഗത്തിൽ save ചെയ്യും; dirty page മാത്രം rewrite ചെയ്യുന്നതിനാലാണ് അത്.

ഓരോ write-ഉം atomic file operations ഉപയോഗിക്കുന്നു — ഒരു temporary file-ലേക്ക് write ചെയ്ത്, പിന്നെ rename ചെയ്യുന്നു — അതിനാൽ save നടക്കുമ്പോൾ crash ഉണ്ടായാലും truncated page ഉണ്ടാകില്ല. Background-ലേക്ക് പ്രവേശിക്കുമ്പോൾ എല്ലാ dirty pages-ഉം ഉടൻ flush ചെയ്യും; ഒരു canvas മാത്രമുണ്ടായിരുന്നപ്പോൾ app-ന് ഉണ്ടായിരുന്ന behavior ഇതുതന്നെയാണ്.

Transactions ഇല്ലാത്ത consistency

File packages-ന് transactions ഇല്ല, പക്ഷേ അതേ purpose നിറവേറ്റുന്ന വ്യക്തമായ ownership rules ഉണ്ട്:

References-ന് മുമ്പ് resources. User ഒരു image insert ചെയ്യുമ്പോൾ asset file ഉടൻ assets/-ലേക്ക് എഴുതുന്നു. Asset-നെ ID ഉപയോഗിച്ച് reference ചെയ്യുന്ന page snapshot debounced auto-save പിന്നീട് എഴുതും. Disk-ൽ ഇല്ലാത്ത asset-നെ page reference ചെയ്യുന്ന അവസ്ഥ ഒരിക്കലും ഉണ്ടാകില്ല.

Source of truth ആണ് നിർണായകം. document.json-ഉം pages/ directory-യും source of truth ആണ്. manifest.json ഒരു cache ആണ്. അവ തമ്മിൽ പൊരുത്തപ്പെടുന്നില്ലെങ്കിൽ അടുത്ത save cache-നെ source-നോട് പൊരുത്തപ്പെടുന്ന വിധം reconcile ചെയ്യും. Thumbnails derived ആയതിനാൽ എപ്പോൾ വേണമെങ്കിലും regenerate ചെയ്യാം.

Dangling references-നേക്കാൾ orphans നല്ലത്. ഒരു crash-ന്റെ ഏറ്റവും മോശം ഫലം ഒരു orphan asset ആണ് — assets/-ലുള്ള, ഒരു page-ഉം reference ചെയ്യാത്ത file. Document close ചെയ്യുമ്പോൾ orphans clean up ചെയ്യും. മറുവശത്ത് — page ഒരു missing file reference ചെയ്യുന്നത് — സംഭവിക്കില്ല, കാരണം reference ചെയ്യുന്ന page snapshot-ന് മുമ്പ് assets എഴുതപ്പെടുന്നു.

ഈ rules WAL checkpointing-നും transaction isolation-നേക്കാൾ മനസ്സിലാക്കാൻ ലളിതമാണ്, കൂടാതെ app-ന്റെ single-process, single-page access pattern-നോട് കൃത്യമായി പൊരുത്തപ്പെടുന്നു.

Upgrade path രേഖപ്പെടുത്തിയിട്ടുണ്ട്

ഇപ്പോൾ flat files തിരഞ്ഞെടുക്കുന്നു എന്നത് flat files എന്നും തിരഞ്ഞെടുക്കും എന്നല്ല. Package structure ഇങ്ങനെ രൂപകൽപ്പന ചെയ്തതാണ്: storage engine upgrade ചെയ്താൽ package തന്നെ മാറാതെ അതിനുള്ളിലെ കാര്യങ്ങൾ മാത്രം മാറും.

Level 1: snapshot + append journal. Write amplification ഒരിക്കൽ ശ്രദ്ധിക്കാവുന്നതായാൽ — ഉദാഹരണത്തിന് ആയിരക്കണക്കിന് strokes ഉള്ള page-ൽ തുടർച്ചയായി എഴുതുമ്പോൾ save delay ശ്രദ്ധയിൽപ്പെടുകയാണെങ്കിൽ — ഓരോ page file-ഉം snapshot, append-only journal എന്നിങ്ങനെ split ചെയ്യും. പുതിയ elements [length][CRC][type][payload] frames ആയി append ചെയ്യും. Journal replay ചെയ്യുമ്പോൾ CRC match ചെയ്യാത്ത frame discard ചെയ്യും; ഇതിലൂടെ crash safety ലഭിക്കും. Journal threshold കടക്കുകയോ page close ചെയ്യുകയോ ചെയ്താൽ അത് snapshot-ലേക്ക് merge ചെയ്യും. External dependencies ഒന്നുമില്ലാതെ ഏകദേശം 200 lines of code മതി.

Page-level snapshots cross-page write amplification ഇതിനകം ഒഴിവാക്കുന്നതിനാൽ, ഈ level ഏറെക്കാലം ആവശ്യമില്ലായിരിക്കാം. ഓരോ 0.5 seconds-ലും 5 MB page rewrite ചെയ്യുന്നത് flash write budgets-നുള്ളിൽ വളരെ സുരക്ഷിതമാണ്.

Level 2: SQLite database. App-ന് ഒരിക്കൽ notes-ലുടനീളം full-text search, per-element synchronization, അല്ലെങ്കിൽ cross-document indexing ആവശ്യമാകുകയാണെങ്കിൽ SQLite ശരിയായ tool ആയിരിക്കും. അപ്പോൾ സാധ്യതയുള്ള engine GRDB ആണ് — mature, source-compiled Swift wrapper, near-zero binary size overhead ഉള്ളത്. Turso ecosystem — പ്രത്യേകിച്ച് Swift-നുള്ള Turso Sync — യഥാർത്ഥ product need ആകുമ്പോൾ മാത്രമേ libsql-swift വീണ്ടും പരിഗണിക്കൂ.

Migration path യാന്ത്രികമാണ്: ഓരോ page-ന്റെയും element array strokes / images table-ലേക്ക് map ചെയ്യും; ഓരോ stroke-നും ഒരു immutable BLOB (little-endian binary-ൽ sampling point-ന് 16 bytes). 100 strokes-നുള്ള 0.019-second benchmark ഈ approach viable ആണെന്ന് ഉറപ്പാക്കുന്നു. Ship ചെയ്യുന്നതിന് മുമ്പ് migration പഴയ package-നും പുതിയ database-നും ഇടയിലെ element counts, asset counts match ചെയ്യുന്നുണ്ടോ എന്നും crash recovery, WAL bounds, background flush എന്നിവയെല്ലാം pass ചെയ്യുന്നുണ്ടോ എന്നും verify ചെയ്യും.

എപ്പോൾ ചെലവഴിക്കണം

ഈ തീരുമാനം databases-നെതിരായ വിധിയല്ല. SQLite shared state-ലുള്ള concurrent writers, complex queries, crash recovery എന്നിവ കൈകാര്യം ചെയ്യും — ഇപ്പോൾ ഞങ്ങളുടെ app-ന് ഇവയിൽ ഒന്നും ആവശ്യമില്ല. App-ന് ഇതുവരെ ഉണ്ടായിട്ടില്ലാത്ത പ്രശ്നങ്ങൾ പരിഹരിക്കുന്ന capabilities-ക്ക് മുൻകൂട്ടി ചെലവിടുന്നത് net loss ആണ്.

ഒരു database-ന്റെ costs — dependency, WAL management, adapter layer, binary size — library link ചെയ്യുന്ന നിമിഷം മുതൽ ആരംഭിക്കുന്നു. App-ന് run ചെയ്യാനുള്ള queries, maintain ചെയ്യാനുള്ള indexes, coordinate ചെയ്യാനുള്ള concurrent writers എന്നിവ ഉണ്ടാകുമ്പോഴാണ് benefits തുടങ്ങുന്നത്. ഈ ഘട്ടത്തിൽ മൂന്നിലും ഒന്നും app-ന് ഇല്ല.

ഞങ്ങളുടെ storage evolution-ലെ ഓരോ ഘട്ടവും ഇതിനകം പ്രത്യക്ഷപ്പെട്ട പ്രശ്നങ്ങൾക്ക് മാത്രം ചെലവിടും. Page-level snapshots ഇന്ന് ഞങ്ങൾക്കുള്ള പ്രശ്നത്തിന് ചെലവിടുന്നു: മുഴുവൻ file rewrite ചെയ്യാതെ multi-page documents save ചെയ്യുക. Write amplification അളക്കാനാകുന്നതായാൽ append journal ആ പ്രശ്നത്തിന് ചെലവിടും. Search അല്ലെങ്കിൽ sync product need ആണെങ്കിൽ database ആ പ്രശ്നത്തിന് ചെലവിടും.

Package structure, manifest, document schema എന്നിവ ഒരു storage engine-നോടും ബന്ധിക്കപ്പെട്ടിട്ടില്ല. Boundaries ശരിയായ ഇടത്തായതിനാൽ switching costs കുറവാണ്. ഒരു database ശരിക്കും ആവശ്യമാകുന്ന ദിവസം വന്നാൽ, hypothetical problem-നായി അല്ല — ഇതിനകം അളന്ന specific problem-നായി — അത് സ്വീകരിക്കും.