git.iko.soy/parseable-android
on master help sign in
Improve performance of log viewer and network layer (#26)
e98fa28iko
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+1−1
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
39 coerceInputValues = true
39 coerceInputValues = true
40 }
40 }
41
41
42− private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
43 private val sharedDispatcher = okhttp3.Dispatcher()
43 private val sharedDispatcher = okhttp3.Dispatcher()
44
44
45 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
45 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(
368 }
368 }
369 }
369 }
370
370
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−
376 LazyColumn(
371 LazyColumn(
377 state = listState,
372 state = listState,
378 contentPadding = PaddingValues(8.dp),
373 contentPadding = PaddingValues(8.dp),
@@ -380,9 +375,9 @@ fun LogViewerScreen(
380 ) {
375 ) {
381 itemsIndexed(
376 itemsIndexed(
382 state.logs,
377 state.logs,
383− key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
378+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
384 ) { index, logEntry ->
379 ) { index, logEntry ->
385− val key = logKeys.getOrElse(index) { "log_$index" }
380+ val key = state.logKeys.getOrElse(index) { "log_$index" }
386 LogEntryCard(
381 LogEntryCard(
387 logEntry = logEntry,
382 logEntry = logEntry,
388 isExpanded = expandedLogKey == key,
383 isExpanded = expandedLogKey == key,
@@ -792,24 +787,6 @@ fun LogEntryCard(
792 }
787 }
793}
788}
794
789
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−
813private fun formatJsonValue(element: JsonElement): String {
790private fun formatJsonValue(element: JsonElement): String {
814 return when (element) {
791 return when (element) {
815 is JsonPrimitive -> element.content
792 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(
59data class LogViewerState(
59data class LogViewerState(
60 val streamName: String = "",
60 val streamName: String = "",
61 val logs: List<JsonObject> = emptyList(),
61 val logs: List<JsonObject> = emptyList(),
62+ val logKeys: List<String> = emptyList(),
62 val columns: List<String> = emptyList(),
63 val columns: List<String> = emptyList(),
64+ val searchableColumns: List<String> = emptyList(),
63 val isLoading: Boolean = false,
65 val isLoading: Boolean = false,
64 val error: String? = null,
66 val error: String? = null,
65 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
67 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -108,6 +110,23 @@ class LogViewerViewModel @Inject constructor(
108 private const val LOAD_MORE_INCREMENT = 500
110 private const val LOAD_MORE_INCREMENT = 500
109 private const val MAX_STREAMING_ERRORS = 5
111 private const val MAX_STREAMING_ERRORS = 5
110 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
112 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+ }
111 }
130 }
112
131
113 fun initialize(streamName: String) {
132 fun initialize(streamName: String) {
@@ -124,7 +143,8 @@ class LogViewerViewModel @Inject constructor(
124 when (val result = repository.getStreamSchema(streamName)) {
143 when (val result = repository.getStreamSchema(streamName)) {
125 is ApiResult.Success -> {
144 is ApiResult.Success -> {
126 val columns = result.data.fields.map { it.name }.sorted()
145 val columns = result.data.fields.map { it.name }.sorted()
146+ val searchable = columns.filter { !it.startsWith("p_") }
127− _state.update { it.copy(columns = columns) }
147+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
128 }
148 }
129 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
149 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
130 }
150 }
@@ -236,9 +256,11 @@ class LogViewerViewModel @Inject constructor(
236 val (startTime, endTime) = getTimeRange()
256 val (startTime, endTime) = getTimeRange()
237 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
257 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238 is ApiResult.Success -> {
258 is ApiResult.Success -> {
259+ val keys = computeLogKeys(result.data)
239 _state.update {
260 _state.update {
240 it.copy(
261 it.copy(
241 logs = result.data,
262 logs = result.data,
263+ logKeys = keys,
242 isLoading = false,
264 isLoading = false,
243 hasMore = result.data.size >= it.currentLimit,
265 hasMore = result.data.size >= it.currentLimit,
244 )
266 )
@@ -253,13 +275,11 @@ class LogViewerViewModel @Inject constructor(
253 }
275 }
254 }
276 }
255
277
256− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
278+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
257 if (searchQuery.isBlank()) return null
279 if (searchQuery.isBlank()) return null
280+ if (searchableColumns.isEmpty()) return null
258 val safeSearch = escapeSql(searchQuery)
281 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 ") {
263 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
283 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
264 }
284 }
265 }
285 }
@@ -283,7 +303,7 @@ class LogViewerViewModel @Inject constructor(
283 // Build WHERE clause from filters + search
303 // Build WHERE clause from filters + search
284 val clauses = current.filters.filterClauses.toMutableList()
304 val clauses = current.filters.filterClauses.toMutableList()
285 if (current.filters.searchQuery.isNotBlank()) {
305 if (current.filters.searchQuery.isNotBlank()) {
286− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
306+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
287 if (searchClause != null) {
307 if (searchClause != null) {
288 clauses.add("($searchClause)")
308 clauses.add("($searchClause)")
289 } else {
309 } else {
@@ -304,9 +324,11 @@ class LogViewerViewModel @Inject constructor(
304 limit = current.currentLimit,
324 limit = current.currentLimit,
305 )) {
325 )) {
306 is ApiResult.Success -> {
326 is ApiResult.Success -> {
327+ val keys = computeLogKeys(result.data)
307 _state.update {
328 _state.update {
308 it.copy(
329 it.copy(
309 logs = result.data,
330 logs = result.data,
331+ logKeys = keys,
310 isLoading = false,
332 isLoading = false,
311 hasMore = result.data.size >= it.currentLimit,
333 hasMore = result.data.size >= it.currentLimit,
312 )
334 )
@@ -415,7 +437,7 @@ class LogViewerViewModel @Inject constructor(
415 // Build WHERE clause from active filters + search
437 // Build WHERE clause from active filters + search
416 val clauses = current.filters.filterClauses.toMutableList()
438 val clauses = current.filters.filterClauses.toMutableList()
417 if (current.filters.searchQuery.isNotBlank()) {
439 if (current.filters.searchQuery.isNotBlank()) {
418− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
440+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
419 if (searchClause != null) {
441 if (searchClause != null) {
420 clauses.add(searchClause)
442 clauses.add(searchClause)
421 }
443 }
@@ -441,15 +463,16 @@ class LogViewerViewModel @Inject constructor(
441 }
463 }
442
464
443 _state.update { state ->
465 _state.update { state ->
444− // Prepend new logs, cap total
466+ // Prepend new logs, cap total — avoid full intermediate list allocation
445− val combined = newLogs + state.logs
467+ val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
446− val capped = if (combined.size > STREAMING_MAX_LOGS) {
468+ val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
447− combined.take(STREAMING_MAX_LOGS)
469+ addAll(newLogs)
448− } else {
470+ val oldLogs = state.logs
449− combined
471+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
450 }
472 }
451 state.copy(
473 state.copy(
452 logs = capped,
474 logs = capped,
475+ logKeys = computeLogKeys(capped),
453 streaming = state.streaming.copy(
476 streaming = state.streaming.copy(
454 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
477 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455 streamingError = null,
478 streamingError = null,