<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Utkarsh Gupta</title>
    <description>The latest articles on DEV Community by Utkarsh Gupta (@utkarsh_gupta).</description>
    <link>https://dev.to/utkarsh_gupta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4235597%2F37010513-6df4-4466-b55b-2977a123c89e.jpg</url>
      <title>DEV Community: Utkarsh Gupta</title>
      <link>https://dev.to/utkarsh_gupta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/utkarsh_gupta"/>
    <language>en</language>
    <item>
      <title>Why an ID Check Let the Same PDF Into Our Pipeline Twice</title>
      <dc:creator>Utkarsh Gupta</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:33:47 +0000</pubDate>
      <link>https://dev.to/utkarsh_gupta/why-an-id-check-let-the-same-pdf-into-our-pipeline-twice-18g5</link>
      <guid>https://dev.to/utkarsh_gupta/why-an-id-check-let-the-same-pdf-into-our-pipeline-twice-18g5</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An ID tells you where a record came from, not what's in it. If the same file can arrive from two places, compare the file itself.&lt;/li&gt;
&lt;li&gt;Two owners can publish the exact same file, and both copies are real. A duplicate check has to include who the file belongs to.&lt;/li&gt;
&lt;li&gt;When the duplicate check itself breaks, decide on purpose what happens next. We chose for our main save jobs to save everything, because losing a real document costs more than keeping an extra copy.&lt;/li&gt;
&lt;li&gt;If two checks answer "have we seen this before?", they must agree on what counts as seen, including whether records that were deleted, or already flagged as copies of another record, still count.&lt;/li&gt;
&lt;li&gt;Passing tests don't prove a new step runs. Walk one real day of input through every filter in front of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our pipeline checked every new document against the ones it already had. It still saved the same PDF twice. The check was working fine, it was just checking the wrong thing.&lt;/p&gt;

&lt;p&gt;You've probably seen this at home. The same bill reaches you by email and by post, each with its own reference number. If you only compare reference numbers, it looks like two bills, and you pay twice. The only way to know is to look at what's inside.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://multibagg.ai" rel="noopener noreferrer"&gt;Multibagg AI&lt;/a&gt; is an AI-powered stock research platform for Indian investors. People read company documents on it and ask its AI assistant about them, so a document stored twice is a document they may see twice.&lt;/p&gt;

&lt;p&gt;This is how I moved our duplicate check from IDs to file contents, and the bugs that turned up on the way, one of which would have thrown away real documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: one PDF, two IDs
&lt;/h2&gt;

&lt;p&gt;Our pipeline pulls company filings from a few external feeds and saves each one as a row in a documents table, with the PDF in file storage. Every feed gives a document its own ID.&lt;/p&gt;

&lt;p&gt;The save jobs decided "new or not" from those IDs. One job compared the feed's own ID with the IDs it had already stored. Another job was weaker still: it made up a fresh random ID for every row it saved, so its only real check was the download link. If the link was already stored, it skipped the document. If not, it saved it.&lt;/p&gt;

&lt;p&gt;So when the same PDF reached us through a second feed, or under a new link, nothing matched. The pipeline saved it again.&lt;/p&gt;

&lt;p&gt;A third feed had a different problem. It delivered investor documents such as call transcripts and presentations, and many of them were the same PDFs we already had from the filings feeds. Its records carried no fingerprint of the file at all, so there was no way to tell whether a document was new. That feed's records lived in a table of its own, separate from the main documents table, so the documents that only existed there never made it into the main table, which meant they never reached the step that converts and indexes documents for search.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did the old pipeline catch duplicates?
&lt;/h2&gt;

&lt;p&gt;It did catch most of them, just late. The classification job, the step that downloads each new document and asks an AI model what kind of document it is, had a duplicate check. I'd added it a few weeks earlier.&lt;/p&gt;

&lt;p&gt;That check used a content hash. A content hash is a fixed-length fingerprint computed from a file's bytes. SHA-256, the one we use, gives the same 64-character hex string for the same bytes every time, and a different one for practically any change to the file.&lt;/p&gt;

