-
Notifications
You must be signed in to change notification settings - Fork 0
feat(observability): 모니터링 API 공용 기반 + Redis 인프라 #29 #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 2 commits
97c7c41
b0d675f
d633e54
37cc502
fc6beee
4e1b95b
7dd3477
2ef5c6a
0dd9447
5ce0b76
2ab89bd
0bcd346
4ccbb43
4fb7802
c6915ee
2ca5e1f
4272ae9
b9f2db8
19e0aea
deceb01
655dcd9
fc3cab3
6f473e0
d54d514
08a5e39
0233a78
2ee2e68
005a406
bc55d78
6c4230f
9eb8aa8
19005cb
7364143
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.dto.common | ||
|
|
||
| data class ApiResponse<T>( | ||
| val success: Boolean = true, | ||
| val data: T?, | ||
| val error: ErrorDetail? = null, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.dto.common | ||
|
|
||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
|
|
||
| data class ErrorDetail( | ||
| val code: String, | ||
| val message: String, | ||
| val status: Int, | ||
| ) { | ||
| companion object { | ||
| fun from(errorCode: ErrorCode) = ErrorDetail( | ||
| errorCode.name, | ||
| errorCode.message, | ||
| errorCode.status, | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.dto.common | ||
|
|
||
| import java.time.Instant | ||
|
|
||
| data class ErrorResponse( | ||
| val success: Boolean = false, | ||
| val error: ErrorDetail, | ||
| val timestamp: Instant = Instant.now(), | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.exception | ||
|
|
||
| import hs.kr.entrydsm.observability.adapterin.web.dto.common.ErrorDetail | ||
| import hs.kr.entrydsm.observability.adapterin.web.dto.common.ErrorResponse | ||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
| import hs.kr.entrydsm.observability.domain.exception.MonitorException | ||
| import jakarta.validation.ConstraintViolationException | ||
| import org.slf4j.LoggerFactory | ||
| import org.slf4j.MDC | ||
| import org.springframework.http.ResponseEntity | ||
| import org.springframework.http.converter.HttpMessageNotReadableException | ||
| import org.springframework.validation.BindException | ||
| import org.springframework.web.bind.MethodArgumentNotValidException | ||
| import org.springframework.web.bind.MissingPathVariableException | ||
| import org.springframework.web.bind.MissingRequestHeaderException | ||
| import org.springframework.web.bind.MissingServletRequestParameterException | ||
| import org.springframework.web.bind.annotation.ExceptionHandler | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice | ||
| import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException | ||
| import org.springframework.web.multipart.support.MissingServletRequestPartException | ||
|
|
||
| @RestControllerAdvice | ||
| class GlobalExceptionHandler { | ||
| private val logger = LoggerFactory.getLogger(javaClass) | ||
|
|
||
| @ExceptionHandler(MonitorException::class) | ||
| fun handleMonitorException(exception: MonitorException): ResponseEntity<ErrorResponse> = | ||
| response(exception.errorCode) | ||
|
|
||
| @ExceptionHandler( | ||
| HttpMessageNotReadableException::class, | ||
| BindException::class, | ||
| ConstraintViolationException::class, | ||
| MethodArgumentNotValidException::class, | ||
| MissingPathVariableException::class, | ||
| MissingRequestHeaderException::class, | ||
| MissingServletRequestParameterException::class, | ||
| MissingServletRequestPartException::class, | ||
| MethodArgumentTypeMismatchException::class, | ||
| ) | ||
| fun handleInvalidRequest(exception: Exception): ResponseEntity<ErrorResponse> = | ||
| response(ErrorCode.INVALID_PAYLOAD) | ||
|
|
||
| @ExceptionHandler(Exception::class) | ||
| fun handleUnhandledException(exception: Exception): ResponseEntity<ErrorResponse> = | ||
| response(ErrorCode.INTERNAL_SERVER_ERROR).also { | ||
| logger.error( | ||
| "Unhandled exception [X-trace-Id={}]", | ||
| MDC.get("X-trace-Id") ?: "unknown", | ||
| exception, | ||
| ) | ||
| } | ||
|
|
||
| private fun response(errorCode: ErrorCode): ResponseEntity<ErrorResponse> = | ||
| ResponseEntity | ||
| .status(errorCode.status) | ||
| .body( | ||
| ErrorResponse( | ||
| error = ErrorDetail.from(errorCode) | ||
| ), | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,11 @@ | ||
| package hs.kr.entrydsm.observability.adapterin | ||
|
|
||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
| import hs.kr.entrydsm.observability.adapterin.web.exception.GlobalExceptionHandlerTest | ||
| import org.junit.runner.RunWith | ||
| import org.junit.runners.Suite | ||
|
|
||
| class ObservabilityAdapterInModuleTest { | ||
| @Test | ||
| fun moduleLoads() { | ||
| assertTrue(true) | ||
| } | ||
| } | ||
| @RunWith(Suite::class) | ||
| @Suite.SuiteClasses( | ||
| GlobalExceptionHandlerTest::class, | ||
| ) | ||
| class ObservabilityAdapterInModuleTest |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package hs.kr.entrydsm.observability.adapterin.web.exception | ||
|
|
||
| import hs.kr.entrydsm.observability.domain.enum.ErrorCode | ||
| import hs.kr.entrydsm.observability.domain.exception.MonitorDomainException | ||
| import jakarta.validation.ConstraintViolationException | ||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Test | ||
| import org.slf4j.MDC | ||
| import org.springframework.validation.BindException | ||
|
|
||
| class GlobalExceptionHandlerTest { | ||
| private val handler = GlobalExceptionHandler() | ||
|
|
||
| @Test | ||
| fun mapsMonitorExceptionToItsErrorResponse() { | ||
| val response = handler.handleMonitorException(MonitorDomainException(ErrorCode.SESSION_NOT_FOUND)) | ||
|
|
||
| assertEquals(404, response.statusCode.value()) | ||
| assertEquals("SESSION_NOT_FOUND", response.body?.error?.code) | ||
| assertEquals(ErrorCode.SESSION_NOT_FOUND.message, response.body?.error?.message) | ||
| } | ||
|
|
||
| @Test | ||
| fun mapsInvalidRequestToBadRequestResponse() { | ||
| val response = handler.handleInvalidRequest(IllegalArgumentException()) | ||
|
|
||
| assertEquals(400, response.statusCode.value()) | ||
| assertEquals("INVALID_PAYLOAD", response.body?.error?.code) | ||
| } | ||
|
|
||
| @Test | ||
| fun mapsValidationExceptionsToBadRequestResponse() { | ||
| val responses = listOf( | ||
| handler.handleInvalidRequest(BindException(this, "request")), | ||
| handler.handleInvalidRequest(ConstraintViolationException(emptySet())), | ||
| ) | ||
|
|
||
| responses.forEach { response -> | ||
| assertEquals(400, response.statusCode.value()) | ||
| assertEquals("INVALID_PAYLOAD", response.body?.error?.code) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun keepsGenericResponseAndLogsCorrelationContextForUnhandledException() { | ||
| MDC.put("X-trace-Id", "test-trace-id") | ||
| try { | ||
| val response = handler.handleUnhandledException(IllegalStateException("internal detail")) | ||
|
|
||
| assertEquals(500, response.statusCode.value()) | ||
| assertEquals("INTERNAL_SERVER_ERROR", response.body?.error?.code) | ||
| assertEquals(ErrorCode.INTERNAL_SERVER_ERROR.message, response.body?.error?.message) | ||
| } finally { | ||
| MDC.remove("X-trace-Id") | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package hs.kr.entrydsm.observability.adapterout.redis | ||
|
|
||
| import hs.kr.entrydsm.observability.application.port.out.RateLimitPort | ||
| import java.time.Duration | ||
| import org.springframework.data.redis.core.StringRedisTemplate | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** IP 기준 고정 윈도우 카운터. 이미 Redis를 쓰므로 별도 rate-limit 라이브러리 없이 INCR+EXPIRE로 구현한다. */ | ||
| @Component | ||
| class RedisRateLimitAdapter( | ||
| private val redis: StringRedisTemplate, | ||
| ) : RateLimitPort { | ||
| override fun tryAcquire(key: String, limit: Long, windowSeconds: Long): Boolean { | ||
| val bucket = System.currentTimeMillis() / (windowSeconds * 1000) | ||
| val redisKey = "monitor:ratelimit:$key:$bucket" | ||
| val count = redis.opsForValue().increment(redisKey) ?: 1L | ||
| if (count == 1L) { | ||
| redis.expire(redisKey, Duration.ofSeconds(windowSeconds)) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate file ---'
ast-grep outline systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisRateLimitAdapter.kt
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisRateLimitAdapter.kt
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '*.kt' 'RedisRateLimitAdapter|tryAcquire|RateLimit|expire\\(|opsForValue\\(\\)\\.increment' systemsRepository: EntryDSM/entrydsm-platform Length of output: 1567 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- related symbols ---'
rg -n --glob '*.kt' 'RedisRateLimitAdapter|tryAcquire|RateLimit|expire\(|opsForValue\(\)\.increment' systems || true
printf '%s\n' '--- candidate-module files ---'
git ls-files 'systems/observability/observability-adapter-out' | sed -n '1,160p'
printf '%s\n' '--- build declarations mentioning Redis ---'
rg -n --glob 'build.gradle*' --glob '*.gradle.kts' --glob '*.gradle' 'redis|spring-data' . || true
printf '%s\n' '--- Redis adapter tests ---'
rg -n --glob '*Test.kt' --glob '*Tests.kt' 'RateLimit|RedisRateLimit|tryAcquire|ratelimit' . || trueRepository: EntryDSM/entrydsm-platform Length of output: 7383 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- rate-limit callers and constants ---'
cat -n systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/SessionCollectionService.kt
cat -n systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/RateLimitPort.kt
cat -n systems/observability/observability-adapter-out/BUILD.bazel
cat -n systems/observability/observability-adapter-out/src/test/kotlin/hs/kr/entrydsm/TestMain.kt
printf '%s\n' '--- read-only behavioral model of the two-command sequence ---'
python3 - <<'PY'
class RedisModel:
def __init__(self):
self.values = {}
self.expiry = {}
def incr(self, key):
self.values[key] = self.values.get(key, 0) + 1
return self.values[key]
def expire(self, key, seconds):
self.expiry[key] = seconds
def try_acquire_with_failure(redis, key, window_seconds, fail_before_expire=False):
count = redis.incr(key)
if count == 1:
if fail_before_expire:
raise ConnectionError("failure between INCR and EXPIRE")
redis.expire(key, window_seconds)
return count
redis = RedisModel()
try:
try_acquire_with_failure(redis, "monitor:ratelimit:client:1", 60, True)
except ConnectionError:
pass
key = "monitor:ratelimit:client:1"
assert redis.values[key] == 1
assert key not in redis.expiry
print("INCR committed while EXPIRE was skipped: key exists=%s, ttl=%s" %
(key in redis.values, redis.expiry.get(key)))
PYRepository: EntryDSM/entrydsm-platform Length of output: 4943 🌐 Web query:
💡 Result: To atomically increment a key and set an expiration in Redis, you have two primary options: using the native INCREX command or a Lua script [1][2][3]. 1. Using INCREX (Recommended) The INCREX command is the preferred, native way to perform an atomic increment with an optional expiration [2][3]. It collapses the two-step pattern into a single O(1) command, ensuring atomicity and reducing round-trips [3]. Example usage: INCREX mykey BY 1 EX 60 2. Using a Lua Script If you are using an older version of Redis or have custom requirements, you can use a Lua script executed via the EVAL command [1][4]. Redis guarantees that scripts execute atomically, blocking all other server activities until the script completes [5][6]. The standard pattern to ensure the key is only expired on its first creation (or specific condition) is: local current = redis.call("incr", KEYS[1]) if current == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return current You would call this via: EVAL "local current = redis.call('incr', KEYS[1]); if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end; return current" 1 mykey 60 Why not just use separate commands? Sending INCR and EXPIRE as separate commands is not atomic [1][4]. If a client fails between the two commands, the key will exist without an expiration, leading to potential "key leakage" where the key persists indefinitely [1][4]. While Redis Transactions (MULTI/EXEC) can group commands, they do not provide the ability to use the result of one command (e.g., the new counter value) to conditionally decide whether to execute the next (e.g., setting the expiration) within the same transaction [7][8]. Lua scripts or native commands like INCREX are necessary for such conditional logic [2][3][8]. Citations:
🤖 Prompt for AI Agents |
||
| return count <= limit | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| package hs.kr.entrydsm.observability.adapterout.redis | ||
|
|
||
| import hs.kr.entrydsm.observability.application.port.out.SessionStorePort | ||
| import hs.kr.entrydsm.observability.domain.enum.DeviceType | ||
| import hs.kr.entrydsm.observability.domain.enum.ServiceName | ||
| import java.time.Duration | ||
| import java.time.Instant | ||
| import org.springframework.data.redis.core.StringRedisTemplate | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| /** | ||
| * 세션·트래픽 집계를 Redis에 저장한다. | ||
| * ponytail: 다중 인스턴스에서도 Redis가 단일 소스라 별도 확장 작업 없이 그대로 동작한다. | ||
| */ | ||
| @Component | ||
| class RedisSessionStoreAdapter( | ||
| private val redis: StringRedisTemplate, | ||
| ) : SessionStorePort { | ||
|
|
||
| override fun enter(sessionId: String, service: ServiceName, deviceType: DeviceType, now: Instant) { | ||
| val nowMillis = now.toEpochMilli() | ||
| val hashOps = redis.opsForHash<String, String>() | ||
| hashOps.putAll( | ||
| metaKey(sessionId), | ||
| mapOf( | ||
| FIELD_SERVICE to service.name, | ||
| FIELD_ENTERED_AT to nowMillis.toString(), | ||
| FIELD_LAST_HEARTBEAT_AT to nowMillis.toString(), | ||
| ), | ||
| ) | ||
| redis.expire(metaKey(sessionId), SESSION_TTL) | ||
| touchWindow(sessionId, service, nowMillis) | ||
| redis.opsForSet().add(VISITORS_KEY, sessionId) | ||
| hashOps.increment(DEVICE_COUNTS_KEY, deviceType.name, 1) | ||
| } | ||
|
|
||
| override fun heartbeat(sessionId: String, service: ServiceName, now: Instant): Boolean { | ||
| if (!redis.hasKey(metaKey(sessionId))) return false | ||
| val nowMillis = now.toEpochMilli() | ||
| val hashOps = redis.opsForHash<String, String>() | ||
| hashOps.put(metaKey(sessionId), FIELD_SERVICE, service.name) | ||
| hashOps.put(metaKey(sessionId), FIELD_LAST_HEARTBEAT_AT, nowMillis.toString()) | ||
| redis.expire(metaKey(sessionId), SESSION_TTL) | ||
| touchWindow(sessionId, service, nowMillis) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RedisSessionStoreAdapter|SessionStore|observability.*redis|redis' | head -200
printf '%s\n' '--- outline ---'
ast-grep outline systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- implementation ---'
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- symbols/usages ---'
rg -n -C 3 'concurrentUsers|heartbeat\\(|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observabilityRepository: EntryDSM/entrydsm-platform Length of output: 7846 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- files ---'
git ls-files | rg 'RedisSessionStoreAdapter|SessionStore|observability.*redis|redis' | head -200
printf '%s\n' '--- implementation ---'
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- usages ---'
rg -n -C 3 'concurrentUsers|heartbeat|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observabilityRepository: EntryDSM/entrydsm-platform Length of output: 28979 🏁 Script executed: #!/bin/bash
set -eu
cat -n systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
rg -n -C 5 'concurrentUsers|heartbeat|touchWindow|FIELD_SERVICE|SESSION_TTL|RedisSessionStoreAdapter' systems/observabilityRepository: EntryDSM/entrydsm-platform Length of output: 36364 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- adapter ---'
sed -n '1,180p' systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt
printf '%s\n' '--- related test files ---'
git ls-files | rg '(^|/)(test|tests)/|Test\\.kt$|Spec\\.kt$' | rg 'observ|redis|session' | head -200
printf '%s\n' '--- all relevant declarations ---'
rg -n -C 8 'fun (heartbeat|leave|concurrentUsers|touchWindow)|FIELD_SERVICE|ZADD|zadd|zrem|remove.*window|concurrentUsers' .Repository: EntryDSM/entrydsm-platform Length of output: 39421 서비스 소속 변경을 차단하세요.
🤖 Prompt for AI Agents |
||
| return true | ||
|
Comment on lines
+39
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
file="systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- relevant source ---'
sed -n '1,220p' "$file"
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '*.kt' 'RedisSessionStoreAdapter|heartbeat\(|leave\(|touchWindow|SESSION_TTL|DURATION_SUM_KEY|DURATION_COUNT_KEY' systemsRepository: EntryDSM/entrydsm-platform Length of output: 11466 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- adapter module files ---'
git ls-files 'systems/observability/observability-adapter-out'
printf '%s\n' '--- adapter tests and Redis configuration ---'
rg -n --glob '*.kt' --glob '*.gradle*' --glob '*.kts' \
'RedisSessionStoreAdapter|StringRedisTemplate|RedisTemplate|`@DataRedisTest`|RedisConnectionFactory|observability-adapter-out' \
systems build.gradle* settings.gradle* gradle* 2>/dev/null || true
printf '%s\n' '--- application call sites ---'
sed -n '1,130p' systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/SessionCollectionService.kt
printf '%s\n' '--- port contract ---'
sed -n '1,100p' systems/observability/observability-application/src/main/kotlin/hs/kr/entrydsm/observability/application/port/out/SessionStorePort.ktRepository: EntryDSM/entrydsm-platform Length of output: 5557 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from itertools import permutations
heartbeat = ["H.hasKey", "H.put(service)", "H.put(heartbeat)", "H.expire", "H.window"]
leave = ["L.entries", "L.duration", "L.delete", "L.removeWindows"]
bad = []
for order in permutations("H" * len(heartbeat) + "L" * len(leave)):
hi = li = 0
state = "present"
recreated = False
deleted_after_read = False
for actor in order:
if actor == "H":
cmd = heartbeat[hi]
hi += 1
if cmd == "H.hasKey":
heartbeat_seen = state == "present"
elif cmd == "H.put(service)" and heartbeat_seen and state == "deleted":
recreated = True
elif cmd == "H.put(heartbeat)" and heartbeat_seen and state == "deleted":
recreated = True
else:
cmd = leave[li]
li += 1
if cmd == "L.entries":
leave_seen = state == "present"
elif cmd == "L.delete" and leave_seen:
state = "deleted"
if recreated:
bad.append(order)
break
print("interleaving reproduces heartbeat-after-leave recreation:", bool(bad))
if bad:
print("example:", " ".join(bad[0]))
PYRepository: EntryDSM/entrydsm-platform Length of output: 254 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from itertools import permutations
# Model the command-level behavior in RedisSessionStoreAdapter.kt.
# A leave call reads the metadata before it increments duration and deletes the key.
commands = {
"A": ["entries", "increment_sum", "increment_count", "delete", "remove_windows"],
"B": ["entries", "increment_sum", "increment_count", "delete", "remove_windows"],
}
order = ["A"] * len(commands["A"]) + ["B"] * len(commands["B"])
duplicate_counted = False
example = None
for schedule in permutations(order):
positions = {"A": 0, "B": 0}
observed = {"A": False, "B": False}
increments = 0
for actor in schedule:
command = commands[actor][positions[actor]]
positions[actor] += 1
if command == "entries":
observed[actor] = True
elif command == "increment_sum" and observed[actor]:
increments += 1
if increments == 2:
duplicate_counted = True
example = schedule
break
print("two concurrent leave calls can both count one session:", duplicate_counted)
if example:
print("example:", " ".join(example))
PYRepository: EntryDSM/entrydsm-platform Length of output: 253 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from itertools import product
heartbeat = ["hasKey", "put_service", "put_heartbeat", "expire_meta", "window_updates"]
leave = ["entries", "duration_sum", "duration_count", "delete_meta", "window_removals"]
# Search all actor-level interleavings while preserving each method's command order.
def interleavings(a, b):
if not a:
yield b
elif not b:
yield a
else:
for rest in interleavings(a[1:], b):
yield ["H"] + rest
for rest in interleavings(a, b[1:]):
yield ["L"] + rest
recreate = False
duplicate_leave_count = False
recreate_example = None
duplicate_example = None
for schedule in interleavings(heartbeat, leave):
hi = li = 0
meta_exists = True
h_observed_exists = False
l_observed_meta = False
leave_duration_updates = 0
heartbeat_recreated = False
for actor in schedule:
if actor == "H":
command = heartbeat[hi]
hi += 1
if command == "hasKey":
h_observed_exists = meta_exists
elif command.startswith("put_") and h_observed_exists and not meta_exists:
meta_exists = True
heartbeat_recreated = True
else:
command = leave[li]
li += 1
if command == "entries":
l_observed_meta = meta_exists
elif command == "duration_sum" and l_observed_meta:
leave_duration_updates += 1
elif command == "delete_meta" and l_observed_meta:
meta_exists = False
if heartbeat_recreated and not recreate:
recreate = True
recreate_example = schedule
if leave_duration_updates == 2:
duplicate_leave_count = True
duplicate_example = schedule
print("heartbeat can recreate metadata after leave:", recreate)
print("example:", " ".join(recreate_example or []))
print("two leave calls can both count one session:", duplicate_leave_count)
print("example:", " ".join(duplicate_example or []))
PYRepository: EntryDSM/entrydsm-platform Length of output: 274 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
def schedules(h, l, prefix=""):
if h == 0:
yield prefix + "L" * l
elif l == 0:
yield prefix + "H" * h
else:
yield from schedules(h - 1, l, prefix + "H")
yield from schedules(h, l - 1, prefix + "L")
heartbeat = ["hasKey", "put_service", "put_heartbeat", "expire_meta", "window_updates"]
leave = ["entries", "duration_sum", "duration_count", "delete_meta", "window_removals"]
for schedule in schedules(len(heartbeat), len(leave)):
hi = li = 0
exists = True
heartbeat_saw_exists = False
leave_saw_meta = False
recreated = False
for actor in schedule:
if actor == "H":
command = heartbeat[hi]
hi += 1
if command == "hasKey":
heartbeat_saw_exists = exists
elif command.startswith("put_") and heartbeat_saw_exists and not exists:
exists = True
recreated = True
else:
command = leave[li]
li += 1
if command == "entries":
leave_saw_meta = exists
elif command == "delete_meta" and leave_saw_meta:
exists = False
if recreated:
print("heartbeat can recreate metadata after leave: True")
print("example:", schedule)
break
else:
print("heartbeat can recreate metadata after leave: False")
for schedule in schedules(len(leave), len(leave)):
ai = bi = 0
a_saw = b_saw = False
duration_updates = 0
for actor in schedule:
if actor == "H":
command = leave[ai]
ai += 1
if command == "entries":
a_saw = True
elif command == "duration_sum" and a_saw:
duration_updates += 1
else:
command = leave[bi]
bi += 1
if command == "entries":
b_saw = True
elif command == "duration_sum" and b_saw:
duration_updates += 1
if duration_updates == 2:
print("two leave calls can both count one session: True")
print("example:", schedule)
break
else:
print("two leave calls can both count one session: False")
PYRepository: EntryDSM/entrydsm-platform Length of output: 303 세션 lifecycle 전환을 원자적으로 처리하세요.
📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| override fun leave(sessionId: String, service: ServiceName, now: Instant): Boolean { | ||
| val meta = redis.opsForHash<String, String>().entries(metaKey(sessionId)) | ||
| if (meta.isEmpty()) return false | ||
| meta[FIELD_ENTERED_AT]?.toLongOrNull()?.let { enteredAt -> | ||
| val durationSeconds = (now.toEpochMilli() - enteredAt) / 1000 | ||
| redis.opsForValue().increment(DURATION_SUM_KEY, durationSeconds) | ||
| redis.opsForValue().increment(DURATION_COUNT_KEY) | ||
| } | ||
| redis.delete(metaKey(sessionId)) | ||
| ServiceName.entries.forEach { redis.opsForZSet().remove(windowKey(it), sessionId) } | ||
| redis.opsForZSet().remove(ALL_WINDOW_KEY, sessionId) | ||
| return true | ||
| } | ||
|
|
||
| override fun concurrentUsers(service: ServiceName?, now: Instant, windowSeconds: Long): Int { | ||
| val key = service?.let { windowKey(it) } ?: ALL_WINDOW_KEY | ||
| val min = (now.toEpochMilli() - windowSeconds * 1000).toDouble() | ||
| return (redis.opsForZSet().count(key, min, Double.MAX_VALUE) ?: 0L).toInt() | ||
| } | ||
|
|
||
| override fun totalVisitors(): Long = redis.opsForSet().size(VISITORS_KEY) ?: 0L | ||
|
|
||
| override fun avgSessionDurationSeconds(): Long { | ||
| val sum = redis.opsForValue().get(DURATION_SUM_KEY)?.toLongOrNull() ?: 0L | ||
| val count = redis.opsForValue().get(DURATION_COUNT_KEY)?.toLongOrNull() ?: 0L | ||
| return if (count == 0L) 0L else sum / count | ||
| } | ||
|
|
||
| override fun deviceBreakdown(): Map<DeviceType, Long> { | ||
| val entries = redis.opsForHash<String, String>().entries(DEVICE_COUNTS_KEY) | ||
| return DeviceType.entries.associateWith { entries[it.name]?.toLongOrNull() ?: 0L } | ||
| } | ||
|
|
||
| override fun sampleConcurrency(now: Instant, windowSeconds: Long) { | ||
| val current = concurrentUsers(null, now, windowSeconds) | ||
| val currentMax = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0 | ||
| if (current > currentMax) { | ||
| redis.opsForValue().set(CONCURRENT_MAX_KEY, current.toString()) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
file='systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt'
printf '%s\n' '--- outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- implementation ---'
cat -n "$file"
printf '%s\n' '--- related files ---'
rg -l --glob '*.kt' 'RedisSessionStoreAdapter|CONCURRENT_MAX_KEY|sampleConcurrency|concurrentMax' . | head -80
printf '%s\n' '--- Redis atomic API usage ---'
rg -n --glob '*.kt' 'increment\(|decrement\(|eval\(|compareAndSet|opsForValue\(\)\.(get|set)' . | head -160
printf '%s\n' '--- build dependencies ---'
rg -n 'spring-data-redis|lettuce|jedis|redis' --glob 'build.gradle*' --glob 'gradle.properties' --glob 'libs.versions.toml' --glob 'pom.xml' .Repository: EntryDSM/entrydsm-platform Length of output: 10613 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
path = Path("systems/observability/observability-adapter-out/src/main/kotlin/hs/kr/entrydsm/observability/adapterout/redis/RedisSessionStoreAdapter.kt")
source = path.read_text()
assert "val currentMax = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0" in source
assert "if (current > currentMax)" in source
assert 'redis.opsForValue().set(CONCURRENT_MAX_KEY, current.toString())' in source
# Model two concurrent sampleConcurrency calls:
# A observes 10, B observes 20, and both read the old Redis maximum 5.
initial_max = 5
a_current, b_current = 10, 20
a_observed, b_observed = initial_max, initial_max
# B completes its conditional SET first; A completes afterward.
redis_max = initial_max
if b_current > b_observed:
redis_max = b_current
if a_current > a_observed:
redis_max = a_current
assert redis_max == 10
assert redis_max != max(a_current, b_current)
print("The separate GET/conditional SET sequence can leave CONCURRENT_MAX_KEY at 10 instead of 20.")
PYRepository: EntryDSM/entrydsm-platform Length of output: 256
두 🤖 Prompt for AI Agents |
||
| redis.opsForValue().increment(CONCURRENT_SUM_KEY, current.toLong()) | ||
| redis.opsForValue().increment(CONCURRENT_SAMPLES_KEY) | ||
| } | ||
|
|
||
| override fun concurrentMax(): Int = redis.opsForValue().get(CONCURRENT_MAX_KEY)?.toIntOrNull() ?: 0 | ||
|
|
||
| override fun concurrentAvg(): Int { | ||
| val sum = redis.opsForValue().get(CONCURRENT_SUM_KEY)?.toLongOrNull() ?: 0L | ||
| val samples = redis.opsForValue().get(CONCURRENT_SAMPLES_KEY)?.toLongOrNull() ?: 0L | ||
| return if (samples == 0L) 0 else (sum / samples).toInt() | ||
| } | ||
|
|
||
| private fun touchWindow(sessionId: String, service: ServiceName, nowMillis: Long) { | ||
| redis.opsForZSet().add(windowKey(service), sessionId, nowMillis.toDouble()) | ||
| redis.opsForZSet().add(ALL_WINDOW_KEY, sessionId, nowMillis.toDouble()) | ||
| redis.expire(windowKey(service), SESSION_TTL) | ||
| redis.expire(ALL_WINDOW_KEY, SESSION_TTL) | ||
| } | ||
|
|
||
| private fun metaKey(sessionId: String) = "monitor:session:meta:$sessionId" | ||
|
|
||
| private fun windowKey(service: ServiceName) = "monitor:session:window:${service.name}" | ||
|
|
||
| companion object { | ||
| private const val VISITORS_KEY = "monitor:visitors:all" | ||
| private const val DEVICE_COUNTS_KEY = "monitor:device:counts" | ||
| private const val DURATION_SUM_KEY = "monitor:duration:sum" | ||
| private const val DURATION_COUNT_KEY = "monitor:duration:count" | ||
| private const val CONCURRENT_MAX_KEY = "monitor:concurrent:max" | ||
| private const val CONCURRENT_SUM_KEY = "monitor:concurrent:sum" | ||
| private const val CONCURRENT_SAMPLES_KEY = "monitor:concurrent:samples" | ||
| private const val ALL_WINDOW_KEY = "monitor:session:window:ALL" | ||
| private const val FIELD_SERVICE = "service" | ||
| private const val FIELD_ENTERED_AT = "enteredAt" | ||
| private const val FIELD_LAST_HEARTBEAT_AT = "lastHeartbeatAt" | ||
| private val SESSION_TTL: Duration = Duration.ofHours(6) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,7 @@ | ||
| KOTLIN_DEPS = [] | ||
| KOTLIN_DEPS = [ | ||
| "@maven//:org_springframework_boot_spring_boot_starter", | ||
| "//systems/observability/observability-domain:main", | ||
| ] | ||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Spring 의존성을 application 계층에서 제거하세요. Line 2는 application module에 Spring Boot starter를 직접 추가합니다. 관련
As per coding guidelines, "For files under *-application modules, flag direct dependency on infrastructure-specific framework classes unless justified." 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| TEST_DEPS = [ | ||
| "@maven//:junit_junit", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 8638
🏁 Script executed:
Repository: EntryDSM/entrydsm-platform
Length of output: 6967
로그 상관관계를 단언하세요.
keepsGenericResponseAndLogsCorrelationContextForUnhandledException는 응답만 검증합니다. 로그 캡처 도구를 사용해logger.error이벤트와X-trace-Id=test-trace-id를 단언하세요.🤖 Prompt for AI Agents
Source: Path instructions