Skip to content

[Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

Description

@hsballoon

Description

When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

Environment

  • dldb: 1.0.0
  • Backend: LanceDB on S3-compatible object storage
  • Logical table partitioning: HASH(job_id), 128 configured buckets
  • Example table: 23 currently materialized physical buckets

In a local call-count reproduction with 23 materialized buckets:

Cold full scan: 24 list_tables() calls
Warm full scan: 1 list_tables() call
Warm explicit scan: 0 list_tables() calls

With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

24 × 17 seconds ≈ 408 seconds

before accounting for the actual Lance row scan.

When It Is Triggered

The severe amplification occurs when all of the following are true:

  1. The logical table uses HASH or VALUE partitioning.
  2. filter() needs to scan multiple physical partitions.
  3. The physical bucket objects are not already cached in the current table wrapper.
  4. The catalog is backed by object storage where list_tables() is relatively expensive.

Typical triggers include:

  • the first query after a process restart;
  • a newly created dldb client or logical table wrapper;
  • the first access to previously unopened buckets;
  • cache invalidation after a table is recreated or replaced;
  • services that create a new client for each request;
  • full-table filters without a partition-key predicate;
  • explicit multi-bucket filters where some buckets are uncached.

The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

Current Execution Flow

HashPartitionTable.filter(partitions=None)

The current flow is effectively:

filter(partitions=None)
-> list_partitions()
-> db_conn.list_tables() # 1 catalog listing
-> for each materialized partition:
-> open_table([partition])
-> bucket is not cached
-> db_conn.list_tables() # 1 listing per bucket
-> db_conn.open_table(bucket)

For N uncached materialized buckets, this results in approximately:

N + 1 catalog listings

HashPartitionTable.filter(partitions=[...])

For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

For K uncached selected buckets, the current path may therefore perform approximately:

K + 1 catalog listings

ValuePartitionTable.filter()

ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

Warm Cache Behavior

open_table() returns immediately when all requested partitions already exist in self.tables.

Therefore:

  • a warm explicit-partition query may perform zero catalog listings;
  • a warm full scan still calls list_partitions() once to discover current partitions;
  • the worst N+1 behavior mainly appears during cold or partially warmed access.

Why dldb Lists the Database Root

A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

There are consequently two related but distinct performance issues:

  1. A single LanceDB/S3 catalog listing is relatively expensive.
  2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

Impact

SAfactory Data Rollout

Data Platform Dashboard

Operational Related Queries

Expected Behavior

A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

Suggested approaches:

  1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
  2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
  3. Allow open_table() to accept a known catalog/table-name snapshot.
  4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
  5. Apply the same correction to both HASH and VALUE partitioned tables.

A minimal improvement would reduce a cold full scan from approximately:

N + 1 listings

to:

2 listings

A better implementation that passes the discovery snapshot into the batch-open operation should require only:

1 listing

Acceptance Criteria

  • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
  • A cold explicit multi-bucket filter does not list the catalog once per bucket.
  • ValuePartitionTable.filter() receives equivalent behavior.
  • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
  • Newly materialized buckets remain discoverable.
  • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
  • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
  • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
       blocks
      (function() {
      function addCopyButtons() {
      document.querySelectorAll('pre code').forEach(function(codeBlock) {
      if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
      codeBlock.parentElement.setAttribute('data-copy-added', 'true');
      var btn = document.createElement('button');
      btn.textContent = 'Copy';
      btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
      btn.onmouseover = function() { this.style.opacity = '1'; };
      btn.onmouseout = function() { this.style.opacity = '0.7'; };
      btn.onclick = function() {
      navigator.clipboard.writeText(codeBlock.textContent).then(function() {
      btn.textContent = 'Copied!';
      setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
      });
      };
      codeBlock.parentElement.style.position = 'relative';
      codeBlock.parentElement.appendChild(btn);
      });
      }
      addCopyButtons();
      // Re-run on dynamic content
      var observer = new MutationObserver(addCopyButtons);
      observer.observe(document.body, { childList: true, subtree: true });
      })();
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
      Skip to content

      [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

      Description

      @hsballoon

      Description

      When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

      For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

      This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

      Environment

      • dldb: 1.0.0
      • Backend: LanceDB on S3-compatible object storage
      • Logical table partitioning: HASH(job_id), 128 configured buckets
      • Example table: 23 currently materialized physical buckets

      In a local call-count reproduction with 23 materialized buckets:

      Cold full scan: 24 list_tables() calls
      Warm full scan: 1 list_tables() call
      Warm explicit scan: 0 list_tables() calls
      

      With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

      24 × 17 seconds ≈ 408 seconds
      

      before accounting for the actual Lance row scan.

      When It Is Triggered

      The severe amplification occurs when all of the following are true:

      1. The logical table uses HASH or VALUE partitioning.
      2. filter() needs to scan multiple physical partitions.
      3. The physical bucket objects are not already cached in the current table wrapper.
      4. The catalog is backed by object storage where list_tables() is relatively expensive.

      Typical triggers include:

      • the first query after a process restart;
      • a newly created dldb client or logical table wrapper;
      • the first access to previously unopened buckets;
      • cache invalidation after a table is recreated or replaced;
      • services that create a new client for each request;
      • full-table filters without a partition-key predicate;
      • explicit multi-bucket filters where some buckets are uncached.

      The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

      Current Execution Flow

      HashPartitionTable.filter(partitions=None)

      The current flow is effectively:

      filter(partitions=None)
      -> list_partitions()
      -> db_conn.list_tables() # 1 catalog listing
      -> for each materialized partition:
      -> open_table([partition])
      -> bucket is not cached
      -> db_conn.list_tables() # 1 listing per bucket
      -> db_conn.open_table(bucket)
      

      For N uncached materialized buckets, this results in approximately:

      N + 1 catalog listings
      

      HashPartitionTable.filter(partitions=[...])

      For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

      For K uncached selected buckets, the current path may therefore perform approximately:

      K + 1 catalog listings
      

      ValuePartitionTable.filter()

      ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

      Warm Cache Behavior

      open_table() returns immediately when all requested partitions already exist in self.tables.

      Therefore:

      • a warm explicit-partition query may perform zero catalog listings;
      • a warm full scan still calls list_partitions() once to discover current partitions;
      • the worst N+1 behavior mainly appears during cold or partially warmed access.

      Why dldb Lists the Database Root

      A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

      dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

      This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

      There are consequently two related but distinct performance issues:

      1. A single LanceDB/S3 catalog listing is relatively expensive.
      2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

      Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

      Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

      Impact

      SAfactory Data Rollout

      Data Platform Dashboard

      Operational Related Queries

      Expected Behavior

      A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

      Suggested approaches:

      1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
      2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
      3. Allow open_table() to accept a known catalog/table-name snapshot.
      4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
      5. Apply the same correction to both HASH and VALUE partitioned tables.

      A minimal improvement would reduce a cold full scan from approximately:

      N + 1 listings
      

      to:

      2 listings
      

      A better implementation that passes the discovery snapshot into the batch-open operation should require only:

      1 listing
      

      Acceptance Criteria

      • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
      • A cold explicit multi-bucket filter does not list the catalog once per bucket.
      • ValuePartitionTable.filter() receives equivalent behavior.
      • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
      • Newly materialized buckets remain discoverable.
      • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
      • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
      • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
          Skip to content

          [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

          Description

          @hsballoon

          Description

          When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

          For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

          This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

          Environment

          • dldb: 1.0.0
          • Backend: LanceDB on S3-compatible object storage
          • Logical table partitioning: HASH(job_id), 128 configured buckets
          • Example table: 23 currently materialized physical buckets

          In a local call-count reproduction with 23 materialized buckets:

          Cold full scan: 24 list_tables() calls
          Warm full scan: 1 list_tables() call
          Warm explicit scan: 0 list_tables() calls
          

          With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

          24 × 17 seconds ≈ 408 seconds
          

          before accounting for the actual Lance row scan.

          When It Is Triggered

          The severe amplification occurs when all of the following are true:

          1. The logical table uses HASH or VALUE partitioning.
          2. filter() needs to scan multiple physical partitions.
          3. The physical bucket objects are not already cached in the current table wrapper.
          4. The catalog is backed by object storage where list_tables() is relatively expensive.

          Typical triggers include:

          • the first query after a process restart;
          • a newly created dldb client or logical table wrapper;
          • the first access to previously unopened buckets;
          • cache invalidation after a table is recreated or replaced;
          • services that create a new client for each request;
          • full-table filters without a partition-key predicate;
          • explicit multi-bucket filters where some buckets are uncached.

          The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

          Current Execution Flow

          HashPartitionTable.filter(partitions=None)

          The current flow is effectively:

          filter(partitions=None)
          -> list_partitions()
          -> db_conn.list_tables() # 1 catalog listing
          -> for each materialized partition:
          -> open_table([partition])
          -> bucket is not cached
          -> db_conn.list_tables() # 1 listing per bucket
          -> db_conn.open_table(bucket)
          

          For N uncached materialized buckets, this results in approximately:

          N + 1 catalog listings
          

          HashPartitionTable.filter(partitions=[...])

          For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

          For K uncached selected buckets, the current path may therefore perform approximately:

          K + 1 catalog listings
          

          ValuePartitionTable.filter()

          ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

          Warm Cache Behavior

          open_table() returns immediately when all requested partitions already exist in self.tables.

          Therefore:

          • a warm explicit-partition query may perform zero catalog listings;
          • a warm full scan still calls list_partitions() once to discover current partitions;
          • the worst N+1 behavior mainly appears during cold or partially warmed access.

          Why dldb Lists the Database Root

          A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

          dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

          This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

          There are consequently two related but distinct performance issues:

          1. A single LanceDB/S3 catalog listing is relatively expensive.
          2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

          Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

          Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

          Impact

          SAfactory Data Rollout

          Data Platform Dashboard

          Operational Related Queries

          Expected Behavior

          A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

          Suggested approaches:

          1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
          2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
          3. Allow open_table() to accept a known catalog/table-name snapshot.
          4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
          5. Apply the same correction to both HASH and VALUE partitioned tables.

          A minimal improvement would reduce a cold full scan from approximately:

          N + 1 listings
          

          to:

          2 listings
          

          A better implementation that passes the discovery snapshot into the batch-open operation should require only:

          1 listing
          

          Acceptance Criteria

          • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
          • A cold explicit multi-bucket filter does not list the catalog once per bucket.
          • ValuePartitionTable.filter() receives equivalent behavior.
          • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
          • Newly materialized buckets remain discoverable.
          • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
          • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
          • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
              Skip to content

              [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

              Description

              @hsballoon

              Description

              When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

              For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

              This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

              Environment

              • dldb: 1.0.0
              • Backend: LanceDB on S3-compatible object storage
              • Logical table partitioning: HASH(job_id), 128 configured buckets
              • Example table: 23 currently materialized physical buckets

              In a local call-count reproduction with 23 materialized buckets:

              Cold full scan: 24 list_tables() calls
              Warm full scan: 1 list_tables() call
              Warm explicit scan: 0 list_tables() calls
              

              With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

              24 × 17 seconds ≈ 408 seconds
              

              before accounting for the actual Lance row scan.

              When It Is Triggered

              The severe amplification occurs when all of the following are true:

              1. The logical table uses HASH or VALUE partitioning.
              2. filter() needs to scan multiple physical partitions.
              3. The physical bucket objects are not already cached in the current table wrapper.
              4. The catalog is backed by object storage where list_tables() is relatively expensive.

              Typical triggers include:

              • the first query after a process restart;
              • a newly created dldb client or logical table wrapper;
              • the first access to previously unopened buckets;
              • cache invalidation after a table is recreated or replaced;
              • services that create a new client for each request;
              • full-table filters without a partition-key predicate;
              • explicit multi-bucket filters where some buckets are uncached.

              The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

              Current Execution Flow

              HashPartitionTable.filter(partitions=None)

              The current flow is effectively:

              filter(partitions=None)
              -> list_partitions()
              -> db_conn.list_tables() # 1 catalog listing
              -> for each materialized partition:
              -> open_table([partition])
              -> bucket is not cached
              -> db_conn.list_tables() # 1 listing per bucket
              -> db_conn.open_table(bucket)
              

              For N uncached materialized buckets, this results in approximately:

              N + 1 catalog listings
              

              HashPartitionTable.filter(partitions=[...])

              For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

              For K uncached selected buckets, the current path may therefore perform approximately:

              K + 1 catalog listings
              

              ValuePartitionTable.filter()

              ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

              Warm Cache Behavior

              open_table() returns immediately when all requested partitions already exist in self.tables.

              Therefore:

              • a warm explicit-partition query may perform zero catalog listings;
              • a warm full scan still calls list_partitions() once to discover current partitions;
              • the worst N+1 behavior mainly appears during cold or partially warmed access.

              Why dldb Lists the Database Root

              A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

              dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

              This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

              There are consequently two related but distinct performance issues:

              1. A single LanceDB/S3 catalog listing is relatively expensive.
              2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

              Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

              Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

              Impact

              SAfactory Data Rollout

              Data Platform Dashboard

              Operational Related Queries

              Expected Behavior

              A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

              Suggested approaches:

              1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
              2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
              3. Allow open_table() to accept a known catalog/table-name snapshot.
              4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
              5. Apply the same correction to both HASH and VALUE partitioned tables.

              A minimal improvement would reduce a cold full scan from approximately:

              N + 1 listings
              

              to:

              2 listings
              

              A better implementation that passes the discovery snapshot into the batch-open operation should require only:

              1 listing
              

              Acceptance Criteria

              • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
              • A cold explicit multi-bucket filter does not list the catalog once per bucket.
              • ValuePartitionTable.filter() receives equivalent behavior.
              • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
              • Newly materialized buckets remain discoverable.
              • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
              • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
              • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
                  Skip to content

                  [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

                  Description

                  @hsballoon

                  Description

                  When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

                  For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

                  This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

                  Environment

                  • dldb: 1.0.0
                  • Backend: LanceDB on S3-compatible object storage
                  • Logical table partitioning: HASH(job_id), 128 configured buckets
                  • Example table: 23 currently materialized physical buckets

                  In a local call-count reproduction with 23 materialized buckets:

                  Cold full scan: 24 list_tables() calls
                  Warm full scan: 1 list_tables() call
                  Warm explicit scan: 0 list_tables() calls
                  

                  With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

                  24 × 17 seconds ≈ 408 seconds
                  

                  before accounting for the actual Lance row scan.

                  When It Is Triggered

                  The severe amplification occurs when all of the following are true:

                  1. The logical table uses HASH or VALUE partitioning.
                  2. filter() needs to scan multiple physical partitions.
                  3. The physical bucket objects are not already cached in the current table wrapper.
                  4. The catalog is backed by object storage where list_tables() is relatively expensive.

                  Typical triggers include:

                  • the first query after a process restart;
                  • a newly created dldb client or logical table wrapper;
                  • the first access to previously unopened buckets;
                  • cache invalidation after a table is recreated or replaced;
                  • services that create a new client for each request;
                  • full-table filters without a partition-key predicate;
                  • explicit multi-bucket filters where some buckets are uncached.

                  The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

                  Current Execution Flow

                  HashPartitionTable.filter(partitions=None)

                  The current flow is effectively:

                  filter(partitions=None)
                  -> list_partitions()
                  -> db_conn.list_tables() # 1 catalog listing
                  -> for each materialized partition:
                  -> open_table([partition])
                  -> bucket is not cached
                  -> db_conn.list_tables() # 1 listing per bucket
                  -> db_conn.open_table(bucket)
                  

                  For N uncached materialized buckets, this results in approximately:

                  N + 1 catalog listings
                  

                  HashPartitionTable.filter(partitions=[...])

                  For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

                  For K uncached selected buckets, the current path may therefore perform approximately:

                  K + 1 catalog listings
                  

                  ValuePartitionTable.filter()

                  ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

                  Warm Cache Behavior

                  open_table() returns immediately when all requested partitions already exist in self.tables.

                  Therefore:

                  • a warm explicit-partition query may perform zero catalog listings;
                  • a warm full scan still calls list_partitions() once to discover current partitions;
                  • the worst N+1 behavior mainly appears during cold or partially warmed access.

                  Why dldb Lists the Database Root

                  A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

                  dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

                  This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

                  There are consequently two related but distinct performance issues:

                  1. A single LanceDB/S3 catalog listing is relatively expensive.
                  2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

                  Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

                  Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

                  Impact

                  SAfactory Data Rollout

                  Data Platform Dashboard

                  Operational Related Queries

                  Expected Behavior

                  A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

                  Suggested approaches:

                  1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
                  2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
                  3. Allow open_table() to accept a known catalog/table-name snapshot.
                  4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
                  5. Apply the same correction to both HASH and VALUE partitioned tables.

                  A minimal improvement would reduce a cold full scan from approximately:

                  N + 1 listings
                  

                  to:

                  2 listings
                  

                  A better implementation that passes the discovery snapshot into the batch-open operation should require only:

                  1 listing
                  

                  Acceptance Criteria

                  • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
                  • A cold explicit multi-bucket filter does not list the catalog once per bucket.
                  • ValuePartitionTable.filter() receives equivalent behavior.
                  • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
                  • Newly materialized buckets remain discoverable.
                  • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
                  • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
                  • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
                      Skip to content

                      [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

                      Description

                      @hsballoon

                      Description

                      When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

                      For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

                      This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

                      Environment

                      • dldb: 1.0.0
                      • Backend: LanceDB on S3-compatible object storage
                      • Logical table partitioning: HASH(job_id), 128 configured buckets
                      • Example table: 23 currently materialized physical buckets

                      In a local call-count reproduction with 23 materialized buckets:

                      Cold full scan: 24 list_tables() calls
                      Warm full scan: 1 list_tables() call
                      Warm explicit scan: 0 list_tables() calls
                      

                      With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

                      24 × 17 seconds ≈ 408 seconds
                      

                      before accounting for the actual Lance row scan.

                      When It Is Triggered

                      The severe amplification occurs when all of the following are true:

                      1. The logical table uses HASH or VALUE partitioning.
                      2. filter() needs to scan multiple physical partitions.
                      3. The physical bucket objects are not already cached in the current table wrapper.
                      4. The catalog is backed by object storage where list_tables() is relatively expensive.

                      Typical triggers include:

                      • the first query after a process restart;
                      • a newly created dldb client or logical table wrapper;
                      • the first access to previously unopened buckets;
                      • cache invalidation after a table is recreated or replaced;
                      • services that create a new client for each request;
                      • full-table filters without a partition-key predicate;
                      • explicit multi-bucket filters where some buckets are uncached.

                      The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

                      Current Execution Flow

                      HashPartitionTable.filter(partitions=None)

                      The current flow is effectively:

                      filter(partitions=None)
                      -> list_partitions()
                      -> db_conn.list_tables() # 1 catalog listing
                      -> for each materialized partition:
                      -> open_table([partition])
                      -> bucket is not cached
                      -> db_conn.list_tables() # 1 listing per bucket
                      -> db_conn.open_table(bucket)
                      

                      For N uncached materialized buckets, this results in approximately:

                      N + 1 catalog listings
                      

                      HashPartitionTable.filter(partitions=[...])

                      For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

                      For K uncached selected buckets, the current path may therefore perform approximately:

                      K + 1 catalog listings
                      

                      ValuePartitionTable.filter()

                      ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

                      Warm Cache Behavior

                      open_table() returns immediately when all requested partitions already exist in self.tables.

                      Therefore:

                      • a warm explicit-partition query may perform zero catalog listings;
                      • a warm full scan still calls list_partitions() once to discover current partitions;
                      • the worst N+1 behavior mainly appears during cold or partially warmed access.

                      Why dldb Lists the Database Root

                      A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

                      dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

                      This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

                      There are consequently two related but distinct performance issues:

                      1. A single LanceDB/S3 catalog listing is relatively expensive.
                      2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

                      Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

                      Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

                      Impact

                      SAfactory Data Rollout

                      Data Platform Dashboard

                      Operational Related Queries

                      Expected Behavior

                      A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

                      Suggested approaches:

                      1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
                      2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
                      3. Allow open_table() to accept a known catalog/table-name snapshot.
                      4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
                      5. Apply the same correction to both HASH and VALUE partitioned tables.

                      A minimal improvement would reduce a cold full scan from approximately:

                      N + 1 listings
                      

                      to:

                      2 listings
                      

                      A better implementation that passes the discovery snapshot into the batch-open operation should require only:

                      1 listing
                      

                      Acceptance Criteria

                      • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
                      • A cold explicit multi-bucket filter does not list the catalog once per bucket.
                      • ValuePartitionTable.filter() receives equivalent behavior.
                      • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
                      • Newly materialized buckets remain discoverable.
                      • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
                      • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
                      • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
                          Skip to content

                          [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

                          Description

                          @hsballoon

                          Description

                          When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

                          For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

                          This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

                          Environment

                          • dldb: 1.0.0
                          • Backend: LanceDB on S3-compatible object storage
                          • Logical table partitioning: HASH(job_id), 128 configured buckets
                          • Example table: 23 currently materialized physical buckets

                          In a local call-count reproduction with 23 materialized buckets:

                          Cold full scan: 24 list_tables() calls
                          Warm full scan: 1 list_tables() call
                          Warm explicit scan: 0 list_tables() calls
                          

                          With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

                          24 × 17 seconds ≈ 408 seconds
                          

                          before accounting for the actual Lance row scan.

                          When It Is Triggered

                          The severe amplification occurs when all of the following are true:

                          1. The logical table uses HASH or VALUE partitioning.
                          2. filter() needs to scan multiple physical partitions.
                          3. The physical bucket objects are not already cached in the current table wrapper.
                          4. The catalog is backed by object storage where list_tables() is relatively expensive.

                          Typical triggers include:

                          • the first query after a process restart;
                          • a newly created dldb client or logical table wrapper;
                          • the first access to previously unopened buckets;
                          • cache invalidation after a table is recreated or replaced;
                          • services that create a new client for each request;
                          • full-table filters without a partition-key predicate;
                          • explicit multi-bucket filters where some buckets are uncached.

                          The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

                          Current Execution Flow

                          HashPartitionTable.filter(partitions=None)

                          The current flow is effectively:

                          filter(partitions=None)
                          -> list_partitions()
                          -> db_conn.list_tables() # 1 catalog listing
                          -> for each materialized partition:
                          -> open_table([partition])
                          -> bucket is not cached
                          -> db_conn.list_tables() # 1 listing per bucket
                          -> db_conn.open_table(bucket)
                          

                          For N uncached materialized buckets, this results in approximately:

                          N + 1 catalog listings
                          

                          HashPartitionTable.filter(partitions=[...])

                          For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

                          For K uncached selected buckets, the current path may therefore perform approximately:

                          K + 1 catalog listings
                          

                          ValuePartitionTable.filter()

                          ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

                          Warm Cache Behavior

                          open_table() returns immediately when all requested partitions already exist in self.tables.

                          Therefore:

                          • a warm explicit-partition query may perform zero catalog listings;
                          • a warm full scan still calls list_partitions() once to discover current partitions;
                          • the worst N+1 behavior mainly appears during cold or partially warmed access.

                          Why dldb Lists the Database Root

                          A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

                          dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

                          This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

                          There are consequently two related but distinct performance issues:

                          1. A single LanceDB/S3 catalog listing is relatively expensive.
                          2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

                          Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

                          Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

                          Impact

                          SAfactory Data Rollout

                          Data Platform Dashboard

                          Operational Related Queries

                          Expected Behavior

                          A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

                          Suggested approaches:

                          1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
                          2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
                          3. Allow open_table() to accept a known catalog/table-name snapshot.
                          4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
                          5. Apply the same correction to both HASH and VALUE partitioned tables.

                          A minimal improvement would reduce a cold full scan from approximately:

                          N + 1 listings
                          

                          to:

                          2 listings
                          

                          A better implementation that passes the discovery snapshot into the batch-open operation should require only:

                          1 listing
                          

                          Acceptance Criteria

                          • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
                          • A cold explicit multi-bucket filter does not list the catalog once per bucket.
                          • ValuePartitionTable.filter() receives equivalent behavior.
                          • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
                          • Newly materialized buckets remain discoverable.
                          • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
                          • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
                          • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets · Issue #101 · DeepLink-org/Persisting · GitHub
                              Skip to content

                              [Performance] Partitioned filter causes N+1 catalog listings for cold or uncached buckets #101

                              Description

                              @hsballoon

                              Description

                              When HashPartitionTable.filter() scans multiple physical partitions, dldb repeatedly calls db_conn.list_tables() while opening the buckets. This cause extremly slow response to the caller. 1w+ items costs 7-8mins.

                              For an S3-backed database, list_tables() appears to enumerate the database catalog/root prefix. A single catalog listing currently takes approximately 16–17 seconds in our environment. Repeating it once per physical bucket amplifies a normal query into a multi-minute operation.

                              This affects both full-table scans and explicitly pruned scans when the relevant bucket objects have not yet been cached.

                              Environment

                              • dldb: 1.0.0
                              • Backend: LanceDB on S3-compatible object storage
                              • Logical table partitioning: HASH(job_id), 128 configured buckets
                              • Example table: 23 currently materialized physical buckets

                              In a local call-count reproduction with 23 materialized buckets:

                              Cold full scan: 24 list_tables() calls
                              Warm full scan: 1 list_tables() call
                              Warm explicit scan: 0 list_tables() calls
                              

                              With a catalog listing latency of approximately 17 seconds, the cold full-scan path can spend roughly:

                              24 × 17 seconds ≈ 408 seconds
                              

                              before accounting for the actual Lance row scan.

                              When It Is Triggered

                              The severe amplification occurs when all of the following are true:

                              1. The logical table uses HASH or VALUE partitioning.
                              2. filter() needs to scan multiple physical partitions.
                              3. The physical bucket objects are not already cached in the current table wrapper.
                              4. The catalog is backed by object storage where list_tables() is relatively expensive.

                              Typical triggers include:

                              • the first query after a process restart;
                              • a newly created dldb client or logical table wrapper;
                              • the first access to previously unopened buckets;
                              • cache invalidation after a table is recreated or replaced;
                              • services that create a new client for each request;
                              • full-table filters without a partition-key predicate;
                              • explicit multi-bucket filters where some buckets are uncached.

                              The existing opened-bucket cache avoids repeated listings after buckets have been opened. However, it does not eliminate the cold-path amplification. The improvement related to #79 therefore appears to cover cached bucket reuse, but not the initial multi-bucket opening path.

                              Current Execution Flow

                              HashPartitionTable.filter(partitions=None)

                              The current flow is effectively:

                              filter(partitions=None)
                              -> list_partitions()
                              -> db_conn.list_tables() # 1 catalog listing
                              -> for each materialized partition:
                              -> open_table([partition])
                              -> bucket is not cached
                              -> db_conn.list_tables() # 1 listing per bucket
                              -> db_conn.open_table(bucket)
                              

                              For N uncached materialized buckets, this results in approximately:

                              N + 1 catalog listings
                              

                              HashPartitionTable.filter(partitions=[...])

                              For explicitly selected HASH buckets, the uncached path first calls list_partitions() to reject valid-but-unmaterialized buckets, then calls open_table([partition]) inside the loop.

                              For K uncached selected buckets, the current path may therefore perform approximately:

                              K + 1 catalog listings
                              

                              ValuePartitionTable.filter()

                              ValuePartitionTable.filter() has the same per-partition open_table([partition]) loop. A cold scan therefore has the same general amplification problem.

                              Warm Cache Behavior

                              open_table() returns immediately when all requested partitions already exist in self.tables.

                              Therefore:

                              • a warm explicit-partition query may perform zero catalog listings;
                              • a warm full scan still calls list_partitions() once to discover current partitions;
                              • the worst N+1 behavior mainly appears during cold or partially warmed access.

                              Why dldb Lists the Database Root

                              A dldb partitioned logical table is represented by multiple physical LanceDB tables. The logical metadata defines the partitioning scheme, such as HASH(job_id) with 128 possible buckets, but physical bucket tables are materialized lazily.

                              dldb therefore calls db_conn.list_tables() to discover which physical bucket tables currently exist. On an object-store-backed LanceDB database, this appears to require listing the database catalog/root prefix.

                              This discovery requirement is understandable, but the same catalog result should not be fetched again for every bucket in one operation.

                              There are consequently two related but distinct performance issues:

                              1. A single LanceDB/S3 catalog listing is relatively expensive.
                              2. dldb multiplies that cost by repeatedly listing the same unchanged catalog within one filter() call.

                              Fixing the dldb amplification is useful even if the underlying single-listing latency remains unchanged.

                              Please also confirm whether LanceDB must perform a root-prefix listing on every list_tables() call for this storage backend, or whether a catalog API/cache can avoid repeated object-store listings.

                              Impact

                              SAfactory Data Rollout

                              Data Platform Dashboard

                              Operational Related Queries

                              Expected Behavior

                              A single logical operation should reuse one catalog snapshot when discovering and opening multiple partitions.

                              Suggested approaches:

                              1. Call open_table(partitions) once before iterating over the partitions instead of calling open_table([partition]) inside the loop.
                              2. Reuse the table-name set already obtained by list_partitions() when opening the discovered buckets.
                              3. Allow open_table() to accept a known catalog/table-name snapshot.
                              4. Optionally add a bounded or generation-aware catalog cache, while preserving discovery of newly materialized buckets.
                              5. Apply the same correction to both HASH and VALUE partitioned tables.

                              A minimal improvement would reduce a cold full scan from approximately:

                              N + 1 listings
                              

                              to:

                              2 listings
                              

                              A better implementation that passes the discovery snapshot into the batch-open operation should require only:

                              1 listing
                              

                              Acceptance Criteria

                              • A cold full-table HASH filter() performs at most one catalog listing for partition discovery and opening.
                              • A cold explicit multi-bucket filter does not list the catalog once per bucket.
                              • ValuePartitionTable.filter() receives equivalent behavior.
                              • Warm explicit-bucket queries continue to avoid catalog listing when all requested buckets are cached.
                              • Newly materialized buckets remain discoverable.
                              • Concurrent bucket creation does not cause valid buckets to be skipped or accidentally create incorrect physical tables.
                              • Existing filtering, limits, ordering, and checkout_latest behavior remain unchanged.
                              • Add a regression test that counts db_conn.list_tables() calls for cold and warm multi-partition filters.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions