Expiry / TTL
Every Sqkon row carries an optional expires_at timestamp. The library never
deletes expired rows for you — you decide when to filter them out on read and
when to purge them. That separation keeps cache semantics flexible: a row can
be “expired but still useful as fallback” until you say otherwise.
- Setting expiry
- Filtering on read
- Cleanup with
deleteExpired - Stale data with
deleteStale - Verbatim test snippet
- See also
Setting expiry
Pass expiresAt: Instant? to any write. We recommend computing it from
Clock.System.now() plus a kotlin.time.Duration:
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
merchants.insert(
key = "m_1",
value = Merchant(id = "m_1", name = "Chipotle", category = "Food"),
expiresAt = Clock.System.now() + 1.hours,
)
// Same parameter on update / upsert / insertAll / updateAll / upsertAll
merchants.upsert("m_1", merchant, expiresAt = Clock.System.now() + 24.hours)
merchants.insertAll(batch, expiresAt = Clock.System.now() + 5.minutes)
Omitting expiresAt (or passing null) stores the row with no expiry.
Filtering on read
Reads do not filter expired rows by default. Pass expiresAfter =
Clock.System.now() to skip rows whose expires_at is in the past:
val live = merchants.selectAll(expiresAfter = Clock.System.now()).first()
val liveFood = merchants.select(
where = Merchant::category eq "Food",
expiresAfter = Clock.System.now(),
).first()
// Counts respect the same filter
val livingCount = merchants.count(expiresAfter = Clock.System.now()).first()
The same parameter exists on selectByKeys, select, selectResult,
selectPagingSource, and selectKeysetPagingSource.
Rows with expires_at = NULL are always returned regardless of expiresAfter.
Use expiry only for entries that should age out — not as a “soft delete”.
Lifecycle
flowchart TD
A[Insert with expiresAt = now+1h] --> B[Row stored]
B --> C{Read with expiresAfter=now?}
C -->|now < expiresAt| D[Returned]
C -->|now >= expiresAt| E[Filtered out]
F[Periodic deleteExpired] --> G[Row removed]
Cleanup with deleteExpired
Filtering on read keeps expired rows on disk. To free space, purge them:
// Purge everything in this store with expires_at < now.
merchants.deleteExpired() // defaults to Clock.System.now()
merchants.deleteExpired(expiresAfter = Clock.System.now())
Good places to schedule it:
- App startup — fire-and-forget on a background dispatcher.
- Android
WorkManagerperiodic — a daily job for caches that don’t see steady writes. - After a sync — call
deleteExpired()after aRemoteMediatorpopulates fresh data, so old generations don’t linger.
Stale data with deleteStale
Different from expiry: deleteStale works off read_at and write_at
timestamps Sqkon maintains automatically on every read/write. Use it for
LRU-style eviction — drop rows nobody has touched recently — even when those
rows have no expires_at.
import kotlin.time.Duration.Companion.days
// Delete rows not read AND not written in the last 24 hours
merchants.deleteStale(
writeInstant = Clock.System.now() - 1.days,
readInstant = Clock.System.now() - 1.days,
)
// Either parameter can be null to skip that condition
merchants.deleteStale(writeInstant = Clock.System.now() - 7.days, readInstant = null)
The default for both
writeInstantandreadInstantisClock.System.now(). With no arguments,deleteStaledeletes every row whosewrite_at < nowAND (read_at IS NULL OR read_at < now) — i.e. almost everything in the store, since nearly every row’s last write or read is in the past. Never calldeleteStale()with no arguments in production. Always pass explicit thresholds (typically a window likeClock.System.now() - 7.days).
Verbatim test snippet
From library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageExpiresTest.kt:
@Test
fun deleteExpired() = runTest {
val now = Clock.System.now()
val expected = (0..10).map { TestObject() }.associateBy { it.id }
testObjectStorage.insertAll(expected, expiresAt = now.minus(1.milliseconds))
val actual = testObjectStorage.selectAll().first() // all results
assertEquals(expected.size, actual.size)
testObjectStorage.deleteExpired(expiresAfter = now)
// No expires to return everything
val actualAfterDelete = testObjectStorage.selectAll().first()
assertEquals(0, actualAfterDelete.size)
}
Two things to notice:
selectAll()with noexpiresAfterstill returns the expired rows — they exist on disk until purged.- After
deleteExpired(now)they’re gone; the nextselectAll()is empty.
See also
- Flow —
expiresAfteron a Flow re-evaluates on every emission, but does not re-emit when wall-clock crosses anexpires_at. Snapshot at the moment of read. - Performance —
expires_atis indexed;expiresAfterfilters cheaply. - Source:
library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt(deleteExpired,deleteStale).