git.iko.soy/parseable-android
on master help sign in
Improve performance of log viewer and network layer (#26)

- Increase OkHttp connection pool from 5 to 16 to match the concurrent stats loading semaphore (8) and avoid connection bottlenecks - Optimize streaming list concatenation to avoid allocating a full intermediate list before capping to STREAMING_MAX_LOGS - Cache searchable (non-p_ prefixed) columns in LogViewerState so buildSearchClause avoids re-filtering on every refresh/poll cycle - Pre-compute stable log keys in ViewModel instead of during Compose composition to keep frame rendering fast https://claude.ai/code/session_01JbrrmFvYrP8NdJBFTpbE7N Co-authored-by: Claude <noreply@anthropic.com>

commite98fa286866e8b261b18d0fa798136f7b1c48a76 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 13:36 (7 months ago) parent6dc9af2
3 files changed+40−40
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+1−1 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+2−25 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+37−14
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+1−1
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
3939 coerceInputValues = true
4040 }
4141
42− private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
4343 private val sharedDispatcher = okhttp3.Dispatcher()
4444
4545 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+2−25
@@ -368,11 +368,6 @@ fun LogViewerScreen(
368368 }
369369 }
370370
371− // Pre-compute stable keys so each log is assigned exactly one key
372− val logKeys = remember(state.logs) {
373− stableLogKeys(state.logs)
374− }
375−
376371 LazyColumn(
377372 state = listState,
378373 contentPadding = PaddingValues(8.dp),
@@ -380,9 +375,9 @@ fun LogViewerScreen(
380375 ) {
381376 itemsIndexed(
382377 state.logs,
383− key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
378+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
384379 ) { index, logEntry ->
385− val key = logKeys.getOrElse(index) { "log_$index" }
380+ val key = state.logKeys.getOrElse(index) { "log_$index" }
386381 LogEntryCard(
387382 logEntry = logEntry,
388383 isExpanded = expandedLogKey == key,
@@ -792,24 +787,6 @@ fun LogEntryCard(
792787 }
793788}
794789
795−/**
796− * Produces deterministic keys for a list of log entries so that expanded state survives
797− * list mutations (e.g., new logs prepended during streaming). Keys are content-based
798− * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
799− */
800−private fun stableLogKeys(logs: List<JsonObject>): List<String> {
801− val seen = mutableMapOf<String, Int>()
802− return logs.map { log ->
803− val ts = log["p_timestamp"]?.toString()
804− val meta = log["p_metadata"]?.toString()
805− val tag = log["p_tags"]?.toString()
806− val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
807− val count = seen.getOrDefault(base, 0)
808− seen[base] = count + 1
809− if (count == 0) base else "$base|$count"
810− }
811−}
812−
813790private fun formatJsonValue(element: JsonElement): String {
814791 return when (element) {
815792 is JsonPrimitive -> element.content
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+37−14
@@ -59,7 +59,9 @@ data class SavedFiltersState(
5959data class LogViewerState(
6060 val streamName: String = "",
6161 val logs: List<JsonObject> = emptyList(),
62+ val logKeys: List<String> = emptyList(),
6263 val columns: List<String> = emptyList(),
64+ val searchableColumns: List<String> = emptyList(),
6365 val isLoading: Boolean = false,
6466 val error: String? = null,
6567 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -108,6 +110,23 @@ class LogViewerViewModel @Inject constructor(
108110 private const val LOAD_MORE_INCREMENT = 500
109111 private const val MAX_STREAMING_ERRORS = 5
110112 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
113+
114+ /**
115+ * Produces deterministic keys for a list of log entries so that expanded state
116+ * survives list mutations (e.g., new logs prepended during streaming).
117+ */
118+ internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
119+ val seen = mutableMapOf<String, Int>()
120+ return logs.map { log ->
121+ val ts = log["p_timestamp"]?.toString()
122+ val meta = log["p_metadata"]?.toString()
123+ val tag = log["p_tags"]?.toString()
124+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
125+ val count = seen.getOrDefault(base, 0)
126+ seen[base] = count + 1
127+ if (count == 0) base else "$base|$count"
128+ }
129+ }
111130 }
112131
113132 fun initialize(streamName: String) {
@@ -124,7 +143,8 @@ class LogViewerViewModel @Inject constructor(
124143 when (val result = repository.getStreamSchema(streamName)) {
125144 is ApiResult.Success -> {
126145 val columns = result.data.fields.map { it.name }.sorted()
127− _state.update { it.copy(columns = columns) }
146+ val searchable = columns.filter { !it.startsWith("p_") }
147+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
128148 }
129149 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
130150 }
@@ -236,9 +256,11 @@ class LogViewerViewModel @Inject constructor(
236256 val (startTime, endTime) = getTimeRange()
237257 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238258 is ApiResult.Success -> {
259+ val keys = computeLogKeys(result.data)
239260 _state.update {
240261 it.copy(
241262 logs = result.data,
263+ logKeys = keys,
242264 isLoading = false,
243265 hasMore = result.data.size >= it.currentLimit,
244266 )
@@ -253,13 +275,11 @@ class LogViewerViewModel @Inject constructor(
253275 }
254276 }
255277
256− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
278+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
257279 if (searchQuery.isBlank()) return null
280+ if (searchableColumns.isEmpty()) return null
258281 val safeSearch = escapeSql(searchQuery)
259− // Search across ALL non-internal columns (not just 5)
260− val searchFields = columns.filter { !it.startsWith("p_") }
261− if (searchFields.isEmpty()) return null
262− return searchFields.joinToString(" OR ") {
282+ return searchableColumns.joinToString(" OR ") {
263283 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
264284 }
265285 }
@@ -283,7 +303,7 @@ class LogViewerViewModel @Inject constructor(
283303 // Build WHERE clause from filters + search
284304 val clauses = current.filters.filterClauses.toMutableList()
285305 if (current.filters.searchQuery.isNotBlank()) {
286− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
306+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
287307 if (searchClause != null) {
288308 clauses.add("($searchClause)")
289309 } else {
@@ -304,9 +324,11 @@ class LogViewerViewModel @Inject constructor(
304324 limit = current.currentLimit,
305325 )) {
306326 is ApiResult.Success -> {
327+ val keys = computeLogKeys(result.data)
307328 _state.update {
308329 it.copy(
309330 logs = result.data,
331+ logKeys = keys,
310332 isLoading = false,
311333 hasMore = result.data.size >= it.currentLimit,
312334 )
@@ -415,7 +437,7 @@ class LogViewerViewModel @Inject constructor(
415437 // Build WHERE clause from active filters + search
416438 val clauses = current.filters.filterClauses.toMutableList()
417439 if (current.filters.searchQuery.isNotBlank()) {
418− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
440+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
419441 if (searchClause != null) {
420442 clauses.add(searchClause)
421443 }
@@ -441,15 +463,16 @@ class LogViewerViewModel @Inject constructor(
441463 }
442464
443465 _state.update { state ->
444− // Prepend new logs, cap total
445− val combined = newLogs + state.logs
446− val capped = if (combined.size > STREAMING_MAX_LOGS) {
447− combined.take(STREAMING_MAX_LOGS)
448− } else {
449− combined
466+ // Prepend new logs, cap total — avoid full intermediate list allocation
467+ val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
468+ val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
469+ addAll(newLogs)
470+ val oldLogs = state.logs
471+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
450472 }
451473 state.copy(
452474 logs = capped,
475+ logKeys = computeLogKeys(capped),
453476 streaming = state.streaming.copy(
454477 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455478 streamingError = null,