&lt;p&gt;Before calling the model, the classification job hashed the PDF and looked for an earlier row with the same hash, for the same company, that wasn't deleted and wasn't already marked as a copy. If it found one, it saved a pointer to the original on the new row and skipped the model call.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A["Feed sends a document"] --&amp;gt; B{"Seen this ID or link?"}
    B --&amp;gt;|"Yes"| C["Skip"]
    B --&amp;gt;|"No"| D["Download and save a new row"]
    D --&amp;gt; E["Classification job hashes the PDF"]
    E --&amp;gt; F{"Same company and hash already stored?"}
    F --&amp;gt;|"Yes"| G["Mark the new row as a copy"]
    F --&amp;gt;|"No"| H["Classify with the AI model"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Before: the save step compared IDs and links. The file's contents were only checked after the row existed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's the save step as it was, rewritten with generic names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DocRow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DocStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;findKnownUrls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;insertMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocRow&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skipDuplicates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveFeedItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocStore&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findKnownUrls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in it is wrong on its own terms. It just never asks what's inside the file. Every duplicate became a real row first and got marked later, and a marked copy was still a row: a teammate later pointed out that our document lists didn't filter on that marker. The investor feed's documents never reached the classification check at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you check the contents without downloading twice?
&lt;/h2&gt;

&lt;p&gt;The save jobs already download every PDF so they can upload it to our file storage. At that moment the bytes are in memory. Hashing them there costs one pass over a buffer we already have, and no extra download.&lt;/p&gt;

&lt;p&gt;For the investor-documents feed, the job now also saves the hash in its own table, and on later runs it reuses that stored hash instead of computing it again. I was firm on that one. Hashing is cheap next to a download, but it isn't free, so if we already have the hash, we don't make it twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hashBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of all this is simple: deduplicate documents by content hash at the moment you save them, while the bytes are still in hand, instead of saving first and cleaning up later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why must the key include the owner, not only the hash?
&lt;/h2&gt;

&lt;p&gt;My first version keyed on the hash alone. About twenty minutes later, before anyone had reviewed it, I changed it to company plus hash.&lt;/p&gt;

&lt;p&gt;Two different companies can file a byte-identical PDF, and each filing is real. A hash-only check would keep the first one and throw the second away, and nobody would notice, because a missing document doesn't raise an error. The classification check had always scoped its search by company, so keying the new check the same way also made the two agree.&lt;/p&gt;

&lt;p&gt;The lookup runs as two queries per run, however many documents the run has. We were also in the middle of moving the main documents table to a new version, so a copy could live in the old main table or the new one, and each gets one query. From here on, "the main table" means the new one, "the old main table" the one being retired, and "the feed's own table" is where the investor documents lived. Each query takes the list of hashes and the list of companies in the batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_hash&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;file_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;duplicate_of&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate lists match any combination of a company and a hash, including pairs that never came in together. That's fine, because the code builds the key from each returned row and only checks the keys of the documents it's actually holding. A pair that wasn't in the batch never gets looked up.&lt;/p&gt;

&lt;p&gt;The last two conditions came later, from review. More on them below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail open or fail closed?
&lt;/h2&gt;

&lt;p&gt;Any check that sits in front of a write can break: the database times out, a query fails. You have to decide what happens then.&lt;/p&gt;

&lt;p&gt;Fail open means that if the duplicate check can't run, everything goes through. Fail closed means that if it can't run, nothing goes through.&lt;/p&gt;

&lt;p&gt;For the save jobs I chose fail open (one other path still fails closed, see the limitations below). If we keep a duplicate, the worst case is an extra row that the classification check marks later. If we drop a real document, it's gone, and nothing tells us. Dropping a real record is worse than keeping a duplicate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If the lookup fails&lt;/th&gt;
&lt;th&gt;Fail open&lt;/th&gt;
&lt;th&gt;Fail closed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A real new document&lt;/td&gt;
&lt;td&gt;Saved&lt;/td&gt;
&lt;td&gt;Lost for this run, and for good if the feed won't send it again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A duplicate&lt;/td&gt;
&lt;td&gt;Saved, marked later by the classification check&lt;/td&gt;
&lt;td&gt;Not saved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you notice&lt;/td&gt;
&lt;td&gt;A log line and an extra row&lt;/td&gt;
&lt;td&gt;A log line and a document that isn't there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same rule covers documents the check can't judge. If a download or upload failed there are no bytes to hash, and if the company couldn't be resolved there's no key to build. Both pass through unchanged instead of being dropped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;HashLookup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;findExistingKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[],&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dropContentDuplicates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocRow&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HashLookup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DocRow&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findExistingKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`duplicate lookup failed, skipping the database check: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`skipped duplicate &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;seen&lt;/code&gt; set catches the case where the same file shows up twice inside one run, which the database lookup can't see. It works even when the lookup fails, so copies within one run are still removed.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A["Download the PDF"] --&amp;gt; B["Hash the bytes already in memory"]
    B --&amp;gt; C["One batched lookup per table, by company and hash"]
    C --&amp;gt; D{"Did the lookup fail?"}
    D --&amp;gt;|"Yes"| E["Skip the database check and log it"]
    E --&amp;gt; I{"Seen earlier in this run?"}
    I --&amp;gt;|"Yes"| G
    I --&amp;gt;|"No"| H
    D --&amp;gt;|"No"| F{"Key already stored or seen in this run?"}
    F --&amp;gt;|"Yes"| G["Skip"]
    F --&amp;gt;|"No"| H["Save the row"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;After: the save step checks the file's contents before the row exists, and fails open.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic IDs: making a re-run harmless
&lt;/h2&gt;

&lt;p&gt;Investor documents that matched nothing get a new row in the main table. Its ID isn't random. It's the feed's own serial number with a fixed prefix, so the same document always gets the same ID.&lt;/p&gt;

&lt;p&gt;The insert then skips any row whose ID already exists. In PostgreSQL that's &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, which our database library exposes as a skip-duplicates option on its insert-many call. Run the job twice and the second run inserts nothing new and fails on nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COPY_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;docfeed-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;copyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COPY_PREFIX&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;insertUnmatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[],&lt;/span&gt;
  &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;copyId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skipDuplicates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which bugs did review catch?
&lt;/h2&gt;

&lt;p&gt;Three, and none of them would have shown up as an error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deleted rows blocked the same content forever
&lt;/h3&gt;

&lt;p&gt;An AI code review I ran on the branch compared my new lookup with the older classification check. The old check ignored deleted rows and rows already marked as copies. Mine didn't.&lt;/p&gt;

&lt;p&gt;So a document we had deleted on purpose would block the same content from ever coming back in. And a row that was itself a copy counted as an original. I added the same two conditions you saw in the query above, so both checks now agree on what "already stored" means.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same document came back twice in lists
&lt;/h3&gt;

&lt;p&gt;The same review found a bug on the read side. For older dates, our readers pulled investor documents from both the feed's own table and the main table, then just sorted and cut the result to the page size. I was copying investor documents into the main table while they still lived in the feed's own table, under different IDs: the plain serial on one side, the prefixed serial on the other. Nothing could merge them.&lt;/p&gt;

&lt;p&gt;So every one of those documents came back twice, and a page made up of them held only N/2 different documents for N results.&lt;/p&gt;

&lt;p&gt;The review suggested filtering those rows out by their source. That would have hidden real records too, because regular filings from the same source live in the main table with the same source label. I filtered on the ID prefix instead, which only the copied rows carry.&lt;/p&gt;

&lt;h3&gt;
  
  
  The new step would almost never have run
&lt;/h3&gt;

&lt;p&gt;A teammate's review found the one I'm least proud of.&lt;/p&gt;

&lt;p&gt;The investor-documents job already had a date filter from the table migration. Anything dated after the switch-over date was skipped, because readers were supposed to get newer documents from the main table, where the filings feeds' copies already landed. My new insert step sat after that filter. And the job only fetches the previous day's documents.&lt;/p&gt;

&lt;p&gt;Put those together: in production, almost every document would be newer than the switch-over date, the filter would drop it, and my new step would only ever see the odd backdated document. Nothing in our tests modelled what a real day of input looks like, so they had no reason to fail.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["Daily job fetches yesterday's documents"] --&amp;gt; B{"Dated after the switch-over?"}
    B --&amp;gt;|"Yes, almost all"| C["Dropped by the old date filter"]
    B --&amp;gt;|"No, rare backdated ones"| D["New insert step"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;The new step was correct, but an older filter in front of it meant it would almost never get any input.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I removed that filter from the path that feeds the new step, and narrowed the read-side exclusion to copied rows dated before the switch-over. Older documents are read from the feed's own table and newer ones from the main table, so each document is returned once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should a duplicate be dropped, or kept with a pointer?
&lt;/h2&gt;

&lt;p&gt;The same teammate pushed back on the design itself. The old classification check kept the duplicate row and stored a pointer to the original, which leaves a record you can query. My save-time check drops the duplicate, and the only trace is a job log line that ages out.&lt;/p&gt;

&lt;p&gt;We kept the drop. We didn't want duplicate rows in the table at all. I didn't need the perfect design here, I needed the duplicates gone without breaking the flow.&lt;/p&gt;

&lt;p&gt;That choice has costs, and they're real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A skipped duplicate leaves no lasting record. If the check is ever wrong, there's nothing to look back at.&lt;/li&gt;
&lt;li&gt;One of the save jobs checks the link before it downloads. A dropped duplicate never stores its link, so if the same PDF keeps coming under a different link, that job downloads it again on every run while it stays in the feed's window. We judged that case rare and the cost small.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade-offs and limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;There's no unique constraint.&lt;/strong&gt; Duplicates are prevented when we insert, not enforced by the database, so two runs at the same moment could both insert the same file. A partial unique index on company plus hash, limited to rows that aren't deleted and aren't marked as copies, would close that gap. It has to use the same scope as the lookup, because the classification check keeps its marked copies in the table. PostgreSQL treats NULLs as distinct by default, so rows with no hash would still go in. We didn't add it in this change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The old main table has no index on the hash.&lt;/strong&gt; Its half of the lookup is a full table scan, and one of the save jobs runs every two minutes. Combined with fail-open, a slow or failing scan quietly turns the duplicate check off. We accepted it because that table was being retired. The catch is the retirement itself: when the table goes, the lookup has to change in the same commit, or it throws on every run and the check silently stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Older rows have no hash.&lt;/strong&gt; We didn't backfill hashes for documents saved before this change, so the save-time check can't match against them. The classification check still runs and remains the backstop for anything without a hash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only exact copies match.&lt;/strong&gt; A content hash changes if a single byte changes. A re-saved or re-compressed PDF with the same text gets a different hash, and this check treats it as new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One path still fails closed.&lt;/strong&gt; In the job that copies investor documents into the main table, a failed lookup skips the whole insert. Because that job only fetches the previous day, those documents wouldn't come back on their own. Both reviews flagged it, and it went in as a follow-up instead of this change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results and observations
&lt;/h2&gt;

&lt;p&gt;Before merging, the teammate's review asked a fair question: is the hash even a reliable match across feeds? If the investor-documents feed served a slightly different file than the filings feed, every document would land twice anyway.&lt;/p&gt;

&lt;p&gt;So I checked a sample of recent data. The review thread records the matches, not the sample's size or window. 113 different documents from the investor-documents feed matched 221 existing records by hash (one document can match more than one stored row, for example when the same file sits in both the old main table and the new one during the migration; we didn't break the 221 down further). In every matched pair, the feed's own document category agreed with the type our classifier had assigned. Zero mismatches.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;113 documents matched 221 existing records by company and hash&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feed category versus classifier type across matched pairs: 0 mismatches&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lookup cost: two queries per run, one per table&lt;/td&gt;
&lt;td&gt;Read from code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample size and documents that matched nothing&lt;/td&gt;
&lt;td&gt;Not recorded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicates prevented after release&lt;/td&gt;
&lt;td&gt;Not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Byte-different copies of the same document that slipped through&lt;/td&gt;
&lt;td&gt;Not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A matching hash already means identical bytes. The category check adds that these matches were the kind of document we expected. The sample doesn't tell us how many documents matched nothing, or how many copies it missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What other engineers can learn
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Key duplicates on what a record is, not where it came from.&lt;/strong&gt; IDs and links describe the delivery. If the same thing can be delivered twice, compare the thing. For files, a hash of the bytes is the cheapest honest answer, and it's cheapest of all when you take it from bytes you already hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the owner in the key.&lt;/strong&gt; "Same content" and "same record" aren't the same question. Two owners with one identical file are two records. Ask what makes a record unique in your domain, and build the key from that, not just from the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose the failure mode by which mistake costs more.&lt;/strong&gt; Every check in front of a write will fail one day. Write down what happens then. For us an extra row is a nuisance and a missing document is silent data loss, so the check fails open. In a payments system you'd likely pick the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make every "have we seen this?" check agree.&lt;/strong&gt; We had two duplicate checks, and for a short while they disagreed about deleted rows and about rows already marked as copies. When two paths answer the same question, give them one definition, ideally one function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace one real day through every filter.&lt;/strong&gt; The step that never would have run had nothing wrong in it. The problem was a filter in front of it and the shape of the input. Before you ship a new step, take yesterday's real input and walk it through every condition between the fetch and your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Back to that bill that came by email and by post. Our pipeline wasn't careless. It checked every document, every time. It just asked "have I seen this ID?" when the real question was "have I seen this file, for this company?"&lt;/p&gt;

&lt;p&gt;Changing that question took one hash and a better key. Getting it right took deciding what to do when the check breaks, and making sure every check in the pipeline asked the question the same way.&lt;/p&gt;

&lt;p&gt;Which error costs more in your pipeline: a duplicate, or a record that quietly never arrives?&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>postgres</category>
      <category>backend</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Why a 0.86 ms Qdrant Query Timed Out at 60 Seconds in Our Script</title>
      <dc:creator>Utkarsh Gupta</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:33:46 +0000</pubDate>
      <link>https://dev.to/utkarsh_gupta/why-a-086-ms-qdrant-query-timed-out-at-60-seconds-in-our-script-4g9a</link>
      <guid>https://dev.to/utkarsh_gupta/why-a-086-ms-qdrant-query-timed-out-at-60-seconds-in-our-script-4g9a</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A slow request doesn't always mean a busy server. Time one request on its own before you blame load.&lt;/li&gt;
&lt;li&gt;An index only helps when your question matches what the index stores. Ask a text index for a number and the database can't use it.&lt;/li&gt;
&lt;li&gt;In an "either this or that" search, one half that can't use the index can make the whole search read everything.&lt;/li&gt;
&lt;li&gt;Check how a field is really indexed by asking the database, not by trusting memory or a guess.&lt;/li&gt;
&lt;li&gt;A script that prints "updated" for every document tells you nothing. Log what actually changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A one-off script kept timing out. Every lookup waited the full 60 seconds and gave up. Yet when I timed one lookup on the same field by hand, the server answered in 0.86 ms.&lt;/p&gt;

&lt;p&gt;You've probably met this at a library desk. Ask for a book by its title and the answer comes back in seconds, because the catalogue is sorted by title. Ask for "this title, or maybe this shelf number" and someone has to walk every aisle, because the catalogue can't answer the second half.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://multibagg.ai" rel="noopener noreferrer"&gt;Multibagg AI&lt;/a&gt; is an AI-powered stock research platform for Indian investors. Its AI assistant answers questions from documents it finds through vector search, so the search index has to stay in step with the documents behind it.&lt;/p&gt;

&lt;p&gt;This is the story of three confident wrong guesses, one timed query, and a filter branch that looked harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: a backfill that never finished
&lt;/h2&gt;

&lt;p&gt;We store chunks of documents as points in Qdrant, an open-source vector database. Each point has a vector for search plus a payload, a small JSON object of fields. One payload field holds the ID of the document the chunk came from, and that field has a payload index, so filters on it don't need to read every point.&lt;/p&gt;

&lt;p&gt;The old IDs were plain numbers stored as text, like "1001", and we needed to rewrite them into a prefixed form across a very large collection. So I wrote a one-off backfill script, a script that rewrites existing records in bulk. For each document, it found the document's points by that ID field, then wrote the new ID onto them. It ran many documents at once.&lt;/p&gt;

&lt;p&gt;Every lookup hit the 60-second timeout. Worse, the script printed "updated" for every document whether the write happened or not, so its own log couldn't tell me whether anything had been written.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did the old script find a document's points?
&lt;/h2&gt;

&lt;p&gt;Here is the lookup, rewritten with generic names. The shape matches the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldLookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Filter&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;should&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a Qdrant filter, &lt;code&gt;should&lt;/code&gt; means OR: a point matches if any branch matches. So this asked for points whose ID equals the string "1001", or equals the integer 1001. The payload index on that field was a keyword index, the kind built for string values.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["Next document"] --&amp;gt; B["Lookup: ID as string OR ID as integer"]
    B --&amp;gt; C["Integer branch: no index can answer it"]
    C --&amp;gt; D["Check the payload of every point"]
    D --&amp;gt; E["60-second timeout"]
    B --&amp;gt; F["Write the new ID"]
    F --&amp;gt; G["Log updated, whatever happened"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;The old lookup inside the backfill: one OR branch the index couldn't answer, and a log line that always said "updated".&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three confident diagnoses, and why each was wrong
&lt;/h2&gt;

&lt;p&gt;I debugged this with an AI coding assistant. It gave three explanations, one after another, each stated with the same confidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Diagnosis&lt;/th&gt;
&lt;th&gt;What it predicted&lt;/th&gt;
&lt;th&gt;What we checked&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The field has no payload index&lt;/td&gt;
&lt;td&gt;Every filter on it is a full scan&lt;/td&gt;
&lt;td&gt;I pasted the collection's schema: the field was indexed&lt;/td&gt;
&lt;td&gt;Wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The optimizer is busy (status yellow)&lt;/td&gt;
&lt;td&gt;The server is slow for every request&lt;/td&gt;
&lt;td&gt;One timed query on the field: 0.86 ms&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Too many requests at once&lt;/td&gt;
&lt;td&gt;Requests queue and time out&lt;/td&gt;
&lt;td&gt;The same timed query: one request answered fast&lt;/td&gt;
&lt;td&gt;Server load ruled out, not queueing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A yellow status in Qdrant means the optimizer is still working on the collection in the background. That sounded plausible. But each theory explained the slowness without anyone having timed a single request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe: time one query before you theorise
&lt;/h2&gt;

&lt;p&gt;So I asked for probes instead of more theories. The assistant designed them, I ran them, and I pasted back the results and the collection's schema.&lt;/p&gt;

&lt;p&gt;Checking the index takes one call. Qdrant reports every payload index and its type in the collection info:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;indexType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload_schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For our field it said &lt;code&gt;keyword&lt;/code&gt;. So "missing index" was wrong.&lt;/p&gt;

&lt;p&gt;Then the timing. One filtered scroll on that field, on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;timeOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;with_payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;with_vector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It came back in 0.86 ms. A server that answers a filtered request in under a millisecond is not too busy to answer. That ruled out server load, and only that. It didn't say what was wrong. It said where not to look: the problem was in the shape of the script's query, not in the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did an integer match skip a keyword index?
&lt;/h2&gt;

&lt;p&gt;This is the part the coding assistant spotted, and it's in the integer branch.&lt;/p&gt;

&lt;p&gt;Qdrant has a separate index type per kind of value. Its &lt;a href="https://qdrant.tech/documentation/concepts/indexing/" rel="noopener noreferrer"&gt;indexing docs&lt;/a&gt; list a keyword index for keyword (string) payloads and an integer index for integer payloads. Each one serves match conditions for its own type.&lt;/p&gt;

&lt;p&gt;Qdrant's source is open, so you can see what happens on a mismatch. When the keyword index is asked to filter by a match value, it handles a string and returns nothing usable for an integer. So the integer branch has no index to use.&lt;/p&gt;

&lt;p&gt;On its own that branch would just be slow. Inside &lt;code&gt;should&lt;/code&gt;, it slows down the whole filter. When Qdrant plans a &lt;code&gt;should&lt;/code&gt; clause, a comment in its query estimator says it plainly: if some branch is un-indexed, it can't make any assumptions about the whole &lt;code&gt;should&lt;/code&gt; clause. With no index plan for the OR, it checks the payload of every point.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A["Filter on the ID field"] --&amp;gt; B{"Which clause?"}
    B --&amp;gt;|"should: string OR integer"| C["Integer branch has no index"]
    C --&amp;gt; D["No index plan for the whole OR"]
    D --&amp;gt; E["Check every point (full scan)"]
    B --&amp;gt;|"must: string only"| F["Keyword index lookup"]
    F --&amp;gt; G["Only this document's points"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Same field, same index. One branch the index can't answer forces a scan of the whole OR.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On a very large collection, a full scan per document, with many documents in flight, most likely explains why every lookup ran into the 60-second limit. I didn't profile the server during the slow run, so that's the explanation the docs, the source and the fix support, not a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one must clause, one type
&lt;/h2&gt;

&lt;p&gt;The fix was small. Keep only the string branch, and use &lt;code&gt;must&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Schemas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;must&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a single &lt;code&gt;must&lt;/code&gt; condition that the keyword index can answer, Qdrant pulls the matching points straight from the index. The value type matches the index type, and there's no OR branch left to drag the plan down to a full scan.&lt;/p&gt;

&lt;p&gt;The re-run finished in about 2 minutes with 0 failures.&lt;/p&gt;

&lt;p&gt;How the work split: I directed the probes and supplied the schema; the coding assistant spotted the integer branch and explained the full scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and limitations
&lt;/h2&gt;

&lt;p&gt;The fix assumes the field only holds strings. If any point had stored the ID as a number, the new filter would skip it. If you really need both types, give the field a second index of the integer type, or normalise the values first, instead of OR-ing two types onto one keyword index.&lt;/p&gt;

&lt;p&gt;There's no server-side proof. I didn't capture a query plan or a profile of the slow requests. The case for the cause is: the field was indexed, a single filtered query on it was fast, the documented and coded behaviour of &lt;code&gt;should&lt;/code&gt; with an unindexed branch, and the re-run finishing once that branch was gone.&lt;/p&gt;

&lt;p&gt;The script was a one-off. It was never committed, so nothing in the codebase stops the same filter shape from coming back.&lt;/p&gt;

&lt;p&gt;Qdrant can enforce this for you. Its &lt;a href="https://qdrant.tech/documentation/ops-configuration/administration/" rel="noopener noreferrer"&gt;strict mode&lt;/a&gt; has an &lt;code&gt;unindexed_filtering_retrieve&lt;/code&gt; setting. Set to &lt;code&gt;false&lt;/code&gt;, it rejects reads that filter on a payload key with no suitable index, which the docs warn "can be very slow". From the source, an integer match asks for an integer index, so this filter should have been rejected with an error instead of timing out. We didn't turn it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@qdrant/js-client-rest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refuseUnindexedFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;strict_mode_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unindexed_filtering_retrieve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a trade-off too. Strict mode can reject ad-hoc queries that are fine on small collections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results and observations
&lt;/h2&gt;

&lt;p&gt;What I measured:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Before the fix, the script's lookups hit the 60-second timeout.&lt;/li&gt;
&lt;li&gt;One filtered scroll on the same field took 0.86 ms.&lt;/li&gt;
&lt;li&gt;After the fix, the whole re-run took about 2 minutes, with 0 failures.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I didn't measure: how long the old OR filter took on its own when nothing else was running, or what the server was doing during the slow run. If you're chasing a Qdrant filter timeout and the payload index is clearly there, these are the two numbers worth collecting before you change anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What other engineers can learn
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time one query before you theorise.&lt;/strong&gt; Three server-side explanations came before any measurement. One timed request split "the server is slow" from "this query is slow". I think timing it first would have skipped two of the three wrong turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match the value type to the index type.&lt;/strong&gt; An index is built for one kind of value. A filter value of a different type doesn't fail, it just can't use the index. So the query still returns, slowly, and nothing tells you why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat an OR as only as fast as its slowest branch.&lt;/strong&gt; A &lt;code&gt;must&lt;/code&gt; clause can lean on its best indexed condition. A &lt;code&gt;should&lt;/code&gt; clause can't, because any branch might add matches the index doesn't know about. One defensive "just in case" branch can cost you the whole index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask the database, not your memory.&lt;/strong&gt; "Is this field indexed?" is a one-call question. So is "with what type?". The answer ended the first theory, and the type in it pointed straight at the second branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log outcomes, not intentions.&lt;/strong&gt; Our old script logged "updated" for every document, whether the write happened or not. Count what the database reports as matched or changed, and a timeout shows up as a failure in the log instead of a success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The server was never the problem. It answered in under a millisecond the moment we asked it a question its index could answer. The slow part was one extra branch in a filter, asking a text index for a number, inside an OR that let that branch decide for the whole query.&lt;/p&gt;

&lt;p&gt;The lesson I keep: when something is slow, time one request first. Then look at the shape of the question before you look at the machine answering it.&lt;/p&gt;

&lt;p&gt;What's the most expensive "just in case" branch you've found in a query?&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>database</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
