BasekickLabs

Delete Operations

Delete rows from Arc with WHERE-clause predicates: enable delete in configuration, run a rewrite-based delete, and confirm the Parquet files were rewritten.

Arc supports deleting data using a rewrite-based approach that provides precise deletion with zero overhead on write and query operations.

Configuration Required

Delete operations must be explicitly enabled in configuration for safety.

Overview

Arc's delete operations provide:

  • Precise Control: Delete specific rows using WHERE clauses
  • Zero Runtime Overhead: No performance impact on writes or queries
  • Physical Removal: Data is permanently removed by rewriting Parquet files
  • Safety Mechanisms: Multiple safeguards prevent accidental deletion
  • Dry Run Mode: Test operations before execution

How it works

Arc deletes rows by rewriting the Parquet files that hold them:

1. Flush buffered rows

Rows written shortly before the DELETE may still be in the ingest buffer. Since v27.01.1 the DELETE first flushes that measurement's buffered rows and waits for them to reach files, so every row written before the request is deleted. While an earlier flush failure is outstanding (its rows wait for WAL recovery), the DELETE answers 503 with Retry-After: 30 instead. A dry run does not flush.

2. Find affected files

List the measurement's Parquet files and count the rows matching the WHERE clause in each, with DuckDB.

In a cluster with compaction commit on (every node runs Arc 2027.01.1 or later), the DELETE first catches up with the cluster manifest, then scans the files the manifest lists for the measurement, plus files this node is still registering. A file in storage the manifest does not list, such as a compaction output not committed yet, is left alone. A file the manifest lists that is not on the node yet (a compaction output on its way) is waited for, up to 30 seconds per scan; a file the manifest stops listing meanwhile is no longer waited for. A file still missing answers 503 listing it in unreachable_files, unless allow_unreachable is set, in which case the DELETE runs without it and still lists it. A dry run does not wait and lists the files it could not count.

3. Rewrite files

For each affected file:

  1. Write the rows that do not match, SELECT * FROM read_parquet(file) WHERE NOT (<where>), to a new file with a fresh name (<name>_rewrite_<nanoseconds>.parquet) in the same partition directory
  2. Publish the new file, then remove the old one (in a cluster, both in one manifest update)

A file whose rows all match is removed.

Crash safety

The old file stays in place until its replacement is published, so a crash leaves either the old file or the new one, never a partial file. Two DELETEs rewrite one at a time.

Compaction during a DELETE

In a cluster with compaction commit on, a DELETE publishes its rewrite of a file only while the file is still registered with the content it read. If a compaction replaced the file while the DELETE ran, the DELETE waits until the compaction's output reaches the node (up to 30 seconds), scans again and deletes from the output. After three rounds of files changing under it, it answers 503 with success: false: the rows of the files listed in files_processed are deleted; retry to finish.

Configuration

Delete operations must be explicitly enabled and configured.

Configuration file

Edit arc.toml:

[delete]
enabled = true
confirmation_threshold = 10000
max_rows_per_delete = 1000000

Environment variables

export ARC_DELETE_ENABLED=true
export ARC_DELETE_CONFIRMATION_THRESHOLD=10000
export ARC_DELETE_MAX_ROWS_PER_DELETE=1000000

Configuration parameters

  • enabled (boolean): Enable/disable delete functionality (default: false)
  • confirmation_threshold (integer): Row count requiring explicit confirmation (default: 10000)
  • max_rows_per_delete (integer): Maximum rows allowed per operation (default: 1000000)

API endpoints

Delete data

Execute a delete operation:

POST /api/v1/delete

Request Body:

{
  "database": "telegraf",
  "measurement": "cpu",
  "where": "host = 'server01' AND time < '2024-01-01'",
  "dry_run": false,
  "confirm": false
}

Parameters:

  • database (string, required): Target database name
  • measurement (string, required): Target measurement name
  • where (string, required): SQL WHERE clause for deletion
  • dry_run (boolean, optional): Test without deleting (default: false)
  • confirm (boolean, optional): Confirm the operation; required for anything but a dry run (default: false)
  • allow_unreachable (boolean, optional, clusters): run the DELETE although files the cluster manifest lists have not reached the primary yet; their rows are not deleted. Applies with stream takeover or compaction commit, to the first scan and to the scan after a compaction replaced a file (default: false)

Response:

