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 =
|
var lastStep: com.medithings.vesiscan.models.ClinicalStep =
|
||||||
com.medithings.vesiscan.models.ClinicalStep.ALIGN_0CM
|
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) {
|
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||||
currentSession = session
|
currentSession = session
|
||||||
@@ -83,21 +89,26 @@ object ClinicalSessionStore {
|
|||||||
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||||
lastPosture = session.posture
|
lastPosture = session.posture
|
||||||
lastStep = session.step
|
lastStep = session.step
|
||||||
cyclesBuffer.clear()
|
cycleCounter = 0
|
||||||
currentLogDir()?.mkdirs()
|
val dir = currentLogDir()
|
||||||
|
dir?.mkdirs()
|
||||||
|
// 이전 세션의 stream 잔여 파일 삭제 (같은 folderName 재사용 방지).
|
||||||
|
dir?.let { cyclesStreamFile(it).delete() }
|
||||||
writeMeta(context)
|
writeMeta(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */
|
/** 한 사이클(piezo + IMU) 누적 — 즉시 cycles.jsonl 에 append (§D streaming). */
|
||||||
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
|
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
|
||||||
if (currentSession == null) return
|
if (currentSession == null) return
|
||||||
cyclesBuffer.add(MeasurementCycle(
|
val cycle = MeasurementCycle(
|
||||||
cycle = cyclesBuffer.size,
|
cycle = cycleCounter,
|
||||||
timestampMs = System.currentTimeMillis(),
|
timestampMs = System.currentTimeMillis(),
|
||||||
piezoChannels = piezo.sortedBy { it.channel }
|
piezoChannels = piezo.sortedBy { it.channel }
|
||||||
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
||||||
imuSamples = imu.toList(),
|
imuSamples = imu.toList(),
|
||||||
))
|
)
|
||||||
|
appendCycleToStream(cycle)
|
||||||
|
cycleCounter++
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */
|
/** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */
|
||||||
@@ -112,8 +123,8 @@ object ClinicalSessionStore {
|
|||||||
alignIcon: String,
|
alignIcon: String,
|
||||||
) {
|
) {
|
||||||
if (currentSession == null) return
|
if (currentSession == null) return
|
||||||
cyclesBuffer.add(MeasurementCycle(
|
val cycle = MeasurementCycle(
|
||||||
cycle = cyclesBuffer.size,
|
cycle = cycleCounter,
|
||||||
timestampMs = System.currentTimeMillis(),
|
timestampMs = System.currentTimeMillis(),
|
||||||
piezoChannels = piezo.sortedBy { it.channel }
|
piezoChannels = piezo.sortedBy { it.channel }
|
||||||
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
||||||
@@ -124,10 +135,56 @@ object ClinicalSessionStore {
|
|||||||
alignUrineLens = alignUrineLens,
|
alignUrineLens = alignUrineLens,
|
||||||
alignHint = alignHint,
|
alignHint = alignHint,
|
||||||
alignIcon = alignIcon,
|
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? {
|
fun currentLogDir(): File? {
|
||||||
@@ -208,7 +265,7 @@ object ClinicalSessionStore {
|
|||||||
val dir = currentLogDir()
|
val dir = currentLogDir()
|
||||||
lastFinalizedFolder = dir
|
lastFinalizedFolder = dir
|
||||||
currentSession = null
|
currentSession = null
|
||||||
cyclesBuffer.clear()
|
cycleCounter = 0
|
||||||
// 2026-07-09: 자동 업로드 추가. apiKey 만 있으면 시도 (status 는 서버 응답으로 판단).
|
// 2026-07-09: 자동 업로드 추가. apiKey 만 있으면 시도 (status 는 서버 응답으로 판단).
|
||||||
// 실패해도 폴더는 그대로 유지 → 다음 ClinicalHome 진입 시 LabdbAutoRetry 재시도.
|
// 실패해도 폴더는 그대로 유지 → 다음 ClinicalHome 진입 시 LabdbAutoRetry 재시도.
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
@@ -259,7 +316,7 @@ object ClinicalSessionStore {
|
|||||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
|
||||||
put("duration_ms", it - s.startedAt)
|
put("duration_ms", it - s.startedAt)
|
||||||
}
|
}
|
||||||
put("captured_cycles", cyclesBuffer.size)
|
put("captured_cycles", cycleCounter)
|
||||||
put("app_version_name", try {
|
put("app_version_name", try {
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||||
} catch (_: Exception) { "unknown" })
|
} catch (_: Exception) { "unknown" })
|
||||||
@@ -270,43 +327,26 @@ object ClinicalSessionStore {
|
|||||||
}
|
}
|
||||||
root.put("meta", meta)
|
root.put("meta", meta)
|
||||||
|
|
||||||
// cycles: 모든 사이클을 piezo + imu 묶음으로
|
// 2026-08-04 §D: cycles.jsonl (스트리밍 append 결과) 를 읽어 aggregate.
|
||||||
|
// 메모리 상시 사용량 최소화 · 마지막 통합 시점에만 잠깐 read+parse.
|
||||||
val cyclesArr = JSONArray()
|
val cyclesArr = JSONArray()
|
||||||
for (c in cyclesBuffer) {
|
val streamFile = cyclesStreamFile(dir)
|
||||||
val piezo = JSONObject()
|
if (streamFile.exists()) {
|
||||||
c.piezoChannels.forEach { (ch, samples) ->
|
streamFile.useLines { lines ->
|
||||||
piezo.put("CH$ch", JSONArray(samples))
|
for (line in lines) {
|
||||||
}
|
val t = line.trim()
|
||||||
val imuArr = JSONArray()
|
if (t.isEmpty()) continue
|
||||||
for (sample in c.imuSamples) {
|
try {
|
||||||
imuArr.put(JSONObject().apply {
|
cyclesArr.put(JSONObject(t))
|
||||||
put("ax", sample.ax.toDouble())
|
} catch (_: Exception) { /* corrupt line skip */ }
|
||||||
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)
|
root.put("cycles", cyclesArr)
|
||||||
|
|
||||||
measurementJsonFile(dir).writeText(root.toString(2))
|
measurementJsonFile(dir).writeText(root.toString(2))
|
||||||
|
// 통합 성공 시 stream 파일 삭제 (같은 폴더 재사용 대비 · measurement.json 이 SSOT).
|
||||||
|
if (streamFile.exists()) streamFile.delete()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// 저장 실패해도 측정 자체는 막지 않음
|
// 저장 실패해도 측정 자체는 막지 않음
|
||||||
}
|
}
|
||||||
@@ -325,7 +365,7 @@ object ClinicalSessionStore {
|
|||||||
fun discardAndDeleteFolder(): File? {
|
fun discardAndDeleteFolder(): File? {
|
||||||
val dir = currentLogDir()
|
val dir = currentLogDir()
|
||||||
currentSession = null
|
currentSession = null
|
||||||
cyclesBuffer.clear()
|
cycleCounter = 0
|
||||||
if (dir != null && dir.exists()) {
|
if (dir != null && dir.exists()) {
|
||||||
dir.deleteRecursively()
|
dir.deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user