Class: Insika::InboundLog

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/inbound_log.rb

Overview

Short-lived memory of inbound event ids, so a platform's retry does not become a second LLM turn and a second reply.

Every messaging platform retries a webhook it did not see acked in time, and a relay consumer that hands us its own queue does the same. Without this, the cost of one flaky ack is a duplicated turn (paid for) and a duplicated answer (visible to the customer). With it, the retry finds the id and gets the SAME task back — the caller learns it already sent this, which is a different fact from "your message was merged into someone else's turn".

The id is the CALLER's (wamid.…, a Slack event id, whatever the consumer's queue uses). A caller that cannot supply a stable one gets at-least-once turns and is told so in the docs — the engine does not hash the content and call that dedup, because two customers legitimately typing "oi" one second apart is not a duplicate.

TTL, not forever: this is a retry window, not an audit log. An expired entry is deleted lazily on read; sweep (boot) clears whatever nobody read back.

Constant Summary collapse

SCOPE =
"inbound"
KEY_PREFIX =
"inbound:"
DEFAULT_TTL =

24h — longer than any platform's retry schedule

86_400

Instance Method Summary collapse

Constructor Details

#initialize(store:, ttl: DEFAULT_TTL, clock: -> { Time.now.utc }) ⇒ InboundLog

Returns a new instance of InboundLog.



30
31
32
33
34
# File 'lib/insika/inbound_log.rb', line 30

def initialize(store:, ttl: DEFAULT_TTL, clock: -> { Time.now.utc })
  @store = store
  @ttl = ttl.to_i
  @clock = clock
end

Instance Method Details

#find(key) ⇒ Object

-> the task id this event already produced, or nil (never seen, or the window closed). An expired entry is removed as it is read: the next identical id is honestly a new message by then.



39
40
41
42
43
44
45
46
47
48
# File 'lib/insika/inbound_log.rb', line 39

def find(key)
  record = @store.get(SCOPE, key_for(key))
  return nil if record.nil?

  if expired?(record)
    @store.delete(SCOPE, key_for(key))
    return nil
  end
  record["task_id"]
end

#record(key, task_id) ⇒ Object

Remembers that key produced task_id. Last write wins, like every other store — a re-record inside the window just refreshes the expiry.



52
53
54
55
56
57
58
59
# File 'lib/insika/inbound_log.rb', line 52

def record(key, task_id)
  @store.set(SCOPE, key_for(key), {
               "key" => key.to_s,
               "task_id" => task_id&.to_s,
               "expires_at" => (@clock.call + @ttl).iso8601
             })
  task_id
end

#sweepObject

Boot housekeeping: drops the entries nobody came back for. -> count removed.



62
63
64
65
66
67
68
69
# File 'lib/insika/inbound_log.rb', line 62

def sweep
  @store.list(SCOPE, KEY_PREFIX).count do |key|
    record = @store.get(SCOPE, key)
    next false unless record && expired?(record)

    @store.delete(SCOPE, key)
  end
end