{
  "success": true,
  "deleted_count": 15000,
  "affected_files": 3,
  "rewritten_files": 3,
  "execution_time_ms": 1250,
  "dry_run": false,
  "files_processed": [
    "cpu_20231215_100000_000000000.parquet",
    "cpu_20231220_100000_000000000.parquet",
    "cpu_20231221_100000_000000000.parquet"
  ]
}
  • files_processed lists the names of the files rewritten or removed (on a dry run, the files that would be).
  • failed_files (when present) lists files that could not be rewritten; the response is then 207 with success: false, and the rows of those files are not deleted.
  • unreachable_files (clusters) lists manifest files the primary did not have: skipped with allow_unreachable, or the reason for a 503 without it.

Get configuration

Retrieve current delete configuration:

GET /api/v1/delete/config

Response:

{
  "enabled": true,
  "confirmation_threshold": 10000,
  "max_rows_per_delete": 1000000
}

Safety mechanisms

1. WHERE clause required

Delete operations must include a WHERE clause to prevent accidental full-table deletion.

Intentional Full Delete:

{
  "where": "1=1"  // Explicitly delete all rows
}

2. Confirmation threshold

Operations exceeding the configured threshold require explicit confirmation:

{
  "where": "time < '2024-01-01'",
  "confirm": true  // Required if deleted_count > threshold
}

Without Confirmation:

{
  "error": "Operation would delete 15000 rows, exceeding threshold of 10000. Set confirm=true to proceed."
}

3. Maximum rows limit

Hard cap prevents extremely large operations that could exhaust resources:

{
  "error": "Operation would delete 2000000 rows, exceeding maximum of 1000000"
}

4. Immutable rewrites

A rewrite is published under a new file name before the old file is removed: a crash leaves the old file or the new one, never a partial file.

Usage examples

Example 1: delete old data

import os
import requests

ARC_TOKEN = os.environ["ARC_TOKEN"]

# Delete data older than a specific date
response = requests.post(
    "http://localhost:8000/api/v1/delete",
    headers={"Authorization": f"Bearer {ARC_TOKEN}"},
    json={
        "database": "telegraf",
        "measurement": "cpu",
        "where": "time < '2024-01-01'",
        "confirm": True
    }
)

print(f"Deleted {response.json()['deleted_count']} rows")
print(f"Execution time: {response.json()['execution_time_ms']}ms")

Example 2: delete specific host data

import os

ARC_TOKEN = os.environ["ARC_TOKEN"]

# Delete data from a specific host
response = requests.post(
    "http://localhost:8000/api/v1/delete",
    headers={"Authorization": f"Bearer {ARC_TOKEN}"},
    json={
        "database": "telegraf",
        "measurement": "cpu",
        "where": "host = 'server01' OR host = 'server02'",
        "confirm": True
    }
)

Example 3: dry run first

import os

ARC_TOKEN = os.environ["ARC_TOKEN"]

# Always test with dry run before deleting
dry_run = requests.post(
    "http://localhost:8000/api/v1/delete",
    headers={"Authorization": f"Bearer {ARC_TOKEN}"},
    json={
        "database": "telegraf",
        "measurement": "cpu",
        "where": "host = 'server01'",
        "dry_run": True
    }
)

print(f"Would delete {dry_run.json()['deleted_count']} rows")
print(f"Affected files: {dry_run.json()['affected_files']}")

# Review the files that would be affected
for name in dry_run.json()['files_processed']:
    print(f"  {name}")

# If satisfied, execute for real
if input("Proceed? (yes/no): ") == "yes":
    result = requests.post(
        "http://localhost:8000/api/v1/delete",
        headers={"Authorization": f"Bearer {ARC_TOKEN}"},
        json={
            "database": "telegraf",
            "measurement": "cpu",
            "where": "host = 'server01'",
            "dry_run": False,
            "confirm": True
        }
    )
    print(f"Deleted {result.json()['deleted_count']} rows")

Example 4: delete with confirmation

import os

ARC_TOKEN = os.environ["ARC_TOKEN"]

# Large delete requiring confirmation
response = requests.post(
    "http://localhost:8000/api/v1/delete",
    headers={"Authorization": f"Bearer {ARC_TOKEN}"},
    json={
        "database": "telegraf",
        "measurement": "cpu",
        "where": "time < '2023-01-01'",
        "confirm": True  # Explicitly confirm large operation
    }
)

Example 5: complex WHERE clause

import os

ARC_TOKEN = os.environ["ARC_TOKEN"]

# Delete based on multiple conditions
response = requests.post(
    "http://localhost:8000/api/v1/delete",
    headers={"Authorization": f"Bearer {ARC_TOKEN}"},
    json={
        "database": "telegraf",
        "measurement": "cpu",
        "where": """
            host IN ('server01', 'server02', 'server03')
            AND time BETWEEN '2023-01-01' AND '2023-06-30'
            AND usage_idle < 10
        """,
        "confirm": True
    }
)

