From 4d8ada0634744e988737326715047fd92f0ffbd2 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 4 Aug 2026 16:00:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(logs):=20=C2=A7D=20=C2=B7=20cyclesBuffer=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=20=EB=88=84=EC=A0=81=20=E2=86=92=20cycles.js?= =?UTF-8?q?onl=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20(Priority=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경 (LOG_STORAGE_ANALYSIS §D): ClinicalSessionStore.cyclesBuffer 는 endMeasurement() 호출 전까지 모든 사이클을 메모리 보관. MeasurementCycle 1개 ≈ 16KB · autoCapture 600ms → 시간당 ~90MB 힙. 장시간 임상 세션 OOM 위험. 수정: cyclesBuffer (MutableList) 완전 제거 · cycleCounter (Int) 로 대체. addCycle / addAlignmentFrame 은 이제: 1. MeasurementCycle 생성 2. appendCycleToStream() → cycles.jsonl 에 즉시 append (fire-and-forget) 3. cycleCounter++ writeMeasurementJson (endMeasurement 시점): - cycles.jsonl 를 useLines 스트리밍 read → cyclesArr 조립 - measurement.json 생성 성공 시 cycles.jsonl 삭제 (SSOT 유지) - 파싱 실패 line 은 skip (corrupt 방지) startMeasurement: - 이전 세션 잔여 cycles.jsonl 삭제 (같은 folderName 재사용 방지) discardAndDeleteFolder / endMeasurement: - cycleCounter = 0 리셋 효과: 메모리 상시: cycleCounter (4B) 만 · 이전 대비 시간당 90MB 절감 End 시점 read-back 은 필요 (measurement.json aggregate) · 그때만 잠깐 spike Cycle 저장 실패해도 측정 계속 (silent · fire-and-forget UC-05) 측정.json 스키마 변경 없음 · guardian app / 분석 툴 하위 호환. 검증: BUILD SUCCESSFUL 2s --- .../vesiscan/services/ClinicalSessionStore.kt | 132 ++++++++++++------ 1 file changed, 86 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt b/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt index 8c0fc77..e5cbf4a 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt @@ -70,8 +70,14 @@ object ClinicalSessionStore { var lastStep: com.medithings.vesiscan.models.ClinicalStep = com.medithings.vesiscan.models.ClinicalStep.ALIGN_0CM - /** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */ - private val cyclesBuffer = mutableListOf() + /** + * 2026-08-04: LOG_STORAGE_ANALYSIS §D fix — cyclesBuffer 무한 누적 (시간당 90MB 힙) + * → 즉시 스트리밍 append (cycles.jsonl). endMeasurement 시 읽어 measurement.json 합성. + * 메모리 상시: cycleCounter 만 유지 (버퍼 없음). + */ + @Volatile private var cycleCounter: Int = 0 + private val cycleStreamLock = Any() + private fun cyclesStreamFile(dir: File): File = File(dir, "cycles.jsonl") fun startMeasurement(context: Context, session: ClinicalSession) { currentSession = session @@ -83,21 +89,26 @@ object ClinicalSessionStore { lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: "" lastPosture = session.posture lastStep = session.step - cyclesBuffer.clear() - currentLogDir()?.mkdirs() + cycleCounter = 0 + val dir = currentLogDir() + dir?.mkdirs() + // 이전 세션의 stream 잔여 파일 삭제 (같은 folderName 재사용 방지). + dir?.let { cyclesStreamFile(it).delete() } writeMeta(context) } - /** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */ + /** 한 사이클(piezo + IMU) 누적 — 즉시 cycles.jsonl 에 append (§D streaming). */ fun addCycle(piezo: List, imu: List) { if (currentSession == null) return - cyclesBuffer.add(MeasurementCycle( - cycle = cyclesBuffer.size, + val cycle = MeasurementCycle( + cycle = cycleCounter, timestampMs = System.currentTimeMillis(), piezoChannels = piezo.sortedBy { it.channel } .associate { it.channel to it.buffer.map { s -> s.toInt() } }, imuSamples = imu.toList(), - )) + ) + appendCycleToStream(cycle) + cycleCounter++ } /** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */ @@ -112,8 +123,8 @@ object ClinicalSessionStore { alignIcon: String, ) { if (currentSession == null) return - cyclesBuffer.add(MeasurementCycle( - cycle = cyclesBuffer.size, + val cycle = MeasurementCycle( + cycle = cycleCounter, timestampMs = System.currentTimeMillis(), piezoChannels = piezo.sortedBy { it.channel } .associate { it.channel to it.buffer.map { s -> s.toInt() } }, @@ -124,10 +135,56 @@ object ClinicalSessionStore { alignUrineLens = alignUrineLens, alignHint = alignHint, alignIcon = alignIcon, - )) + ) + appendCycleToStream(cycle) + cycleCounter++ } - val cycleCount: Int get() = cyclesBuffer.size + /** cycles.jsonl 에 단일 사이클 append. fire-and-forget · 실패해도 측정 계속. */ + private fun appendCycleToStream(c: MeasurementCycle) { + val dir = currentLogDir() ?: return + try { + synchronized(cycleStreamLock) { + dir.mkdirs() + val json = cycleToJson(c).toString() + cyclesStreamFile(dir).appendText(json + "\n") + } + } catch (_: Exception) { } + } + + private fun cycleToJson(c: MeasurementCycle): JSONObject { + val piezo = JSONObject() + c.piezoChannels.forEach { (ch, samples) -> + piezo.put("CH$ch", JSONArray(samples)) + } + val imuArr = JSONArray() + for (sample in c.imuSamples) { + imuArr.put(JSONObject().apply { + put("ax", sample.ax.toDouble()) + put("ay", sample.ay.toDouble()) + put("az", sample.az.toDouble()) + put("gx", sample.gx.toDouble()) + put("gy", sample.gy.toDouble()) + put("gz", sample.gz.toDouble()) + }) + } + return JSONObject().apply { + put("cycle", c.cycle) + put("timestamp_ms", c.timestampMs) + put("piezo", piezo) + put("imu", imuArr) + if (c.alignPhase.isNotBlank()) { + put("align_phase", c.alignPhase) + put("align_score", c.alignScore) + put("align_hint", c.alignHint) + put("align_icon", c.alignIcon) + put("align_detected", JSONArray(c.alignDetected)) + put("align_urine_lens", JSONArray(c.alignUrineLens)) + } + } + } + + val cycleCount: Int get() = cycleCounter /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ fun currentLogDir(): File? { @@ -208,7 +265,7 @@ object ClinicalSessionStore { val dir = currentLogDir() lastFinalizedFolder = dir currentSession = null - cyclesBuffer.clear() + cycleCounter = 0 // 2026-07-09: 자동 업로드 추가. apiKey 만 있으면 시도 (status 는 서버 응답으로 판단). // 실패해도 폴더는 그대로 유지 → 다음 ClinicalHome 진입 시 LabdbAutoRetry 재시도. if (dir != null) { @@ -259,7 +316,7 @@ object ClinicalSessionStore { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it))) put("duration_ms", it - s.startedAt) } - put("captured_cycles", cyclesBuffer.size) + put("captured_cycles", cycleCounter) put("app_version_name", try { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" } catch (_: Exception) { "unknown" }) @@ -270,43 +327,26 @@ object ClinicalSessionStore { } root.put("meta", meta) - // cycles: 모든 사이클을 piezo + imu 묶음으로 + // 2026-08-04 §D: cycles.jsonl (스트리밍 append 결과) 를 읽어 aggregate. + // 메모리 상시 사용량 최소화 · 마지막 통합 시점에만 잠깐 read+parse. val cyclesArr = JSONArray() - for (c in cyclesBuffer) { - val piezo = JSONObject() - c.piezoChannels.forEach { (ch, samples) -> - piezo.put("CH$ch", JSONArray(samples)) - } - val imuArr = JSONArray() - for (sample in c.imuSamples) { - imuArr.put(JSONObject().apply { - put("ax", sample.ax.toDouble()) - put("ay", sample.ay.toDouble()) - put("az", sample.az.toDouble()) - put("gx", sample.gx.toDouble()) - put("gy", sample.gy.toDouble()) - put("gz", sample.gz.toDouble()) - }) - } - cyclesArr.put(JSONObject().apply { - put("cycle", c.cycle) - put("timestamp_ms", c.timestampMs) - put("piezo", piezo) - put("imu", imuArr) - // Alignment metadata (alignment mode 일 때만 채워짐) - if (c.alignPhase.isNotBlank()) { - put("align_phase", c.alignPhase) - put("align_score", c.alignScore) - put("align_hint", c.alignHint) - put("align_icon", c.alignIcon) - put("align_detected", JSONArray(c.alignDetected)) - put("align_urine_lens", JSONArray(c.alignUrineLens)) + val streamFile = cyclesStreamFile(dir) + if (streamFile.exists()) { + streamFile.useLines { lines -> + for (line in lines) { + val t = line.trim() + if (t.isEmpty()) continue + try { + cyclesArr.put(JSONObject(t)) + } catch (_: Exception) { /* corrupt line skip */ } } - }) + } } root.put("cycles", cyclesArr) measurementJsonFile(dir).writeText(root.toString(2)) + // 통합 성공 시 stream 파일 삭제 (같은 폴더 재사용 대비 · measurement.json 이 SSOT). + if (streamFile.exists()) streamFile.delete() } catch (_: Exception) { // 저장 실패해도 측정 자체는 막지 않음 } @@ -325,7 +365,7 @@ object ClinicalSessionStore { fun discardAndDeleteFolder(): File? { val dir = currentLogDir() currentSession = null - cyclesBuffer.clear() + cycleCounter = 0 if (dir != null && dir.exists()) { dir.deleteRecursively() }