fix(logs): §D · cyclesBuffer 무한 누적 → cycles.jsonl 스트리밍 (Priority 2)

배경 (LOG_STORAGE_ANALYSIS §D):
  ClinicalSessionStore.cyclesBuffer 는 endMeasurement() 호출 전까지 모든 사이클을
  메모리 보관. MeasurementCycle 1개 ≈ 16KB · autoCapture 600ms → 시간당 ~90MB 힙.
  장시간 임상 세션 OOM 위험.

수정:
  cyclesBuffer (MutableList<MeasurementCycle>) 완전 제거 · 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
This commit is contained in:
2026-08-04 16:00:54 +09:00
parent 1df1a4e6cd
commit 4d8ada0634
@@ -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<MeasurementCycle>()
/**
* 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<PiezoChannelData>, imu: List<ImuSample>) {
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()
}