Performance characteristics

Delete operations are computationally expensive but designed for infrequent use:

Execution times

Small Files (10MB):

  • Read + Filter + Write: ~50-100ms per file

Medium Files (100MB):

  • Read + Filter + Write: ~500ms-1s per file

Large Files (1GB):

  • Read + Filter + Write: ~2-5s per file

Performance factors

  • File Size: Larger files take longer to rewrite
  • Selectivity: Fewer deleted rows = faster (less data movement)
  • Storage I/O: Disk speed affects read/write performance
  • Concurrent Load: Other operations may slow deletion

Best practices

1. Keep disabled by default

Only enable delete operations when needed:

[delete]
enabled = false  # Enable only when necessary

2. Always use dry run

Test operations before execution to verify scope:

# Step 1: Dry run
result = requests.post(..., json={"dry_run": True})
print(f"Would delete {result['deleted_count']} rows")

# Step 2: Review affected files
for file in result['files']:
    print(f"{file['path']}: {file['action']}")

# Step 3: Execute if satisfied
result = requests.post(..., json={"dry_run": False, "confirm": True})

3. Consider retention policies

For time-based deletion, use retention policies instead:

# Instead of manual deletes:
# requests.post("/api/v1/delete", json={"where": "time < '2024-01-01'"})

# Use retention policies:
requests.post("/api/v1/retention", json={
    "retention_days": 90,
    "buffer_days": 7
})

4. Monitor execution times

Track deletion performance for capacity planning:

import time

start = time.time()
result = requests.post("/api/v1/delete", json={...})
elapsed = time.time() - start

print(f"Deleted {result['deleted_count']} rows in {elapsed:.2f}s")

5. Batch large deletes

Break large deletions into smaller batches by time range:

from datetime import datetime, timedelta

# Instead of one large delete:
# WHERE time < '2023-01-01'

# Batch by month:
start = datetime(2022, 1, 1)
while start < datetime(2023, 1, 1):
    end = start + timedelta(days=30)

    requests.post("/api/v1/delete", json={
        "where": f"time >= '{start.isoformat()}' AND time < '{end.isoformat()}'"
    })

    start = end

6. Understand storage impact

Deletion rewrites files, which may temporarily increase storage usage:

# Before deletion: 100MB original file
# During deletion: 100MB original + 60MB temp file = 160MB
# After deletion: 60MB rewritten file

Ensure sufficient disk space for temporary files during operations.

Limitations

Not for frequent operations

Delete operations rewrite entire Parquet files, making them expensive. They are designed for infrequent, manual operations only.

Use Cases:

  • Removing test data
  • Deleting specific hosts/sensors
  • One-time cleanup operations

Not Suitable For:

  • Automated recurring deletions (use retention policies)
  • High-frequency data cleanup
  • Real-time data removal

Explicit WHERE required

Full-table deletion requires explicit WHERE 1=1:

# This will fail:
{"where": ""}  # Error: WHERE clause required

# Explicit full delete:
{"where": "1=1", "confirm": True}

Maximum row limits

Large deletions are subject to max_rows_per_delete configuration:

# Will fail if exceeds limit:
{"where": "time < '2020-01-01'"}  # May exceed max_rows

# Solution: Batch by time range
{"where": "time >= '2023-01-01' AND time < '2023-02-01'"}

One DELETE at a time

DELETEs rewrite one at a time; a second one waits for the first. Writes are not blocked.

Troubleshooting

Delete not enabled

Problem: delete.enabled is false (the default).

Solution:

[delete]
enabled = true

Confirmation required

Problem: Operation exceeds confirmation threshold.

Solution: Add confirm: true:

{"confirm": true}

Exceeds maximum rows

Problem: Deletion would affect more rows than max_rows_per_delete.

Solutions:

  1. Batch the operation by time range
  2. Increase max_rows_per_delete (carefully)
  3. Use retention policies for large-scale cleanup

No rows deleted

Problem: deleted_count: 0 but expected deletions.

Solutions:

  • Verify WHERE clause syntax matches data
  • Check that data exists in the specified database/measurement
  • Use dry run to inspect affected files

Slow execution

Problem: Delete operations take longer than expected.

Solutions:

  • Check file sizes (large files take longer)
  • Monitor disk I/O performance
  • Batch operations during low-traffic periods
  • Consider using retention policies for time-based cleanup

On this page