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:
@@ -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 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 */ }
|
||||
}
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user