Skip to content

Commit b409788

Browse files
committed
feat: Defer command when In-Reply-To absent, retry on thread arrival
1 parent c780668 commit b409788

5 files changed

Lines changed: 375 additions & 31 deletions

File tree

resources/bark-schema.edn

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444
:attachment/data {:db/valueType :db.type/string}
4545
:email/in-reply-to {:db/valueType :db.type/string}
4646
:email/references {:db/valueType :db.type/string}
47+
;; Ancestors-mids: persisted union of References + In-Reply-To,
48+
;; one entry per ancestor message-id. Indexed multi-valued so the
49+
;; pending-thread retry can locate emails sharing a thread in O(hits).
50+
:email/ancestor-mids {:db/valueType :db.type/string
51+
:db/cardinality :db.cardinality/many}
52+
;; Set when an email is ingested while its In-Reply-To target is
53+
;; absent from the DB. Phase 3/4 of process-email! is skipped on
54+
;; pending emails; they are retried when a related email arrives or
55+
;; when a TTL job forces the threading.
56+
:email/pending-thread? {:db/valueType :db.type/boolean}
4757
:email/headers-edn {:db/valueType :db.type/string}
4858
:email/ingested-at {:db/valueType :db.type/instant}
4959
:email/digested-at {:db/valueType :db.type/instant}

src/bark/digest.clj

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525

2626
(def email-pull-pattern
2727
'[:db/id :email/id :email/source :email/subject :email/message-id
28-
:email/in-reply-to :email/references
28+
:email/in-reply-to :email/references :email/ancestor-mids
29+
:email/pending-thread?
2930
:email/author-address :email/author-name
3031
:email/from-address :email/from-name
3132
:email/date-sent :email/ingested-at
@@ -472,17 +473,68 @@
472473
(log/info "Auto-created single-member series for"
473474
(count patches) "patch attachments"))))
474475

476+
;; ---------------------------------------------------------------------------
477+
;; Pending-thread retry (out-of-order delivery rescue)
478+
;; ---------------------------------------------------------------------------
479+
480+
(defn- in-reply-to-resolved?
481+
"True when the email's In-Reply-To target is nil (root) or already
482+
stored in the DB. When false, the email is held as pending — its
483+
threading and commands are deferred until the missing ancestor (or
484+
any thread sibling) arrives."
485+
[db email]
486+
(let [irt (:email/in-reply-to email)]
487+
(or (nil? irt)
488+
(some? (d/entid db [:email/message-id irt])))))
489+
490+
(declare process-email!)
491+
492+
(defn- retry-pending-in-shared-thread!
493+
"After processing `email` normally, retry any pending email that
494+
shares at least one ancestor mid with it (or that has `email`'s own
495+
mid in its ancestors). The recursive `process-email!` call retracts
496+
the pending flag if its In-Reply-To is now resolvable."
497+
[conn email source-map sources]
498+
(let [own-mid (:email/message-id email)
499+
ancestors (cond-> (set (:email/ancestor-mids email))
500+
own-mid (conj own-mid))]
501+
(when (seq ancestors)
502+
(let [pendings (d/q '[:find [?e ...] :in $ [?mid ...]
503+
:where
504+
[?e :email/pending-thread? true]
505+
[?e :email/ancestor-mids ?mid]]
506+
(d/db conn) (vec ancestors))]
507+
(doseq [pending-eid pendings]
508+
(let [pending-email (d/pull (d/db conn) email-pull-pattern pending-eid)]
509+
(log/info "Retrying pending email" (:email/message-id pending-email)
510+
"(triggered by" own-mid ")")
511+
(process-email! conn source-map sources pending-email)))))))
512+
475513
;; ---------------------------------------------------------------------------
476514
;; Single-email processing — orchestrator
477515
;; ---------------------------------------------------------------------------
478516

479517
(defn process-email!
480518
"Process a single email: classify source, detect report, thread,
481-
apply commands, manage series. Called after store-email! succeeds."
482-
[conn source-map sources email]
483-
(let [message-id (:email/message-id email)
484-
eid (:db/id email)
485-
from-addr (:email/author-address email)
519+
apply commands, manage series. Called after store-email! succeeds.
520+
521+
When the email's In-Reply-To points to a message-id absent from
522+
the DB, Phase 3 (threading + commands) and Phase 4 (post-creation
523+
hooks) are skipped and the email is flagged
524+
`:email/pending-thread? true`. A later arrival sharing the same
525+
thread triggers the retry via `retry-pending-in-shared-thread!`,
526+
or the TTL flush forces processing after N days.
527+
528+
Opts:
529+
:force-thread? — bypass the pending-thread guard, threading the
530+
email with whatever ancestors currently exist in
531+
the DB. Used by the TTL flush."
532+
([conn source-map sources email] (process-email! conn source-map sources email {}))
533+
([conn source-map sources email {:keys [force-thread?]}]
534+
(let [message-id (:email/message-id email)
535+
eid (:db/id email)
536+
from-addr (:email/author-address email)
537+
was-pending? (:email/pending-thread? email)
486538
[source-name email delivery] (resolve-email-source! conn email sources)]
487539
(if-not source-name
488540
(log/debug "No matching source for" message-id "— skipping")
@@ -499,23 +551,75 @@
499551
[report-eid report-info]
500552
(maybe-create-report! conn eid message-id email from-addr
501553
source-name source-cfg via-channel? rroles)
554+
db (d/db conn)]
555+
556+
(if (or force-thread? (in-reply-to-resolved? db email))
557+
;; --- Normal path: threading, commands, post-creation hooks ---
558+
(do
559+
(when was-pending?
560+
(d/transact! conn [[:db/retract eid :email/pending-thread? true]])
561+
(log/info "Cleared pending flag on" message-id))
562+
(let [parent-eids (find-reports-for-email email db)
563+
nearest-eids (find-nearest-report email db)
564+
;; Recover the existing report-eid on retry so Phase 4
565+
;; hooks (link-related, close-superseded-thread, …) can
566+
;; run for pending emails that created a report on first
567+
;; pass but were skipped past Phase 3/4.
568+
report-eid (or report-eid
569+
(when (and was-pending?
570+
(report-exists? db message-id))
571+
(d/entid db [:report/message-id message-id])))]
572+
573+
(when (and (seq parent-eids) via-channel?)
574+
(thread-and-apply-commands! conn eid email from-addr source-name rroles
575+
source-map delivery parent-eids nearest-eids
576+
(some? report-eid)))
577+
578+
;; Phase 4: post-creation hooks (plan is pure, execution is effectful)
579+
(when report-eid
580+
(let [patches (detect/build-patch-entities email)
581+
plan (post-creation-plan report-info nearest-eids parent-eids patches)]
582+
(run-post-creation-hooks! conn report-eid eid email from-addr report-info
583+
parent-eids nearest-eids patches plan)))
584+
585+
;; Mark email as fully digested so future re-fetches can skip it.
586+
(d/transact! conn [{:db/id eid :email/digested-at (Date.)}])
587+
588+
;; Out-of-order rescue: this email may have unblocked pending
589+
;; siblings/descendants. Run AFTER the digested-at write so
590+
;; the recursive call sees a consistent state.
591+
(retry-pending-in-shared-thread! conn email source-map sources)))
592+
593+
;; --- Pending path: defer threading and commands ---
594+
(do (d/transact! conn [{:db/id eid
595+
:email/pending-thread? true
596+
:email/digested-at (Date.)}])
597+
(log/info "Pending:" message-id "— in-reply-to"
598+
(:email/in-reply-to email) "absent from DB")))))))))
599+
600+
;; ---------------------------------------------------------------------------
601+
;; TTL flush — force-process pending emails older than max-age-days
602+
;; ---------------------------------------------------------------------------
502603

503-
;; Phase 3: threading and commands
504-
db (d/db conn)
505-
parent-eids (find-reports-for-email email db)
506-
nearest-eids (find-nearest-report email db)]
507-
508-
(when (and (seq parent-eids) via-channel?)
509-
(thread-and-apply-commands! conn eid email from-addr source-name rroles
510-
source-map delivery parent-eids nearest-eids
511-
(some? report-eid)))
512-
513-
;; Phase 4: post-creation hooks (plan is pure, execution is effectful)
514-
(when report-eid
515-
(let [patches (detect/build-patch-entities email)
516-
plan (post-creation-plan report-info nearest-eids parent-eids patches)]
517-
(run-post-creation-hooks! conn report-eid eid email from-addr report-info
518-
parent-eids nearest-eids patches plan)))
519-
520-
;; Mark email as fully digested so future re-fetches can skip it.
521-
(d/transact! conn [{:db/id eid :email/digested-at (Date.)}]))))))
604+
(defn flush-stale-pending!
605+
"Force-process pending emails older than `max-age-days`. The pending
606+
flag is retracted and threading runs against whatever ancestors
607+
currently exist in the DB. Returns the count of flushed emails."
608+
[conn source-map sources max-age-days]
609+
(let [cutoff (Date. (- (System/currentTimeMillis)
610+
(* max-age-days 24 60 60 1000)))
611+
pendings (d/q '[:find [?e ...] :in $ ?cutoff
612+
:where
613+
[?e :email/pending-thread? true]
614+
[?e :email/ingested-at ?ts]
615+
[(.before ^java.util.Date ?ts ^java.util.Date ?cutoff)]]
616+
(d/db conn) cutoff)]
617+
(when (seq pendings)
618+
(log/info "Flushing" (count pendings)
619+
"stale pending email(s) older than" max-age-days "day(s)"))
620+
(doseq [eid pendings]
621+
(d/transact! conn [[:db/retract eid :email/pending-thread? true]])
622+
(let [email (d/pull (d/db conn) email-pull-pattern eid)]
623+
(log/info "TTL-flush" (:email/message-id email))
624+
(process-email! conn source-map sources email {:force-thread? true})))
625+
(count pendings)))

src/bark/ingest.clj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@
175175
(parse-message-ids (if (vector? v)
176176
(str/join " " (keep identity v))
177177
v)))
178+
ancestor-mids (let [refs-vec (when references
179+
(re-seq #"<[^>]+>" references))
180+
base (vec refs-vec)]
181+
(->> (cond-> base
182+
(and in-reply-to (not (some #{in-reply-to} base)))
183+
(conj in-reply-to))
184+
distinct
185+
vec))
178186
attachments (mapv (fn [att]
179187
(let [filename (or (:filename att) "unnamed")
180188
is-patch (re-find #"(?i)\.(patch|diff)$" filename)
@@ -231,6 +239,7 @@
231239
(seq attachments) (assoc :email/attachments attachments)
232240
in-reply-to (assoc :email/in-reply-to in-reply-to)
233241
references (assoc :email/references references)
242+
(seq ancestor-mids) (assoc :email/ancestor-mids ancestor-mids)
234243
headers-edn (assoc :email/headers-edn headers-edn)))))
235244

236245
;; ---------------------------------------------------------------------------

src/bark/main.clj

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,18 +417,25 @@
417417

418418
(def ^:private one-day-ms (* 24 60 60 1000))
419419

420+
;; Pending-thread emails are flushed (force-threaded) after this many
421+
;; days, so that genuine orphans (In-Reply-To target never arrives)
422+
;; eventually leave the queue rather than accumulating forever.
423+
(def ^:private pending-flush-max-age-days 7)
424+
420425
(defn- maybe-expire!
421-
"Run expire-reports! if at least one day has elapsed since `last-ms`.
422-
Returns the updated timestamp on success, or `last-ms` on failure
423-
so that the next cycle retries."
424-
[db-conn source-map last-ms]
426+
"Run expire-reports! and flush stale pending emails if at least one
427+
day has elapsed since `last-ms`. Returns the updated timestamp on
428+
success, or `last-ms` on failure so that the next cycle retries."
429+
[db-conn source-map sources last-ms]
425430
(let [now (System/currentTimeMillis)]
426431
(if (> (- now last-ms) one-day-ms)
427432
(try
428433
(expire/expire-reports! db-conn source-map)
434+
(digest/flush-stale-pending! db-conn source-map sources
435+
pending-flush-max-age-days)
429436
now
430437
(catch Exception e
431-
(log/error e "Expire failed:" (blog/exception-msg e))
438+
(log/error e "Expire/flush failed:" (blog/exception-msg e))
432439
last-ms))
433440
last-ms)))
434441

@@ -518,7 +525,7 @@
518525
(try
519526
(log/info "Mailbox connected, folder:" folder)
520527
(catch-up-fetch! src db-conn folder fetch-opts source-map sources ingest-opts mailbox-type)
521-
(let [ts (maybe-expire! db-conn source-map last-expire-ms)]
528+
(let [ts (maybe-expire! db-conn source-map sources last-expire-ms)]
522529
(when-not (shutting-down?)
523530
(start-watch! src db-conn folder source-map sources ingest-opts))
524531
ts)
@@ -548,6 +555,8 @@
548555
(try
549556
(catch-up-fetch! src db-conn folder fetch-opts source-map sources ingest-opts mailbox-type)
550557
(expire/expire-reports! db-conn source-map)
558+
(digest/flush-stale-pending! db-conn source-map sources
559+
pending-flush-max-age-days)
551560
(finally
552561
(close-mailbox! src)))))
553562

0 commit comments

Comments
 (0)