The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
4.5 KiB
Plaintext
98 lines
4.5 KiB
Plaintext
# 청구 저장소의 javadoc
|
|
25: * <p>Claiming is {@code FOR UPDATE SKIP LOCKED} inside the select, for the same reason the delivery
|
|
26: * queue is: two workers polling the same table must not both take the same job.
|
|
# 청구 문장과 그것을 실행하는 메서드
|
|
44: SELECT id, attempt_id, provider_profile_id, next_check_at, attempts, last_result,
|
|
45: created_at, updated_at
|
|
46: FROM notification_reconciliation_job
|
|
47: WHERE next_check_at <= ?
|
|
48: ORDER BY next_check_at, id
|
|
49: LIMIT ?
|
|
50: FOR UPDATE SKIP LOCKED
|
|
51: """;
|
|
52:
|
|
113: public List<ReconciliationJob> claimDue(int limit, Instant now) {
|
|
114- Objects.requireNonNull(now, "now");
|
|
115- if (limit < 1) {
|
|
116- throw new IllegalArgumentException("limit");
|
|
117- }
|
|
118- return jdbc.query(CLAIM_DUE, MAPPER, Timestamp.from(now), limit);
|
|
119- }
|
|
120-
|
|
# 그 클래스의 @Transactional / TransactionPort: 0
|
|
|
|
# 같은 패키지의 전달 큐는 같은 잠금 구문에 UPDATE 를 붙인다
|
|
27: "WITH claimable AS ("
|
|
28: + " SELECT id FROM notification_recipient_delivery"
|
|
29: + " WHERE next_dispatch_at <= :now"
|
|
30: + " AND delivery_state IN ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING')"
|
|
31: + " AND (expires_at IS NULL OR expires_at > :now)"
|
|
32: + " AND (lease_until IS NULL OR lease_until < :now)"
|
|
33: + " ORDER BY next_dispatch_at, id"
|
|
34: + " FOR UPDATE SKIP LOCKED"
|
|
35: + " LIMIT :batchSize"
|
|
36: + ") "
|
|
37: + "UPDATE notification_recipient_delivery AS d "
|
|
38: + " SET lease_owner = :owner,"
|
|
39: + " lease_until = :leaseUntil,"
|
|
40: + " lease_fence = d.lease_fence + 1,"
|
|
41: + " delivery_state = 'DISPATCHING',"
|
|
42: // The JPA @Version column, advanced by the native write. Without this a managed entity
|
|
43: // loaded before the claim still holds the old version, so its flush succeeds and
|
|
44: // overwrites the lease and the state with pre-claim values — optimistic locking that
|
|
45: // reports no conflict because the native statement never told it there was one.
|
|
46: + " version = d.version + 1,"
|
|
47: + " updated_at = :now"
|
|
48: + " FROM claimable"
|
|
49: + " WHERE d.id = claimable.id"
|
|
50: + " RETURNING d.id, d.lease_fence";
|
|
|
|
# 작업자는 조회와 정산 사이에 제공자를 부른다
|
|
80: public int reconcileOnce() {
|
|
81- List<ReconciliationJob> due = jobs.claimDue(batchSize, clock.instant());
|
|
82- int settled = 0;
|
|
83- for (ReconciliationJob job : due) {
|
|
84- // One job's failure is not the pass's: a provider that is refusing connections would
|
|
85- // otherwise stop every other provider's jobs behind it.
|
|
86- try {
|
|
87- if (handle(job)) {
|
|
88- settled++;
|
|
89- }
|
|
90- } catch (RuntimeException failure) {
|
|
91- jobs.reschedule(
|
|
92- job, failure.getClass().getSimpleName(), clock.instant().plus(retryBackoff));
|
|
93- }
|
|
94- }
|
|
95- return settled;
|
|
96- }
|
|
# 그 클래스의 @Transactional / TransactionPort: 0
|
|
|
|
# 조정 작업 표의 열
|
|
68:CREATE TABLE notification_reconciliation_job (
|
|
69- id uuid PRIMARY KEY,
|
|
70- attempt_id uuid NOT NULL REFERENCES notification_delivery_attempt(id),
|
|
71- provider_profile_id varchar(120) NOT NULL,
|
|
72- next_check_at timestamptz NOT NULL,
|
|
73- attempts integer NOT NULL DEFAULT 0,
|
|
74- last_result varchar(40),
|
|
75- created_at timestamptz NOT NULL,
|
|
76- updated_at timestamptz NOT NULL,
|
|
77- CONSTRAINT uk_notification_reconciliation_attempt UNIQUE (attempt_id),
|
|
78- CONSTRAINT ck_notification_reconciliation_attempts CHECK (attempts >= 0)
|
|
79-);
|
|
80-
|
|
# 그 표 정의 안에서 owner/lease/status 를 포함한 줄: 0
|
|
# 이 표를 건드리는 마이그레이션 전부
|
|
V3__notification_platform_inbox_admin.sql:68:CREATE TABLE notification_reconciliation_job (
|
|
V3__notification_platform_inbox_admin.sql:82: ON notification_reconciliation_job (next_check_at, id);
|
|
V4__notification_platform_alignment_and_activation.sql:178: ('notification_reconciliation_job'),
|
|
|
|
# 도달 조건
|
|
486: enabled: ${APP_NOTIFICATION_PLATFORM_ENABLED:false}
|
|
489: mode: ${APP_NOTIFICATION_PLATFORM_MODE:SERVING}
|
|
61: Executors.newScheduledThreadPool(
|
|
62- 1,
|
|
63- runnable -> {
|
|
64- Thread thread = new Thread(runnable, "notification-background");
|
|
78: schedule("reconciliation", reconciliation::reconcileOnce);
|