Skip to content

HDDS-14913. Implement Scalable CSV Export for Unhealthy Containers in Recon UI.#10162

Draft
ArafatKhan2198 wants to merge 8 commits intoapache:masterfrom
ArafatKhan2198:csvExport2
Draft

HDDS-14913. Implement Scalable CSV Export for Unhealthy Containers in Recon UI.#10162
ArafatKhan2198 wants to merge 8 commits intoapache:masterfrom
ArafatKhan2198:csvExport2

Conversation

@ArafatKhan2198
Copy link
Copy Markdown
Contributor

@ArafatKhan2198 ArafatKhan2198 commented Apr 30, 2026

What changes were proposed in this pull request?

The Recon UI had no way for administrators to export unhealthy container data (Missing, Under-Replicated, Over-Replicated, etc.) at scale. For clusters with millions of containers, any streaming export over a long-running HTTP connection would be killed by network infrastructure (firewalls, load balancers, proxies) before completion.


Solution: Asynchronous Background Export with Queue

Instead of streaming data directly to the browser, this PR implements a server-side background job system that:

  1. Builds the export on the Recon node itself
  2. Splits large exports into 500K-record CSV chunks
  3. Archives them into a single TAR file
  4. Lets the user download the TAR from the browser when ready

Backend Changes

New: ExportJob model (ExportJob.java)

A data class representing one export job with fields:

  • jobId (UUID), userId, state (container state), status (QUEUED → RUNNING → COMPLETED/FAILED)
  • queuePosition, totalRecords, estimatedTotal, progressPercent
  • filePath (path to TAR on disk), submittedAt, startedAt, completedAt, errorMessage

New: ExportJobManager.java — the core engine

A Guice Singleton that runs for the lifetime of the Recon server:

  • Single-threaded executor — one export runs at a time, eliminating concurrent Derby database access
  • Global queue (max 4 jobs) — incoming requests beyond the limit return HTTP 429
  • 3-second cooldown between jobs (on the worker thread, transparent to users)
  • CSV splitting — every 500K records creates a new part file (e.g., part001.csv, part002.csv)
  • TAR archiving — all part files are archived using Archiver.create() into export_{state}_{userId}_{shortJobId}.tar
  • Progress tracking — runs a COUNT(*) before the cursor opens to calculate estimatedTotal; totalRecords increments live
  • Cleanup — temp CSV files and their directory are deleted after TAR is created
  • Synchronized submitJob() — prevents race conditions when multiple users submit simultaneously
  • getQueuePosition() — walks LinkedHashMap (insertion-order) to return 1-indexed position

ContainerEndpoint.java — new REST endpoints

Method Path Purpose
POST /api/v1/containers/unhealthy/export Submit a new export job
GET /api/v1/containers/unhealthy/export List all jobs (new)
GET /api/v1/containers/unhealthy/export/{jobId} Get one job's status
GET /api/v1/containers/unhealthy/export/{jobId}/download Stream the TAR to browser
DELETE /api/v1/containers/unhealthy/export/{jobId} Cancel a job

Queue-full (429) errors return JSON instead of Jetty's HTML error page.

ContainerHealthSchemaManager.java

  • Added getUnhealthyContainersCursor() — jOOQ lazy cursor for streaming DB records without holding them all in JVM heap
  • Added getUnhealthyContainersCount() — fast COUNT(*) used before the cursor opens for progress estimation

ReconServerConfigKeys.java

New config keys:

  • ozone.recon.export.worker.threads (default: 1)
  • ozone.recon.export.directory (default: /tmp/recon/exports)
  • ozone.recon.export.max.jobs.total (default: 10)

Frontend Changes (containers.tsx, container.types.ts)

New: Export Tab (tab key '6')

A dedicated Export tab is added to the Containers page alongside Missing, Under-Replicated, etc. It contains:

Submit Controls:

  • Dropdown to select container state (Missing, Under-Replicated, Over-Replicated, Mis-Replicated, Replica Mismatch)
  • "Export CSV" button — POSTs to backend and immediately shows the job in the table below

Active Exports table (hidden when empty):

  • Columns: Job ID (8-char + full ID tooltip), State, Status (colored Tag), Queue Position (#1, #2...), Progress bar + record count
  • No pagination — always compact

Completed Exports table (always visible, paginated):

  • Columns: Job ID, State, Status, Records, Submitted, Started, Completed, Action
  • Download button (only for COMPLETED jobs) — triggers TAR file download to browser
  • Error message tooltip (for FAILED jobs)
  • Timestamps formatted as MMM D, HH:mm:ss

Polling:

  • 3-second interval using setInterval + useRef — starts when Export tab is opened or a job is submitted
  • Auto-stops when no QUEUED or RUNNING jobs remain

Error handling:

  • 429 queue-full error shows a 6-second toast with the specific message
  • All errors show clean messages (no raw HTML from Jetty)
  • Guard in fetchTabData prevents undefined API calls when Export tab is active
## What is the link to the Apache JIRA

https://issues.apache.org/jira/browse/HDDS-14913

How was this patch tested?

Log Changes -


2026-04-30 15:16:48 2026-04-30 09:46:48,962 [pool-56-thread-1] INFO api.ExportJobManager: Starting export job ac16b513-f3f0-4e2d-a124-f208155697c3
2026-04-30 15:16:54 2026-04-30 09:46:54,625 [pool-56-thread-1] INFO api.ExportJobManager: Export job ac16b513-f3f0-4e2d-a124-f208155697c3 will process approximately 3040000 records
2026-04-30 15:16:54 2026-04-30 09:46:54,628 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part1
2026-04-30 15:17:28 2026-04-30 09:47:28,413 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part2
2026-04-30 15:17:57 2026-04-30 09:47:57,420 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part3
2026-04-30 15:17:58 2026-04-30 09:47:58,876 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part4
2026-04-30 15:18:00 2026-04-30 09:48:00,646 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part5
2026-04-30 15:18:02 2026-04-30 09:48:02,488 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part6
2026-04-30 15:18:04 2026-04-30 09:48:04,261 [pool-56-thread-1] INFO api.ExportJobManager: Created CSV file: part7
2026-04-30 15:18:04 2026-04-30 09:48:04,429 [pool-56-thread-1] INFO api.ExportJobManager: Export job ac16b513-f3f0-4e2d-a124-f208155697c3 wrote 3040000 records across 7 files
2026-04-30 15:18:05 2026-04-30 09:48:05,730 [pool-56-thread-1] INFO api.ExportJobManager: Created TAR archive: /tmp/recon/exports/export_missing_webui_ac16b513.tar
2026-04-30 15:18:05 2026-04-30 09:48:05,755 [pool-56-thread-1] INFO api.ExportJobManager: Deleted temporary CSV files for job ac16b513-f3f0-4e2d-a124-f208155697c3
2026-04-30 15:18:05 2026-04-30 09:48:05,755 [pool-56-thread-1] INFO api.ExportJobManager: Completed export job ac16b513-f3f0-4e2d-a124-f208155697c3 (3040000 records)
CSV_Export_Feature.mp4

@devmadhuu devmadhuu self-requested a review April 30, 2026 10:17
@devmadhuu
Copy link
Copy Markdown
Contributor

@ArafatKhan2198 as discussed, please design the solution server based for single Recon user. We don't have user based logins in Recon. We should not localize the logic at browser for job progress. All browser windows opened in multiple machines opening the recon page should see the same job and its progress. At a time only job should be allowed to run and remaining 2 should go in queue.

Copy link
Copy Markdown
Contributor

@sumitagrawl sumitagrawl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ArafatKhan2198 Thanks for working, given few comments

private long estimatedTotal;

@JsonProperty("filePath")
private String filePath;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exposing internal file path may have security risk, we should not return file path

*/
public static final String OZONE_RECON_EXPORT_DIRECTORY =
"ozone.recon.export.directory";
public static final String OZONE_RECON_EXPORT_DIRECTORY_DEFAULT = "/tmp/recon/exports";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should avoid tmp path, and keep the same Recon metapath with export

LOG.error("Failed to create export directory: {}", exportDirectory, e);
}

LOG.info("ExportJobManager initialized with single-threaded queue (max {} jobs)", MAX_QUEUE_SIZE);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restart of Recon will leave this file as orphan, IMO, we should able to get those jobs again via UI

LOG.error("Failed to create export directory: {}", exportDirectory, e);
}

LOG.info("ExportJobManager initialized with single-threaded queue (max {} jobs)", MAX_QUEUE_SIZE);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For failed job files remaining over disk, we should remove it, can clean failed jobs

LOG.info("ExportJobManager initialized with single-threaded queue (max {} jobs)", MAX_QUEUE_SIZE);
}

public synchronized String submitJob(String userId, String state, int limit, long prevKey) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need remove userId

ExportJob job = new ExportJob(jobId, userId, state, limit, prevKey);
// Filename format: export_{state}_{userId}_{shortJobId}.tar
String shortJobId = jobId.substring(0, 8);
String filePath = exportDirectory + "/export_" + state.toLowerCase() + "_" + userId + "_" + shortJobId + ".tar";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are you ensuring file are unique ? why you need uniqueness ? may be timestamp can be added

int fileIndex = 1;
long totalRecords = 0;
long recordsInCurrentFile = 0;
final int CHUNK_SIZE = 500_000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to RECORD_SIZE

}
}

private void deleteDirectory(Path directory) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reuse exisiting directory delete recursive

} finally {
// 3-second cooldown before the next queued job is picked up by the single worker thread.
try {
Thread.sleep(3000);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not provide any advantage for sleep, as tar logic already provide some delay where waiting task can get lock and proceed.
Also max download file count will help avoid this.

replicaMismatchCount: number;
}

export type ExportJobStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI related,

  1. Download button should not show filename, it can be downloaded
  2. Active Export / Completed Export: can be combined, and add fields, submitted time, started time.
  3. DELETE -- should act as cancel and/or deleted completed jobs ?

Copy link
Copy Markdown
Contributor

@devmadhuu devmadhuu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ArafatKhan2198 for improving the patch. However few comments, pls check.
Also I am not sure of any cleanup or TTL for completed jobs. How these exported files will be cleaned up, what is the lifecycle ? They can continue to accumulate indefinitely ?

}

// Check global queue size limit
synchronized (jobQueue) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very confusing. This method acquires this object lock, then lock over jobQueue. Any other thread that acquires jobQueue first and then tries to call a synchronized method creates a deadlock condition.

Copy link
Copy Markdown
Contributor

@devmadhuu devmadhuu May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls check this still not solved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deadlock nested synchronized(this) + synchronized(jobQueue) Fixed. Removed synchronized from submitJob and moved all queue checks and mutations into a single synchronized(jobQueue) block. One lock everywhere, no nesting, no possible lock-order deadlock.

@Singleton
public class ExportJobManager {
private static final Logger LOG = LoggerFactory.getLogger(ExportJobManager.class);
private static final int MAX_QUEUE_SIZE = 4;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better put a comment here, why hardcoded as 4 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with maxQueueSize field read from ozone.recon.export.max.jobs.total config (default 4). Javadoc explains the choice: single-threaded worker, ~5 unhealthy states, one-TAR-per-state rule.

*/
public static final String OZONE_RECON_EXPORT_MAX_JOBS_TOTAL =
"ozone.recon.export.max.jobs.total";
public static final int OZONE_RECON_EXPORT_MAX_JOBS_TOTAL_DEFAULT = 10;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these used anywhere ? Also contradicts with your thread queue size ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant is now actively read in ExportJobManager constructor and wired to maxQueueSize. Default updated to 4 to match the design.

* Default: 1
*/
public static final String OZONE_RECON_EXPORT_WORKER_THREADS =
"ozone.recon.export.worker.threads";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the constant entirely. Export is intentionally single-threaded to avoid concurrent Derby access a worker-threads config would be misleading.

* Manages asynchronous CSV export jobs.
*/
@Singleton
public class ExportJobManager {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some unit tests for this class

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit tests for ExportJobManager Added TestExportJobManager covering: submit success, empty result, duplicate-state (running + completed), failed-state retry, queue-full, cancel running, cancel completed, unknown job, queue position, startup cleanup, and filename pattern. Plus TestExportJob for the download counter and path derivation.


// Controls how many rows Derby returns per JDBC round-trip.
// Default is 10,000 rows.
query.fetchSize(10000);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hardcoded again. In old PR , it was fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchSize(10000) hardcoded Fixed. Now reads from ozone.recon.unhealthy.container.fetch.size (default 10,000) wired in ContainerHealthSchemaManager constructor via OzoneConfiguration.

* @param prevKey Container ID offset for cursor-based pagination
* @return Total count of matching containers
*/
public long getUnhealthyContainersCount(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check javadoc above this method. Seems something wrong.

this.totalRecords = totalRecords;
}

public void incrementTotalRecords() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, what is the purpose of this. The current code passes a local long totalRecords counter and calls setTotalRecords on every row. Using incrementTotalRecords() removes the local counter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

@ArafatKhan2198
Copy link
Copy Markdown
Contributor Author

@devmadhuu @sumitagrawl please take another look

Copy link
Copy Markdown
Contributor

@devmadhuu devmadhuu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ArafatKhan2198 Few comments still unresolved. pls check.

private int maxDownloads;

@JsonProperty("downloadCount")
private int downloadCount;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make this to AtomicInteger to avoid any race conditions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced int downloadCount with AtomicInteger and introduced a single tryReserveDownload() method that atomically checks and increments in one CAS loop, so concurrent download requests can't race past the limit.

final int RECORDS_PER_FILE = 500_000;

BufferedWriter writer = null;
FileOutputStream fos = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use fos in try finally block to avoid any resource leak.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fos in try/finally Fixed. Added an inner try/finally around the BufferedWriter construction if wrapping fails, fos.close() is called immediately so no file descriptor leaks.

@ArafatKhan2198 ArafatKhan2198 requested a review from devmadhuu May 5, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants