본문 바로가기
C.W.K.
Lesson 06 of 07 · published

일 하나에 알림 한 번

~15 min · notifications, coop, telegram, idempotency

Level 0구경꾼
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

사건 하나에 울림은 넷까지

Coop은 맡긴 일 얘기를 아빠한테 짧은 줄로 전해. 그런데 2026-10-23 전까진 같은 얘기를 여러 번 하는 일이 잦았어. 워커가 답하고, 1초 뒤 Coop이 같은 말을 blocked로 또 보내고, 1분 뒤엔 Coop chief가 그 일을 아빠한테 park하거나 escalate해. 줄 하나하나는 다 맞는 말이었고, 저마다 자기 아웃박스 행이 있었고, 전부 Master Chief와 아빠 폰 둘 다에 닿았어. 2026-10-22까지 한 주 동안 일 하나에 관한 Coop 줄 486개 중 201개는 120초 안에 같은 일에 관한 새 줄이 뒤따랐고, 그중 124개는 글자까지 똑같았어. 그 옆엔 세션 배관 얘기가 185줄 있었는데, 제일 시끄러운 건 깜빡이는 채팅이었어. Chief엔 2026-10-07부터 "worker session disconnected"가 99줄 쌓였고, 투두 하나의 워커는 끊겼다는 알림이 사흘 밤에 일곱 번, 2026-10-22 이사 때는 1분 만에 다섯 번 왔어.

Chief 쪽을 고친 그날 아침, 아빠가 폰 쪽도 주문했어.

"텔레그램 쪽 중복 알림도 하나로 합쳐."
— 아빠

이 레슨은 그 한 문장 뒤의 장치야. 배관은 밖에 두고, 일 줄은 가라앉히고, 제일 새 줄만 남기고, 붙잡아 둔 줄은 피파가 직접 배달해.

배관은 안 들어와

첫 규칙은 거름망이야. Coop 줄 중엔 일이 아니라 워커 세션에 관한 게 있어. 세션이 떴다, 끊겼다, 끝났다, 등록됐다, 초대가 만료됐거나 거절됐다, 후속 지시를 못 전했다. 이런 건 Coop과 Telegram에 남고, Chief는 필요하면 coop 도구로 읽어. chief_doors.COOP_INFO_REASONS가 그 목록이야. 키는 Coop이 보내는 사유고, 값은 알림기가 쓰는 줄 머리야. 그래서 coop_info_only는 Coop의 사유로, 엔진이 사유를 안 보내면 줄 머리로 그런 줄을 알아봐.

Coop의 사유줄 머리
remote_session_readyremote session ready
session_disconnectedworker session disconnected
session_exitedworker session exited
take_adoptedchat enrolled
assignment_expiredassignment setup expired
invitation_declinedinvitation declined
invitation_expiredinvitation not accepted
followup_undeliveredfollow-up not delivered

가라앉히고, 갈아 끼워

둘째 규칙은 창이야. 문은 줄 머리에서 어느 일인지 읽어 ([Coop] … : todo #N, coop_item으로). 그리고 Chief 아웃박스에 group coop:todo #N과 120초짜리 settle(COOP_SETTLE_SECONDS)을 넘겨. report()는 그만큼 뒤의 not_before를 달아 행을 적고 바로 돌아와. 줄 하나 때문에 2분을 기다릴 쪽은 없어야 하니까. 같은 group의 새 행이 오면, 아직 내려앉지 못한 그 group의 옛 행엔 전부 새 키가 superseded_by로 찍혀. 드레이너는 창이 지났고 갈아 끼워지지 않은 행만 내려놔. select 뒤에도 한 번 더 확인해. 그 사이에 새 줄이 올 수 있거든.

조용하기만 한 게 아니라 안전하기까지 한 건 세부 셋 덕이야. 갈아 끼우는 건 새 행뿐이야. Coop 엔진은 답을 받을 때까지 알림을 다시 보내는데, 옛 줄의 재시도가 그 뒤에 온 줄의 자리를 차지하면 안 되니까. 갈아 끼워진 행의 재시도는 아무것도 안 돌려주고 절대 안 내려앉아. 그리고 갈아 끼워진 행은 안 지워. superseded_by가 찍힌 채 아웃박스에 남아서, 무슨 말이 있었고 뭐가 그걸 대신했는지 기록이 돼. Coop도 줄을 전부 간직해. 마지막 하나만 남기는 건 Chief의 재생뿐이야.

Telegram도 똑같이 가라앉혀 (3097f620). 일에 관한 줄은 글, group, not_before까지 같이 coop_notify_sent에 held로 붙잡혀. 그 일에 관한 새 줄이 오면 붙잡혀 있던 줄은 superseded가 돼. 어느 일에도 안 걸린 줄은 예전처럼 찜하고 바로 보내.

붙잡은 줄은 피파가 배달해

줄을 붙잡으면 주인이 바뀌어. 문은 Coop 엔진한테 바로 settling으로 답하고, 엔진은 자기 아웃박스 행을 정리해. 다시는 안 보낸다는 뜻이야. 그러니 피파가 해야 해. 데이터베이스마다 하나뿐인 발송기 _telegram_sender가 제일 이른 줄의 차례가 오면 깨어나. Telegram을 부르기 전에 그 행을 sending으로 찜해. 그래서 조금 앞서 온 새 줄이 이기고, 보내다가 재시작해도 같은 행을 두 번 보내는 일이 없어. 보낸 행은 sent가 돼. 실패하면 held로 돌아가고 not_before가 60초 뒤로 밀려 (TELEGRAM_RETRY_SECONDS). 30번 실패하면(TELEGRAM_MAX_ATTEMPTS) failed가 되고 오류로 남아. 읽기 시간 초과도 여기선 실패로 쳐. Telegram이 그 줄을 이미 띄웠을 수 있으니, 이 경우엔 1분 뒤에 한 번 더 울릴 수 있어. 시작할 때 resume_held_notifications가 재시작 때문에 sending으로 남은 행을 uncertain으로 바꾸고, 그런 행은 다시 안 보내. 그리고 아직 기다리는 건 배달해. 어느 쪽이든 Master Chief 사본은 단단히 남아 있어. 그래서 죽고 난 뒤엔 틀리더라도 '많아야 한 번' 쪽으로 틀리는 게 맞아. 5트랙 레슨 4 "막히면 크게, 성공은 조용히"가 후속 확인에서 고른 것과 같은 선택이고, 빈틈도 같아. 답이 안 돌아온 전송은 불확실로 두지 않고 다시 보내.

반복엔 띠를 하나 더

가라앉히기는 한 일에 관한 줄들이 붙어서 올 때 접어 줘. 몇 시간마다 깜빡이는 채팅은 모양이 달라. 같은 줄이 멀리 떨어져서 와. 진짜 수리는 Coop 쪽에서 했어 (cwkCoop d917c51, #599, M3). 이미 알린 끊김은 그 뒤 보고나 메모가 없으면 다시 안 알리고, 어댑터는 실패가 60초 이어지기 전엔 아무것도 판정하지 않아. 피파의 문도 띠를 하나 더 둘렀어 (7a993593). 지난 30분 안에 Telegram이 실어 간 주의 줄과 공백 빼고 똑같은 줄이 오면, 배달됐다고 답하고, 그 답의 repeat_of에 앞선 행을 적어. 기록은 자기 행에 repeat로 남기는데, 그 행엔 줄의 해시만 있고 어느 행을 되풀이했는지는 없어. 그리고 Master Chief까지 포함해 어디에도 안 보내. 30분이 지나면 같은 줄도 다시 소식이야.

얻은 것과 치른 것

게이트 전 한 주에 이 규칙을 다시 돌려 보면(전체 표는 2트랙 레슨 6 "Chief는 성역이야"에 있어) Chief의 Coop 줄 671개가 285개가 되고, 배관 얘기는 하나도 없어. 토큰으로는 아빠 턴의 1% 남짓이야. 압축이 이미 그 줄들을 작게 붙들고 있었으니까. 얻은 건 읽기야. 아빠 폰에선 그게 전부야. 사건 하나에 울림 하나, 그리고 그 울림엔 그 일의 가장 최근 말이 실려. 그 앞에 온 답이 아니라, 대개 아빠한테 뭔가를 묻는 말이야(park, escalation). 값은 시간과 장치야. 일에 관한 결과는 이제 Coop이 쓴 뒤 최대 2분 늦게 폰에 닿아. 그리고 예전엔 문이 Coop 엔진한테 맡겨 두던 재시도 고리와 작은 상태 기계(held, sending, sent, superseded, failed, uncertain, repeat)를 이제 피파가 쥐고 있어.

읽는 쪽이 신경 쓰는 단위로 묶고, 제일 새 걸 남겨. 사건마다 알림 하나는 시스템 눈으로 본 세상이야. 짧게 가라앉힌 뒤 일마다 하나가 읽는 쪽 눈이고.

Code

원문 발췌, routes/coop_chief.py notify(), 2026-10-27 기준: 배관은 밖에, Chief 줄은 가라앉히고, Telegram 줄은 붙잡아·python
    # The Chief is a sanctuary (todo #624): a worker session's plumbing (ready,
    # disconnected, enrolled) stays in Coop and on Telegram, and the Chief
    # reads it with the coop tool when asked.
    chief_message=None
    if not chief_doors.coop_info_only(body.text,body.reason):
        # The Chief line is durable in its outbox before this waits, and the
        # wait is bounded: a busy Chief held this request past the engine's
        # 15 s timeout, every retry added a waiter, and each re-sent Telegram
        # once the Chief was free (cold review #6). It lands when the Chief is
        # free, and a line about a work item settles first: a newer line for
        # the same item within the window takes its place (one event used to
        # reach the Chief as reply, blocked, then parked or escalated).
        item=chief_doors.coop_item(body.text)
        chief_message=await report_to_chief(text=body.text,source_id='coop-notify:'+body.outbox_id,store=store,
                                            wait=NOTIFY_CHIEF_WAIT_SECONDS,group='coop:'+item if item else None,
                                            settle=chief_doors.COOP_SETTLE_SECONDS)
    answer={'delivered':True,'channel':'telegram','outbox_id':body.outbox_id,'chief_message_id':chief_message}
    if chief_task:
        answer['chief_task']=chief_task
    item=chief_doors.coop_item(body.text)
    settle=chief_doors.COOP_SETTLE_SECONDS
    if item and settle>0:
        state=await _hold_notify(store,body.outbox_id,body.text,'coop:'+item,settle)
        if state=='held':
            return {**answer,'settling':True}
        return {**answer,'already_sent':state in ('sent','repeat'),'state':state}
직접 돌려볼 수 있는 최소 스케치, 엔진 아님 (표준 라이브러리, Python 3.9 이상): 한 일에 관한 줄은 가라앉고, 새 줄이 아직 가라앉는 줄을 갈아 끼우고, 갈아 끼워진 줄의 재시도는 절대 안 내려앉아·python
"""A minimal sketch: lines about one work item settle for a window, the newest
takes the place of the ones still settling, and a retry of a replaced line
never lands. Not the engine: one table, a fake clock, no Telegram."""
import sqlite3

SETTLE = 120.0
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE outbox (key TEXT PRIMARY KEY, item TEXT, text TEXT,
              not_before REAL, superseded_by TEXT, landed REAL)""")
OWED = "landed IS NULL AND superseded_by IS NULL"


def report(key, item, text, now):
    """Hold a line until it settles; only a NEW row supersedes older ones."""
    inserted = db.execute("INSERT OR IGNORE INTO outbox (key, item, text, not_before) VALUES (?, ?, ?, ?)",
                          (key, item, text, now + SETTLE)).rowcount
    if inserted:
        db.execute(f"UPDATE outbox SET superseded_by=? WHERE item=? AND key!=? AND {OWED}", (key, item, key))
        return "settling"
    (by,) = db.execute("SELECT superseded_by FROM outbox WHERE key=?", (key,)).fetchone()
    return f"retry of a line replaced by {by}: never lands" if by else "retry: nothing changes"


def drain(now):
    """Land every line whose window has passed and that nothing replaced."""
    due = db.execute(f"SELECT key, text FROM outbox WHERE {OWED} AND not_before<=? ORDER BY not_before",
                     (now,)).fetchall()
    for key, text in due:
        db.execute("UPDATE outbox SET landed=? WHERE key=?", (now, key))
        print(f"t={now:5.0f}s  RING  {text}")


print(report("c1", "todo #9", "[Coop] worker reply: todo #9 ...", now=0))
print(report("c2", "todo #9", "[Coop] blocked: todo #9 ...", now=1))
print(report("c3", "todo #9", "[Coop] parked for Dad: todo #9 ...", now=62))
print(report("c1", "todo #9", "[Coop] worker reply: todo #9 ...", now=70))   # the engine retries c1
print(report("d1", "todo #12", "[Coop] done: todo #12 ...", now=80))
for t in (60, 121, 182, 200):
    drain(t)

# Output:
# settling
# settling
# settling
# retry of a line replaced by c2: never lands
# settling
# t=  182s  RING  [Coop] parked for Dad: todo #9 ...
# t=  200s  RING  [Coop] done: todo #12 ...

Exercise

네 시스템 하나가 한 주 동안 보낸 알림을 뽑아서, 알림을 만든 사건이 아니라 알림이 다룬 대상(티켓, 빌드, 배포)으로 묶어 봐. 대상마다, 같은 대상에 관한 다른 알림과 2분 안에 붙어 온 게 몇 개인지 세. 그다음 보내는 쪽 앞에 가라앉히는 창을 둬. 줄마다 그 창만큼 붙잡고, 같은 대상의 새 줄이 오면 붙잡힌 줄을 갈아 끼우고, 갈아 끼워진 줄은 지우지 말고 기록으로 남겨.
Hint
만들기 전에 붙잡은 줄을 누가 다시 보낼지부터 정해. 줄을 아직 붙잡고 있는데 위쪽 시스템한테 '받았어'라고 답하면 위쪽은 재시도를 멈춰. 그러니 붙잡은 쪽이 배달을 쥐어야 해. 보내기 전에 찜하고, 실패하면 상한을 두고 다시 하고, 재시작 뒤 반쯤 보낸 줄은 다시 보내지 말고 불확실로 둬.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.