Skip to content

Filesystem layout="{table_name}" makes replace delete prefix-named sibling tables #4265

Description

@mattfaltyn

dlt version

1.29.0 source checkout from current devel at 91403f1a39058230361820de414b718a22d7a423.

Describe the problem

When the filesystem destination uses layout="{table_name}", replacing one table can silently delete an unrelated table whose name starts with the same prefix.

For example, after loading tables named event and events, replacing only event deletes the physical data file for events. The load completes successfully and rewrites event, so there is no failure or warning that unrelated data was removed.

The current write path automatically appends .<full_extension> when {ext} is omitted, producing files such as:

event.jsonl.gz
events.jsonl.gz

However, table-prefix extraction returns the bare prefix event. Replace truncation selects files with filepath.startswith(prefix), so both files are selected and deleted.

I reproduced this twice from a clean detached checkout of current upstream devel; both runs exited successfully with identical data loss.

Expected behavior

Replacing event should update only event.jsonl.gz; events.jsonl.gz should remain present and unchanged.

If an end-of-layout {table_name} cannot be handled safely, the destination should reject the layout during validation instead of accepting it and performing an overbroad delete.

Steps to reproduce

import tempfile
from pathlib import Path

import dlt

root = Path(tempfile.mkdtemp(prefix="dlt-fs-prefix-repro-"))
bucket = root / "bucket"

pipeline = dlt.pipeline(
    pipeline_name="prefix_repro",
    pipelines_dir=str(root / "pipelines"),
    destination=dlt.destinations.filesystem(
        bucket_url=str(bucket),
        layout="{table_name}",
    ),
    dataset_name="ds",
)

pipeline.run(
    dlt.resource([{"id": 1}], name="event"),
    loader_file_format="jsonl",
)
pipeline.run(
    dlt.resource([{"id": 2}], name="events"),
    loader_file_format="jsonl",
)

dataset = bucket / "ds"

def table_files():
    return sorted(path.name for path in dataset.glob("event*"))

print("before:", table_files())

pipeline.run(
    dlt.resource(
        [{"id": 3}],
        name="event",
        write_disposition="replace",
    ),
    loader_file_format="jsonl",
)

print("after:", table_files())

Actual output on current devel:

before: ['event.jsonl.gz', 'events.jsonl.gz']
after: ['event.jsonl.gz']

A control run changing only the layout to "{table_name}.{ext}" preserves both files:

before: ['event.jsonl.gz', 'events.jsonl.gz']
after: ['event.jsonl.gz', 'events.jsonl.gz']

Operating system

macOS

Runtime environment

Local

Python version

3.13

dlt data source

Inline Python resources containing dictionaries.

dlt destination

Filesystem & buckets

Other deployment details

The reproduction uses a local temporary filesystem and requires no credentials or external services. The affected prefix/deletion logic is shared by filesystem backends.

Additional information

The root cause appears to span these paths:

  • create_path appends a dot and the full extension when {ext} is omitted.
  • get_table_prefix_layout intends to retain the separator after {table_name}, specifically to avoid selecting similarly named tables, but returns bare {table_name} when the placeholder terminates the layout.
  • _delete_table_files deletes the files returned by prefix listing.
  • list_files_with_prefixes uses raw filepath.startswith(prefix).

The filesystem layout documentation says omitted extensions are automatically appended with a dot and that replace-compatible layouts need a separator after {table_name}.

A focused fix could make get_table_prefix_layout("{table_name}") derive "{table_name}.", matching the path produced by create_path. A stricter alternative is to reject the layout before loading.

Suggested regression coverage:

  • Unit assertion for the exact end-of-layout case in tests/destinations/test_path_utils.py.
  • Filesystem replace test with sibling tables event and events.

Focused validation on the clean upstream checkout:

tests/destinations/test_path_utils.py: 70 passed
local-only tests/load/filesystem/test_filesystem_client.py: 53 passed, 1 skipped

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds designValid but needs maintainer alignment on design or approach.

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions