001package gudusoft.gsqlparser.util;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.TGSqlParser;
005import gudusoft.gsqlparser.util.json.JSON;
006
007import java.io.*;
008import java.util.ArrayList;
009import java.util.LinkedHashMap;
010import java.util.List;
011import java.util.Map;
012
013/**
014 * Splits large files into smaller chunks for parallel processing.
015 *
016 * <p>Supports two categories of input:
017 * <ul>
018 *   <li><b>Plain SQL files</b> — split by statement boundaries using the SQL
019 *       parser to find safe cut points.</li>
020 *   <li><b>JSON metadata files</b> — split by format (sqldep/grabit, sqlflow,
021 *       sqlflow-sharded), converting each into smaller self-contained JSON files
022 *       that the re-reader can consume independently.</li>
023 * </ul>
024 *
025 * <h3>JSON format routing</h3>
026 * splitJsonFile() routes to the appropriate splitter based on
027 * {@code createdBy} and {@code format}:
028 * <ul>
029 *   <li>{@code format: "sqlflow-sharded"} → splitSqlflowShardedJson()</li>
030 *   <li>{@code createdBy} contains "sqldep" or "grabit" → splitSqldepGrabitJson()</li>
031 *   <li>{@code createdBy} contains "sqlflow" → splitSqlflowJson()</li>
032 * </ul>
033 */
034public class FileSplitter {
035
036    private static final int MAX_CONSECUTIVE_FAILURES = 3;
037    private static final int BUFFER_SIZE = 8192;
038    private static final Logger logger = LoggerFactory.getLogger(FileSplitter.class);
039
040    /**
041     * Split large file
042     *
043     * @param inputFile   Input file
044     * @param outputDir   Output directory
045     * @param splitSizeMB Split size (MB)
046     * @param dbVendor    Database vendor
047     * @return List of split files
048     * @throws IOException IO exception
049     */
050    public static List<File> splitFile(File inputFile, File outputDir, int splitSizeMB, EDbVendor dbVendor) throws IOException {
051        List<File> splitFiles = new ArrayList<>();
052        if (!inputFile.exists()) {
053            throw new FileNotFoundException("Input file does not exist: " + inputFile.getAbsolutePath());
054        }
055
056        if (!outputDir.exists() && !outputDir.mkdirs()) {
057            throw new IOException("Failed to create output directory: " + outputDir.getAbsolutePath());
058        }
059
060        // Check if it's a JSON file
061        if (isJsonFile(inputFile)) {
062            return splitJsonFile(inputFile, outputDir, splitSizeMB);
063        }
064
065        // Process regular SQL file
066        long splitSizeBytes = splitSizeMB * 1024 * 1024L;
067
068        try (RandomAccessFile raf = new RandomAccessFile(inputFile, "r")) {
069            long fileLength = raf.length();
070            long currentPos = 0;
071            long chunkStartPos = 0;
072            int consecutiveFailures = 0;
073            int fileIndex = 1;
074            long globalStartLineNo = 1;
075
076            while (currentPos < fileLength) {
077                Long lastValidatedLineNo = getLastLineFromCurrentPos(raf, currentPos, fileLength, splitSizeBytes, dbVendor);
078                if (lastValidatedLineNo == null) break;
079
080                while (lastValidatedLineNo != null && lastValidatedLineNo == -1) {
081                    consecutiveFailures++;
082                    logger.warn("No validated statement found at position " + currentPos + "  (consecutive failures: " + consecutiveFailures + ")");
083
084                    long incrementSize = Math.min(splitSizeBytes, fileLength - currentPos);
085                    long previousPos = currentPos;
086                    currentPos = Math.min(currentPos + incrementSize, fileLength);
087
088                    if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
089                        logger.error("Reached maximum consecutive failures (" + MAX_CONSECUTIVE_FAILURES + "), skipping this segment and moving to next");
090                        long skipSize = splitSizeBytes * (consecutiveFailures + 1);
091                        currentPos = Math.min(previousPos + skipSize, fileLength);
092                        chunkStartPos = currentPos;
093                        consecutiveFailures = 0; // Reset consecutive failure counter
094                        logger.info("Skipped to position " + currentPos + ", continuing with next segment");
095                        break;
096                    }
097
098                    lastValidatedLineNo = getLastLineFromCurrentPos(raf, currentPos, fileLength, splitSizeBytes * (consecutiveFailures + 1), dbVendor);
099                }
100
101                if (lastValidatedLineNo == null) {
102                    break;
103                }
104
105                if (lastValidatedLineNo == -1) {
106                    continue;
107                }
108
109                consecutiveFailures = 0;
110                long endPos = findLineEndPosition(raf, currentPos, lastValidatedLineNo);
111                long globalEndLineNo = globalStartLineNo + lastValidatedLineNo - 1;
112
113                File splitFile = splitFileByPosition(outputDir, inputFile, chunkStartPos, endPos, fileIndex, globalStartLineNo, globalEndLineNo);
114                splitFiles.add(splitFile);
115
116                chunkStartPos = endPos;
117                currentPos = endPos;
118                globalStartLineNo = globalEndLineNo + 1;
119                fileIndex++;
120
121                if (currentPos >= fileLength) {
122                    break;
123                }
124            }
125        }
126
127        return splitFiles;
128    }
129
130    /**
131     * Check if file is a JSON file
132     */
133    private static boolean isJsonFile(File file) {
134        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
135            int c;
136            // Skip whitespace characters
137            while ((c = reader.read()) != -1) {
138                if (!Character.isWhitespace(c)) {
139                    // Check if first non-whitespace character is '{'
140                    return c == '{';
141                }
142            }
143        } catch (IOException e) {
144            logger.error("Error reading file to check if it's JSON: " + file.getAbsolutePath(), e);
145        }
146        return false;
147    }
148
149    /**
150     * Get file extension
151     */
152    private static String getFileExtension(String fileName) {
153        int lastDotIndex = fileName.lastIndexOf('.');
154        if (lastDotIndex == -1) {
155            return "";
156        }
157        return fileName.substring(lastDotIndex + 1);
158    }
159
160    /**
161     * Get file name without extension
162     */
163    private static String getFileNameWithoutExtension(String fileName) {
164        int lastDotIndex = fileName.lastIndexOf('.');
165        if (lastDotIndex == -1) {
166            return fileName;
167        }
168        return fileName.substring(0, lastDotIndex);
169    }
170
171    /**
172     * Move file
173     */
174    private static void moveFile(File source, File target) throws IOException {
175        if (!source.exists()) {
176            throw new FileNotFoundException("Source file does not exist: " + source.getAbsolutePath());
177        }
178        if (target.exists() && !target.delete()) {
179            throw new IOException("Failed to delete existing target file: " + target.getAbsolutePath());
180        }
181        if (!source.renameTo(target)) {
182            // If renameTo fails, try copy and delete
183            copyFile(source, target);
184            if (!source.delete()) {
185                throw new IOException("Failed to delete source file after copy: " + source.getAbsolutePath());
186            }
187        }
188    }
189
190    /**
191     * Copy file
192     */
193    private static void copyFile(File source, File target) throws IOException {
194        try (FileInputStream fis = new FileInputStream(source);
195             FileOutputStream fos = new FileOutputStream(target)) {
196            byte[] buffer = new byte[BUFFER_SIZE];
197            int bytesRead;
198            while ((bytesRead = fis.read(buffer)) != -1) {
199                fos.write(buffer, 0, bytesRead);
200            }
201        }
202    }
203
204    /**
205         * Split a JSON metadata file by routing to the format-specific splitter.
206         *
207         * <p>Routing logic:
208         * <ul>
209         *   <li>{@code format: "sqlflow-sharded"} → splitSqlflowShardedJson()
210         *       (after version-gating via isSupportedSqlflowSharded)</li>
211         *   <li>{@code createdBy} contains "sqldep" or "grabit" → splitSqldepGrabitJson()</li>
212         *   <li>{@code createdBy} contains "sqlflow" (and no format key) → splitSqlflowJson()</li>
213         * </ul>
214         *
215         * @param inputFile   the JSON file to split
216         * @param outputDir   the directory to write split files
217         * @param splitSizeMB target split size in megabytes
218         * @return list of generated split files
219         */
220        private static List<File> splitJsonFile(File inputFile, File outputDir, int splitSizeMB) throws IOException {
221        List<File> splitFiles = new ArrayList<>();
222
223        // Read file content
224        String jsonContent = readFileContent(inputFile);
225        Map<?, ?> content = (Map<?, ?>) JSON.parseObject(jsonContent);
226        String createdBy = (String) content.get("createdBy");
227        String format = (String) content.get("format");
228
229        if (createdBy == null) {
230            logger.warn("JSON file does not contain 'createdBy' field, treating as regular file");
231            return splitFiles;
232        }
233
234        // Check for sqlflow-sharded format (new version)
235        if ("sqlflow-sharded".equals(format)) {
236            if (!gudusoft.gsqlparser.dlineage.dataflow.metadata.MetadataReader
237                    .isSupportedSqlflowSharded(jsonContent)) {
238                // Unknown future formatVersion: do not split/rewrite a manifest we
239                // may mis-read into legacy files that strip the version.
240                logger.warn("Unsupported sqlflow-sharded formatVersion in " + inputFile
241                        + "; not split.");
242                return splitFiles;
243            }
244            splitFiles.addAll(splitSqlflowShardedJson(inputFile, outputDir, splitSizeMB, content));
245        } else if (createdBy.toLowerCase().contains("sqldep") || createdBy.toLowerCase().contains("grabit")) {
246            splitFiles.addAll(splitSqldepGrabitJson(inputFile, outputDir, splitSizeMB, content, createdBy));
247        } else if (createdBy.toLowerCase().contains("sqlflow")) {
248            splitFiles.addAll(splitSqlflowJson(inputFile, outputDir, splitSizeMB, content));
249        } else {
250            logger.warn("Unknown JSON file type with createdBy: " + createdBy);
251        }
252
253        return splitFiles;
254    }
255
256    private static String readFileContent(File inputFile) {
257        return SQLUtil.getFileContent(inputFile);
258    }
259
260    /**
261     * Write JSON file
262     */
263    private static void writeJsonFile(File file, Object content) throws IOException {
264        try (FileWriter writer = new FileWriter(file)) {
265            JSON.toJSONString(content, writer);
266        }
267    }
268
269    /**
270         * Split a sqldep or grabit format JSON file.
271         *
272         * <p>These formats have {@code queries[]} at the root level. The splitter:
273         * <ul>
274         *   <li>Creates a metadata file (everything except queries) as the first
275         *       output file.</li>
276         *   <li>Splits the queries into batches by cumulative sourceCode size,
277         *       preserving the original {@code createdBy} so the re-reader
278         *       recognizes the format via {@code MetadataReader.isGrabit()}.</li>
279         * </ul>
280         *
281         * @param inputFile   the original JSON file
282         * @param outputDir   the directory to write split files
283         * @param splitSizeMB target split size in megabytes
284         * @param content     the parsed JSON content
285         * @param createdBy   the original createdBy string
286         * @return list of generated split files
287         */
288        private static List<File> splitSqldepGrabitJson(File inputFile, File outputDir, int splitSizeMB, Map<?, ?> content, String createdBy) throws IOException {
289        List<File> splitFiles = new ArrayList<>();
290        List<Map<?, ?>> queries = (List<Map<?, ?>>) content.get("queries");
291        if (queries == null || queries.isEmpty()) {
292            logger.warn("No queries found in JSON file");
293            return splitFiles;
294        }
295
296        // Remove queries field, create metadata file
297        @SuppressWarnings("unchecked")
298        Map<String, Object> metadataContent = new LinkedHashMap<>((Map<String, Object>) content);
299        metadataContent.remove("queries");
300
301        String extension = getFileExtension(inputFile.getName());
302        File metadataFile = new File(outputDir, getFileNameWithoutExtension(inputFile.getName()) + "_" + 0 + "_" + 1 + (extension.isEmpty() ? "" : "." + extension));
303        writeJsonFile(metadataFile, metadataContent);
304        splitFiles.add(metadataFile);
305        logger.info("Created metadata file: " + metadataFile.getAbsolutePath());
306
307        // Split queries
308        long splitSizeBytes = splitSizeMB * 1024 * 1024L;
309        int length = 0;
310        List<Map<?, ?>> temp = new ArrayList<>();
311        long startIndex = 1;
312        long endIndex = 1;
313
314        for (Map<?, ?> item : queries) {
315            temp.add(item);
316            String sourceCode = (String) item.get("sourceCode");
317            if (sourceCode != null) {
318                length += sourceCode.length();
319            }
320
321            if (length >= splitSizeBytes) {
322                File queryFile = createQueryFile(outputDir, inputFile, startIndex, endIndex, extension);
323                Map<String, Object> jsonObject = new LinkedHashMap<>();
324                jsonObject.put("createdBy", createdBy);
325                jsonObject.put("dbvendor", content.get("dbvendor"));
326                jsonObject.put("databases", new ArrayList<>());
327                jsonObject.put("queries", temp);
328                writeJsonFile(queryFile, jsonObject);
329                splitFiles.add(queryFile);
330                logger.info("Created query file: " + queryFile.getAbsolutePath());
331
332                temp.clear();
333                length = 0;
334                startIndex = endIndex;
335            }
336            endIndex++;
337        }
338
339        // Process remaining queries
340        if (!temp.isEmpty()) {
341            File queryFile = createQueryFile(outputDir, inputFile, startIndex, endIndex, extension);
342            Map<String, Object> jsonObject = new LinkedHashMap<>();
343            jsonObject.put("createdBy", createdBy);
344            jsonObject.put("dbvendor", content.get("dbvendor"));
345            jsonObject.put("databases", new ArrayList<>());
346            jsonObject.put("queries", temp);
347            writeJsonFile(queryFile, jsonObject);
348            splitFiles.add(queryFile);
349            logger.info("Created query file: " + queryFile.getAbsolutePath());
350        }
351
352        return splitFiles;
353    }
354
355    /**
356         * Split an old sqlflow format JSON file.
357         *
358         * <p>The old sqlflow format has {@code servers[].queries[]} structure with
359         * no {@code format} key. The splitter:
360         * <ul>
361         *   <li>Extracts all queries from all servers, removing them from the
362         *       metadata.</li>
363         *   <li>Creates a metadata file with the servers (minus queries) as the
364         *       first output file.</li>
365         *   <li>Splits the queries into batches by cumulative sourceCode size,
366         *       writing each batch as a grabit-format file ({@code createdBy: "grabit
367         *       v1.7.0"}, {@code queries[]} at root level) so the re-reader
368         *       recognizes it via {@code MetadataReader.isGrabit()}.</li>
369         * </ul>
370         *
371         * @param inputFile   the original JSON file
372         * @param outputDir   the directory to write split files
373         * @param splitSizeMB target split size in megabytes
374         * @param content     the parsed JSON content
375         * @return list of generated split files
376         */
377        private static List<File> splitSqlflowJson(File inputFile, File outputDir, int splitSizeMB, Map<?, ?> content) throws IOException {
378        List<File> splitFiles = new ArrayList<>();
379        List<Map<?, ?>> queries = new ArrayList<>();
380
381        // Extract all queries
382        List<Map<?, ?>> servers = (List<Map<?, ?>>) content.get("servers");
383        if (servers != null) {
384            for (Map<?, ?> serverObject : servers) {
385                @SuppressWarnings("unchecked")
386                List<Map<?, ?>> serverQueries = (List<Map<?, ?>>) serverObject.get("queries");
387                if (serverQueries != null) {
388                    queries.addAll(serverQueries);
389                    serverObject.remove("queries");
390                }
391            }
392        }
393
394        if (queries.isEmpty()) {
395            logger.warn("No queries found in SQLFlow JSON file");
396            return splitFiles;
397        }
398
399        // Create metadata file
400        String extension = getFileExtension(inputFile.getName());
401        File metadataFile = new File(outputDir, getFileNameWithoutExtension(inputFile.getName()) + "_" + 0 + "_" + 1 + (extension.isEmpty() ? "" : "." + extension));
402        writeJsonFile(metadataFile, content);
403        splitFiles.add(metadataFile);
404        logger.info("Created metadata file: " + metadataFile.getAbsolutePath());
405
406        // Split queries
407        long splitSizeBytes = splitSizeMB * 1024 * 1024L;
408        int length = 0;
409        List<Map<?, ?>> temp = new ArrayList<>();
410        long startIndex = 1;
411        long endIndex = 1;
412
413        for (Map<?, ?> item : queries) {
414            temp.add(item);
415            String sourceCode = (String) item.get("sourceCode");
416            if (sourceCode != null) {
417                length += sourceCode.length();
418            }
419
420            if (length >= splitSizeBytes) {
421                File queryFile = createQueryFile(outputDir, inputFile, startIndex, endIndex, extension);
422                Map<String, Object> jsonObject = new LinkedHashMap<>();
423                jsonObject.put("createdBy", "grabit v1.7.0");
424                jsonObject.put("queries", temp);
425                writeJsonFile(queryFile, jsonObject);
426                splitFiles.add(queryFile);
427                logger.info("Created query file: " + queryFile.getAbsolutePath());
428
429                temp.clear();
430                length = 0;
431                startIndex = endIndex;
432            }
433            endIndex++;
434        }
435
436        // Process remaining queries
437        if (!temp.isEmpty()) {
438            File queryFile = createQueryFile(outputDir, inputFile, startIndex, endIndex, extension);
439            Map<String, Object> jsonObject = new LinkedHashMap<>();
440            jsonObject.put("createdBy", "grabit v1.7.0");
441            jsonObject.put("queries", temp);
442            writeJsonFile(queryFile, jsonObject);
443            splitFiles.add(queryFile);
444            logger.info("Created query file: " + queryFile.getAbsolutePath());
445        }
446
447        return splitFiles;
448    }
449
450    //autogenerate
451    /**
452         * Split a sqlflow-sharded manifest into multiple legacy sqlflow files suitable
453         * for parallel processing.
454         *
455         * <h3>Format conversion</h3>
456         * Input:  sqlflow-sharded ({@code format: "sqlflow-sharded"}, separate
457         *         catalog/*.catalog.json and source/*.source.jsonl files).
458         * Output: old sqlflow format (no {@code format} key, {@code createdBy}
459         *         containing "sqlflow", catalog and source inlined into
460         *         {@code servers[].databases[]} / {@code servers[].queries[]}).
461         *
462         * <p>The conversion is necessary because the re-reader identifies the old
463         * sqlflow format via {@code MetadataReader.isSqlflow()}, which requires:
464         * <ul>
465         *   <li>No {@code format} key present</li>
466         *   <li>{@code createdBy} contains "sqlflow"</li>
467         * </ul>
468         *
469         * <h3>Split strategy</h3>
470         * <ul>
471         *   <li>Catalog: if a single database's catalog exceeds splitSizeMB, keep it
472         *       as one file; otherwise merge consecutive databases until the batch
473         *       reaches splitSizeMB.</li>
474         *   <li>Source: if a single database's source is below splitSizeMB, merge with
475         *       the next; if above, split by record count keeping individual records
476         *       complete.</li>
477         * </ul>
478         *
479         * <h3>Limitations</h3>
480         * Only catalog-topology (servers[].databases[]) is supported. Schema-topology
481         * (servers[].schemas[]) exports are skipped — the caller falls back to
482         * whole-file processing.
483         *
484         * @param inputFile   the original manifest file
485         * @param outputDir   the directory to write split files
486         * @param splitSizeMB target split size in megabytes
487         * @param manifest    the parsed manifest JSON
488         * @return list of generated split files
489         */
490        private static List<File> splitSqlflowShardedJson(File inputFile, File outputDir, int splitSizeMB, Map<?, ?> manifest) throws IOException {
491        List<File> splitFiles = new ArrayList<>();
492        
493        File baseDir = inputFile.getParentFile();
494        if (baseDir == null) {
495            logger.warn("Cannot determine base directory from manifest file");
496            return splitFiles;
497        }
498        
499        String createdBy = (String) manifest.get("createdBy");
500        List<Map<?, ?>> servers = (List<Map<?, ?>>) manifest.get("servers");
501        if (servers == null || servers.isEmpty()) {
502            logger.warn("No servers found in sqlflow-sharded manifest");
503            return splitFiles;
504        }
505        
506        String extension = getFileExtension(inputFile.getName());
507        long splitSizeBytes = splitSizeMB * 1024 * 1024L;
508        
509        // Collect all database info first
510        List<DatabaseCatalogInfo> allCatalogs = new ArrayList<>();
511        List<DatabaseSourceInfo> allSources = new ArrayList<>();
512        
513        for (Map<?, ?> server : servers) {
514            String serverName = (String) server.get("name");
515            String dbVendor = (String) server.get("dbVendor");
516            
517            // NOTE: schema-topology exports (servers[].schemas[]) are
518            // intentionally NOT split here. This splitter's output writers emit
519            // catalog-topology (databases[]-wrapped) shards; emitting flat
520            // schema shards through them yields a manifest legacy readers load
521            // as 0 tables. Rather than produce a broken split, we skip schema
522            // shards so the caller falls back to whole-file processing, which
523            // the readers now handle correctly (SqlflowSharded* schema-topology
524            // support). Splitting schema-topology manifests needs schema-aware
525            // output writers + a round-trip test — tracked as a follow-up.
526            List<Map<?, ?>> databases = (List<Map<?, ?>>) server.get("databases");
527            if (databases == null || databases.isEmpty()) {
528                continue;
529            }
530            
531            for (Map<?, ?> database : databases) {
532                String databaseName = (String) database.get("name");
533                String shardId = (String) database.get("shardId");
534                
535                // Collect catalog info
536                Map<?, ?> catalogInfo = (Map<?, ?>) database.get("catalog");
537                if (catalogInfo != null) {
538                    String catalogPath = (String) catalogInfo.get("path");
539                    File catalogFile = resolvePath(baseDir, catalogPath);
540                    if (catalogFile != null && catalogFile.exists()) {
541                        allCatalogs.add(new DatabaseCatalogInfo(serverName, dbVendor, databaseName, shardId, catalogFile));
542                    }
543                }
544                
545                // Collect source info
546                Map<?, ?> sourceInfo = (Map<?, ?>) database.get("source");
547                String sourceCompression = (String) manifest.get("sourceCompression");
548                if (sourceInfo != null) {
549                    String sourcePath = (String) sourceInfo.get("path");
550                    File sourceFile = resolvePath(baseDir, sourcePath);
551                    if (sourceFile != null && sourceFile.exists()) {
552                        allSources.add(new DatabaseSourceInfo(serverName, dbVendor, databaseName, shardId, sourceFile, sourceCompression));
553                    }
554                }
555            }
556        }
557        
558        // Process catalogs: merge if total < splitSizeMB, otherwise keep single
559        List<File> catalogFiles = processCatalogs(outputDir, inputFile, extension, allCatalogs, splitSizeBytes, createdBy);
560        splitFiles.addAll(catalogFiles);
561        
562        // Process sources: merge if total < splitSizeMB, split by size if > splitSizeMB
563        List<File> sourceFiles = processSources(outputDir, inputFile, extension, allSources, splitSizeBytes, createdBy);
564        splitFiles.addAll(sourceFiles);
565        
566        return splitFiles;
567    }
568    
569    /**
570         * Process catalog shards: batch small catalogs together, keep large ones
571         * alone.
572         *
573         * <p>Strategy:
574         * <ul>
575         *   <li>If a single catalog exceeds splitSizeBytes, write it as a standalone
576         *       file via {@link #createCatalogSingleFile}.</li>
577         *   <li>Otherwise, accumulate catalogs into a batch. Flush the batch when
578         *       adding the next catalog would exceed maxBatchSize (2× splitSizeBytes).</li>
579         *   <li>Any remaining batch is flushed at the end.</li>
580         * </ul>
581         *
582         * <p>All output files use the old sqlflow format (no {@code format} key,
583         * {@code servers[].databases[]}) so the re-reader recognizes them via
584         * {@code MetadataReader.isSqlflow()}.
585         *
586         * @param outputDir     the directory to write output files
587         * @param inputFile     the original manifest file (used for naming)
588         * @param extension     the file extension
589         * @param catalogs      the list of catalog shards to process
590         * @param splitSizeBytes target split size in bytes
591         * @param createdBy     the createdBy string (must contain "sqlflow")
592         * @return list of generated catalog files
593         */
594        private static List<File> processCatalogs(File outputDir, File inputFile, String extension,
595                                           List<DatabaseCatalogInfo> catalogs, long splitSizeBytes,
596                                           String createdBy) throws IOException {
597        List<File> result = new ArrayList<>();
598        
599        if (catalogs == null || catalogs.isEmpty()) {
600            return result;
601        }
602        
603        int catalogIndex = 1;
604        List<DatabaseCatalogInfo> batch = new ArrayList<>();
605        int currentSize = 0;
606        long maxBatchSize = splitSizeBytes * 2;
607        
608        for (DatabaseCatalogInfo catalog : catalogs) {
609            int catalogSize = (int) catalog.file.length();
610            
611            // If single catalog > splitSizeBytes (5MB), it must be alone
612            if (catalogSize > splitSizeBytes) {
613                // Save current batch first
614                if (!batch.isEmpty()) {
615                    File batchFile = createCatalogBatchFile(outputDir, inputFile, extension, batch, catalogIndex, createdBy);
616                    if (batchFile != null) {
617                        result.add(batchFile);
618                        logger.info("Created catalog batch file: " + batchFile.getAbsolutePath());
619                    }
620                    catalogIndex++;
621                    batch = new ArrayList<>();
622                    currentSize = 0;
623                }
624                // Write this large catalog alone
625                File singleFile = createCatalogSingleFile(outputDir, inputFile, catalog, extension, catalogIndex, createdBy);
626                if (singleFile != null) {
627                    result.add(singleFile);
628                    logger.info("Created catalog file: " + singleFile.getAbsolutePath());
629                }
630                catalogIndex++;
631            } else {
632                // Check if adding this catalog would exceed 2*splitSize (10MB)
633                if (currentSize + catalogSize > maxBatchSize && !batch.isEmpty()) {
634                    // Save current batch
635                    File batchFile = createCatalogBatchFile(outputDir, inputFile, extension, batch, catalogIndex, createdBy);
636                    if (batchFile != null) {
637                        result.add(batchFile);
638                        logger.info("Created catalog batch file: " + batchFile.getAbsolutePath());
639                    }
640                    catalogIndex++;
641                    batch = new ArrayList<>();
642                    currentSize = 0;
643                }
644                batch.add(catalog);
645                currentSize += catalogSize;
646            }
647        }
648        
649        // Save remaining batch
650        if (!batch.isEmpty()) {
651            File batchFile = createCatalogBatchFile(outputDir, inputFile, extension, batch, catalogIndex, createdBy);
652            if (batchFile != null) {
653                result.add(batchFile);
654                logger.info("Created catalog batch file: " + batchFile.getAbsolutePath());
655            }
656        }
657        
658        return result;
659    }
660    
661    /**
662         * Process source shards: read all records from JSONL source files, split them
663         * into batches by cumulative sourceCode size.
664         *
665         * <p>Each source record is enriched with its database name and preserved
666         * fields ({@code schema}, {@code name}, {@code type}, {@code sourceCode},
667         * {@code groupName}, {@code sourceUnavailable}, {@code sourceUnavailableReason})
668         * so the downstream analyzer has the full per-query context.
669         *
670         * <p>Output files use the old sqlflow format ({@code servers[].queries[]},
671         * no {@code format} key) with {@code createdBy} set to the caller-provided
672         * value. The re-reader must contain "sqlflow" to be recognized by
673         * {@code MetadataReader.isSqlflow()}.
674         *
675         * @param outputDir      the directory to write output files
676         * @param inputFile      the original manifest file (used for naming)
677         * @param extension      the file extension
678         * @param sources        the list of source shards to process
679         * @param splitSizeBytes target split size in bytes
680         * @param createdBy      the createdBy string (must contain "sqlflow")
681         * @return list of generated source files
682         */
683        private static List<File> processSources(File outputDir, File inputFile, String extension,
684                                          List<DatabaseSourceInfo> sources, long splitSizeBytes,
685                                          String createdBy) throws IOException {
686        List<File> result = new ArrayList<>();
687        
688        if (sources == null || sources.isEmpty()) {
689            return result;
690        }
691        
692        int startIndex = 1;
693        List<SourceQueryEntry> currentBatch = new ArrayList<>();
694        int currentSize = 0;
695        List<String> mergedFrom = new ArrayList<>();
696
697        for (DatabaseSourceInfo source : sources) {
698            mergedFrom.add(source.shardId);
699
700            List<Map<?, ?>> records = readSourceRecords(source.file, source.compression);
701            if (records == null || records.isEmpty()) {
702                continue;
703            }
704
705            for (Map<?, ?> record : records) {
706                Map<String, Object> query = new LinkedHashMap<>();
707                query.put("database", source.databaseName);
708                query.put("schema", record.get("schema"));
709                query.put("name", record.get("name"));
710                query.put("type", record.get("type"));
711                query.put("sourceCode", record.get("sourceCode"));
712                if (record.containsKey("groupName")) {
713                    query.put("groupName", record.get("groupName"));
714                }
715                // Preserve the availability flag through the split, or the
716                // downstream analyzer cannot tell an unavailable (encrypted /
717                // permission-denied) record from a real one and would parse its
718                // placeholder text as live SQL.
719                if (record.containsKey("sourceUnavailable")) {
720                    query.put("sourceUnavailable", record.get("sourceUnavailable"));
721                }
722                if (record.containsKey("sourceUnavailableReason")) {
723                    query.put("sourceUnavailableReason", record.get("sourceUnavailableReason"));
724                }
725
726                Object sourceCode = record.get("sourceCode");
727                int recordSize = sourceCode != null ? sourceCode.toString().length() : 0;
728
729                if (currentSize + recordSize > splitSizeBytes && !currentBatch.isEmpty()) {
730                    int endIndex = startIndex + currentBatch.size() - 1;
731                    File batchFile = createSourceQueryFileCommon(outputDir, inputFile, extension,
732                            currentBatch, mergedFrom, startIndex, endIndex, createdBy);
733                    if (batchFile != null) {
734                        result.add(batchFile);
735                        logger.info("Created source batch file: " + batchFile.getAbsolutePath());
736                    }
737                    startIndex = endIndex + 1;
738                    currentBatch = new ArrayList<>();
739                    currentSize = 0;
740                }
741
742                currentBatch.add(new SourceQueryEntry(source.serverName, source.dbVendor, query));
743                currentSize += recordSize;
744            }
745        }
746
747        // Save remaining batch
748        if (!currentBatch.isEmpty()) {
749            int endIndex = startIndex + currentBatch.size() - 1;
750            File batchFile = createSourceQueryFileCommon(outputDir, inputFile, extension,
751                    currentBatch, mergedFrom, startIndex, endIndex, createdBy);
752            if (batchFile != null) {
753                result.add(batchFile);
754                logger.info("Created source batch file: " + batchFile.getAbsolutePath());
755            }
756        }
757
758        return result;
759    }
760
761    /**
762         * A single source query record with its server context (serverName, dbVendor).
763         * Used to group queries by server when writing split output files.
764         */
765        private static class SourceQueryEntry {
766        String serverName;
767        String dbVendor;
768        Map<String, Object> query;
769
770        SourceQueryEntry(String serverName, String dbVendor, Map<String, Object> query) {
771            this.serverName = serverName;
772            this.dbVendor = dbVendor;
773            this.query = query;
774        }
775    }
776    
777    /**
778     * Calculate total size of source records
779     */
780    private static int calculateSourceSize(List<Map<?, ?>> records) {
781        if (records == null) return 0;
782        int size = 0;
783        for (Map<?, ?> record : records) {
784            Object sourceCode = record.get("sourceCode");
785            if (sourceCode != null) {
786                size += sourceCode.toString().length();
787            }
788        }
789        return size;
790    }
791    
792    /**
793         * Create a catalog file for a batch of databases by converting sqlflow-sharded
794         * catalog shards into the old sqlflow format.
795         *
796         * <p>The output uses the old sqlflow format ({@code servers[].databases[]},
797         * no {@code format} key) so that the re-reader recognizes it via
798         * {@link gudusoft.gsqlparser.dlineage.dataflow.metadata.MetadataReader#isSqlflow(String)}.
799         * The {@code createdBy} must contain "sqlflow" for the re-reader to recognize
800         * the format.
801         *
802         * @param outputDir  the directory to write the output file
803         * @param inputFile  the original manifest file (used for naming)
804         * @param extension  the file extension to use
805         * @param batch      the batch of catalog shards to merge
806         * @param batchIndex the batch index for file naming
807         * @param createdBy  the createdBy string (must contain "sqlflow")
808         * @return the created catalog file, or null if the batch is empty
809         */
810        private static File createCatalogBatchFile(File outputDir, File inputFile, String extension,
811                                            List<DatabaseCatalogInfo> batch, int batchIndex,
812                                            String createdBy) throws IOException {
813        if (batch == null || batch.isEmpty()) {
814            return null;
815        }
816        
817        Map<String, Object> sqlflow = new LinkedHashMap<>();
818        sqlflow.put("createdBy", createdBy != null ? createdBy : "sqlflow-ingester");
819        
820        // Add mergedFrom field to track original shardIds
821        List<String> mergedFrom = new ArrayList<>();
822        
823        List<Map<String, Object>> servers = new ArrayList<>();
824        
825        for (DatabaseCatalogInfo catalog : batch) {
826            mergedFrom.add(catalog.shardId);
827            
828            Map<String, Object> catalogContent = readCatalogContent(catalog.file);
829            if (catalogContent == null) continue;
830            
831            Map<String, Object> serverObj = new LinkedHashMap<>();
832            serverObj.put("name", catalog.serverName);
833            serverObj.put("dbVendor", catalog.dbVendor);
834            
835            Map<String, Object> dbObj = new LinkedHashMap<>();
836            dbObj.put("name", catalog.databaseName);
837            
838            copyCatalogFields(catalogContent, dbObj);
839            
840            List<Map<String, Object>> databases = new ArrayList<>();
841            databases.add(dbObj);
842            serverObj.put("databases", databases);
843            servers.add(serverObj);
844        }
845
846        sqlflow.put("servers", servers);
847        sqlflow.put("mergedFrom", mergedFrom);
848
849        String fileName = getFileNameWithoutExtension(inputFile.getName()) + "_catalog_" + batchIndex +
850                (extension.isEmpty() ? "" : "." + extension);
851        
852        File outputFile = new File(outputDir, fileName);
853        writeJsonFile(outputFile, sqlflow);
854        return outputFile;
855    }
856    
857    /**
858         * Create a catalog file for a single large database by converting its
859         * sqlflow-sharded catalog shard into the old sqlflow format.
860         *
861         * <p>Same format as {@link #createCatalogBatchFile} but for a single
862         * database whose catalog alone exceeds the split size threshold. The output
863         * uses the old sqlflow format ({@code servers[].databases[]}, no
864         * {@code format} key) so the re-reader recognizes it via
865         * {@code MetadataReader.isSqlflow()}.
866         *
867         * @param outputDir    the directory to write the output file
868         * @param inputFile    the original manifest file (used for naming)
869         * @param catalog      the catalog shard to convert
870         * @param extension    the file extension to use
871         * @param catalogIndex the catalog index for file naming
872         * @param createdBy    the createdBy string (must contain "sqlflow")
873         * @return the created catalog file, or null on failure
874         */
875        private static File createCatalogSingleFile(File outputDir, File inputFile, DatabaseCatalogInfo catalog,
876                                             String extension, int catalogIndex, String createdBy) throws IOException {
877        Map<String, Object> catalogContent = readCatalogContent(catalog.file);
878        if (catalogContent == null) {
879            return null;
880        }
881        
882        Map<String, Object> sqlflow = new LinkedHashMap<>();
883        sqlflow.put("createdBy", createdBy != null ? createdBy : "sqlflow-ingester");
884        
885        Map<String, Object> serverObj = new LinkedHashMap<>();
886        serverObj.put("name", catalog.serverName);
887        serverObj.put("dbVendor", catalog.dbVendor);
888        
889        Map<String, Object> dbObj = new LinkedHashMap<>();
890        dbObj.put("name", catalog.databaseName);
891        copyCatalogFields(catalogContent, dbObj);
892        
893        List<Map<String, Object>> databases = new ArrayList<>();
894        databases.add(dbObj);
895        serverObj.put("databases", databases);
896        
897        List<Map<String, Object>> servers = new ArrayList<>();
898        servers.add(serverObj);
899        sqlflow.put("servers", servers);
900
901        // Add mergedFrom field to track original shardId
902        List<String> mergedFrom = new ArrayList<>();
903        mergedFrom.add(catalog.shardId);
904        sqlflow.put("mergedFrom", mergedFrom);
905        
906        String fileName = getFileNameWithoutExtension(inputFile.getName()) + "_catalog_" + catalogIndex +
907                (extension.isEmpty() ? "" : "." + extension);
908        
909        File outputFile = new File(outputDir, fileName);
910        writeJsonFile(outputFile, sqlflow);
911        return outputFile;
912    }
913    
914    /**
915     * Read catalog content from file
916     */
917    private static Map<String, Object> readCatalogContent(File catalogFile) throws IOException {
918        String content = readFileContent(catalogFile);
919        @SuppressWarnings("unchecked")
920        Map<String, Object> catalog = (Map<String, Object>) JSON.parseObject(content);
921        return catalog;
922    }
923    
924    /**
925         * Copy catalog metadata fields from the raw catalog shard into the database
926         * object of the output file. Preserves schemas, tables, views, procedures,
927         * functions, triggers, packages, and synonyms.
928         */
929        private static void copyCatalogFields(Map<String, Object> catalog, Map<String, Object> dbObj) {
930        if (catalog.containsKey("schemas")) dbObj.put("schemas", catalog.get("schemas"));
931        if (catalog.containsKey("tables")) dbObj.put("tables", catalog.get("tables"));
932        if (catalog.containsKey("views")) dbObj.put("views", catalog.get("views"));
933        if (catalog.containsKey("procedures")) dbObj.put("procedures", catalog.get("procedures"));
934        if (catalog.containsKey("functions")) dbObj.put("functions", catalog.get("functions"));
935        if (catalog.containsKey("triggers")) dbObj.put("triggers", catalog.get("triggers"));
936        if (catalog.containsKey("packages")) dbObj.put("packages", catalog.get("packages"));
937        if (catalog.containsKey("synonyms")) dbObj.put("synonyms", catalog.get("synonyms"));
938    }
939    
940    /**
941         * Create a source query file for a batch of records by writing them in the
942         * old sqlflow format.
943         *
944         * <p>Output format: {@code servers[].queries[]} with no {@code format} key.
945         * Queries are grouped by server (serverName + dbVendor). The
946         * {@code createdBy} is set from the caller-provided value and must contain
947         * "sqlflow" for the re-reader to recognize the format via
948         * {@code MetadataReader.isSqlflow()}.
949         *
950         * <p>File naming: {@code <inputName>_source_<startIndex>_<endIndex>.<ext>}
951         *
952         * @param outputDir  the directory to write the output file
953         * @param inputFile  the original manifest file (used for naming)
954         * @param extension  the file extension
955         * @param entries    the source query records to write
956         * @param mergedFrom the list of original shardIds
957         * @param startIndex the start record index (for file naming)
958         * @param endIndex   the end record index (for file naming)
959         * @param createdBy  the createdBy string (must contain "sqlflow")
960         * @return the created file
961         */
962        private static File createSourceQueryFileCommon(File outputDir, File inputFile, String extension,
963                                                  List<SourceQueryEntry> entries, List<String> mergedFrom,
964                                                  int startIndex, int endIndex,
965                                                  String createdBy) throws IOException {
966        String fileName = getFileNameWithoutExtension(inputFile.getName()) + "_source_" + startIndex + "_" + endIndex +
967                (extension.isEmpty() ? "" : "." + extension);
968
969        File queryFile = new File(outputDir, fileName);
970
971        Map<String, Object> sqlflow = new LinkedHashMap<>();
972        sqlflow.put("createdBy", createdBy != null ? createdBy : "grabit v1.7.0");
973        sqlflow.put("mergedFrom", mergedFrom);
974
975        // Group queries by server (preserve insertion order)
976        Map<String, Map<String, Object>> serverMap = new LinkedHashMap<>();
977        for (SourceQueryEntry entry : entries) {
978            String key = (entry.serverName == null ? "" : entry.serverName) + "|" + (entry.dbVendor == null ? "" : entry.dbVendor);
979            Map<String, Object> serverObj = serverMap.get(key);
980            if (serverObj == null) {
981                serverObj = new LinkedHashMap<>();
982                serverObj.put("name", entry.serverName);
983                if (entry.dbVendor != null) {
984                    serverObj.put("dbVendor", entry.dbVendor);
985                }
986                serverObj.put("queries", new ArrayList<Map<String, Object>>());
987                serverMap.put(key, serverObj);
988            }
989            @SuppressWarnings("unchecked")
990            List<Map<String, Object>> queries = (List<Map<String, Object>>) serverObj.get("queries");
991            queries.add(entry.query);
992        }
993
994        List<Map<String, Object>> servers = new ArrayList<>(serverMap.values());
995        sqlflow.put("servers", servers);
996
997        writeJsonFile(queryFile, sqlflow);
998        return queryFile;
999    }
1000    
1001    /**
1002     * Resolve path relative to base directory
1003     */
1004    private static File resolvePath(File baseDir, String path) {
1005        if (path == null || path.isEmpty()) {
1006            return null;
1007        }
1008        
1009        File file = new File(path);
1010        if (file.isAbsolute()) {
1011            return file;
1012        }
1013        
1014        return new File(baseDir, path);
1015    }
1016    
1017    /**
1018         * Read source records from a JSONL file.
1019         *
1020         * <p>Supports two compression modes:
1021         * <ul>
1022         *   <li>{@code "block"} — gzip-compressed JSONL, read via GZIPInputStream.</li>
1023         *   <li>Otherwise — plain text JSONL, split by newline.</li>
1024         * </ul>
1025         *
1026         * @param sourceFile  the JSONL source file
1027         * @param compression the compression mode ("block" or null)
1028         * @return list of parsed records, or null if the file cannot be read
1029         */
1030        private static List<Map<?, ?>> readSourceRecords(File sourceFile, String compression) throws IOException {
1031        if (sourceFile == null || !sourceFile.exists()) {
1032            return null;
1033        }
1034        
1035        List<Map<?, ?>> records = new ArrayList<>();
1036        
1037        if ("block".equals(compression)) {
1038            // Read gzip compressed file
1039            try (java.util.zip.GZIPInputStream gzis = new java.util.zip.GZIPInputStream(
1040                    new java.io.FileInputStream(sourceFile))) {
1041                java.io.BufferedReader reader = new java.io.BufferedReader(
1042                        new java.io.InputStreamReader(gzis, "UTF-8"));
1043                String line;
1044                while ((line = reader.readLine()) != null) {
1045                    if (!line.trim().isEmpty()) {
1046                        Map<?, ?> record = (Map<?, ?>) JSON.parseObject(line);
1047                        records.add(record);
1048                    }
1049                }
1050            }
1051        } else {
1052            // Read plain JSONL file
1053            String content = readFileContent(sourceFile);
1054            String[] lines = content.split("\n");
1055            for (String line : lines) {
1056                if (!line.trim().isEmpty()) {
1057                    Map<?, ?> record = (Map<?, ?>) JSON.parseObject(line);
1058                    records.add(record);
1059                }
1060            }
1061        }
1062        
1063        return records;
1064    }
1065    
1066    /**
1067         * Holds metadata for a single catalog shard: server, database, and the
1068         * catalog file to read.
1069         */
1070        private static class DatabaseCatalogInfo {
1071        String serverName;
1072        String dbVendor;
1073        String databaseName;
1074        String shardId;
1075        File file;
1076        
1077        DatabaseCatalogInfo(String serverName, String dbVendor, String databaseName, String shardId, File file) {
1078            this.serverName = serverName;
1079            this.dbVendor = dbVendor;
1080            this.databaseName = databaseName;
1081            this.shardId = shardId;
1082            this.file = file;
1083        }
1084    }
1085    
1086    /**
1087         * Holds metadata for a single source shard: server, database, source file,
1088         * and compression mode.
1089         */
1090        private static class DatabaseSourceInfo {
1091        String serverName;
1092        String dbVendor;
1093        String databaseName;
1094        String shardId;
1095        File file;
1096        String compression;
1097        
1098        DatabaseSourceInfo(String serverName, String dbVendor, String databaseName, String shardId, File file, String compression) {
1099            this.serverName = serverName;
1100            this.dbVendor = dbVendor;
1101            this.databaseName = databaseName;
1102            this.shardId = shardId;
1103            this.file = file;
1104            this.compression = compression;
1105        }
1106    }
1107
1108    /**
1109     * Create query file
1110     */
1111    private static File createQueryFile(File outputDir, File inputFile, long startIndex, long endIndex, String extension) {
1112        String fileName = getFileNameWithoutExtension(inputFile.getName()) + "_" + startIndex + "_" + endIndex + (extension.isEmpty() ? "" : "." + extension);
1113        return new File(outputDir, fileName);
1114    }
1115
1116    private static Long getLastLineFromCurrentPos(RandomAccessFile raf, long currentPos, long fileLength, long splitSizeBytes, EDbVendor vendor) throws IOException {
1117        raf.seek(currentPos);
1118
1119        long remainingBytes = fileLength - currentPos;
1120        long readSize = Math.min(splitSizeBytes, remainingBytes);
1121
1122        byte[] buffer = new byte[(int) readSize];
1123        int bytesRead = raf.read(buffer);
1124        if (bytesRead <= 0) {
1125            return null;
1126        }
1127
1128        String partialContent = new String(buffer, 0, bytesRead, "UTF-8");
1129        if (partialContent.isEmpty()) {
1130            return null;
1131        }
1132
1133        TGSqlParser parser = new TGSqlParser(vendor);
1134        parser.sqltext = partialContent;
1135        parser.getrawsqlstatements();
1136
1137        long lastValidatedLineNo = parser.getLastLineNoOfLastStatementBeenValidated();
1138        return lastValidatedLineNo;
1139    }
1140
1141    /**
1142     * Find end position of Nth line from specified byte position
1143     * Supports multiple newline formats: \n (Unix/Linux), \r\n (Windows), \r (Old Mac)
1144     * Uses buffer for batch reading to improve performance
1145     */
1146    private static long findLineEndPosition(RandomAccessFile raf, long startPos, long lineCount) throws IOException {
1147        raf.seek(startPos);
1148        long newlineCount = 0;
1149        long fileLength = raf.length();
1150
1151        byte[] buffer = new byte[BUFFER_SIZE];
1152        long currentPos = startPos;
1153
1154        while (currentPos < fileLength) {
1155            int toRead = (int) Math.min(buffer.length, fileLength - currentPos);
1156            int bytesRead = raf.read(buffer, 0, toRead);
1157            if (bytesRead <= 0) {
1158                break;
1159            }
1160
1161            for (int i = 0; i < bytesRead; i++) {
1162                byte b = buffer[i];
1163
1164                if (b == '\n') {
1165                    newlineCount++;
1166                    if (newlineCount == lineCount) {
1167                        return currentPos + i + 1;
1168                    }
1169                } else if (b == '\r') {
1170                    if (i + 1 < bytesRead) {
1171                        if (buffer[i + 1] == '\n') {
1172                            newlineCount++;
1173                            if (newlineCount == lineCount) {
1174                                return currentPos + i + 2;
1175                            }
1176                            i++;
1177                        } else {
1178                            newlineCount++;
1179                            if (newlineCount == lineCount) {
1180                                return currentPos + i + 1;
1181                            }
1182                        }
1183                    } else {
1184                        long savedPos = raf.getFilePointer();
1185                        int nextByte = raf.read();
1186
1187                        if (nextByte == '\n') {
1188                            newlineCount++;
1189                            if (newlineCount == lineCount) {
1190                                return currentPos + i + 2;
1191                            }
1192                            currentPos++;
1193                        } else {
1194                            newlineCount++;
1195                            if (newlineCount == lineCount) {
1196                                return currentPos + i + 1;
1197                            }
1198                            if (nextByte != -1) {
1199                                raf.seek(savedPos);
1200                            }
1201                        }
1202                    }
1203                }
1204            }
1205
1206            currentPos += bytesRead;
1207        }
1208
1209        return fileLength;
1210    }
1211
1212    /**
1213     * Split file by byte position
1214     */
1215    private static File splitFileByPosition(File outputDir, File inputFile, long startPos, long endPos, int fileIndex, long startLineNo, long endLineNo) throws IOException {
1216        String fileName = getFileNameWithoutExtension(inputFile.getName()) + "_" + fileIndex + "_" + startLineNo + "_" + endLineNo + "." + getFileExtension(inputFile.getName());
1217        File outputFile = new File(outputDir, fileName);
1218
1219        try (RandomAccessFile rafRead = new RandomAccessFile(inputFile, "r");
1220             BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputFile), java.nio.charset.StandardCharsets.UTF_8))) {
1221
1222            rafRead.seek(startPos);
1223            long bytesToRead = endPos - startPos;
1224            long bytesRemaining = bytesToRead;
1225
1226            byte[] buffer = new byte[BUFFER_SIZE];
1227
1228            while (bytesRemaining > 0) {
1229                int toRead = (int) Math.min(buffer.length, bytesRemaining);
1230                int bytesRead = rafRead.read(buffer, 0, toRead);
1231                if (bytesRead <= 0) {
1232                    break;
1233                }
1234
1235                String chunk = new String(buffer, 0, bytesRead, java.nio.charset.StandardCharsets.UTF_8);
1236                writer.write(chunk);
1237                bytesRemaining -= bytesRead;
1238            }
1239
1240            logger.info("split file " + inputFile.getName() + " (index: " + fileIndex + ", lines: " + startLineNo + "-" + endLineNo + ") from byte " + startPos + " to " + endPos + " to " + outputFile.getName());
1241
1242            return outputFile;
1243        }
1244    }
1245}