unit DataStreamer; interface uses System.SysUtils, System.Classes, System.Generics.Collections, System.IOUtils, System.Threading, System.Zip; type IDataPage = interface ['{B3E3C5B7-7C1A-4A8B-8B7E-15C9D9E0F4A6}'] function GetStream: TStream; function GetSourceIdentifier: string; function GetLastModificationTime: TDateTime; end; IRecordStreamReader = interface ['{C1B7E8F0-5A0A-4F3B-BD28-18D5E5A3F7A0}'] function GetCurrentRecord: T; function MoveToNextRecord: Boolean; procedure Reset; function GetCurrentRecordIndex: Int64; function Seek(RecordIndex: Int64): Int64; property CurrentRecord: T read GetCurrentRecord; property CurrentRecordIndex: Int64 read GetCurrentRecordIndex; end; TLocalFilePage = class(TInterfacedObject, IDataPage) private FActualFilePath: string; FMemoryStream: TMemoryStream; FLastModifiedOnDisk: TDateTime; function GetStream: TStream; function GetSourceIdentifier: string; function GetLastModificationTime: TDateTime; public constructor Create(const AActualFilePath: string; ADataStream: TMemoryStream; ALastModifiedTimeOnDisk: TDateTime); destructor Destroy; override; class function IsFileModified(const AFileName: string; KnownLastModified: TDateTime): Boolean; end; TStreamingFileRecordReader = class(TInterfacedObject, IRecordStreamReader) private FDirectoryPath: string; FDataSetName: string; FExtension: string; FFilePaths: TStringList; FCachedPages: TObjectDictionary; FPageCacheQueue: TQueue; FMaxCacheSize: Integer; FPreloadPageTask: ITask; FLockObject: TObject; FCurrentPage: IDataPage; FCurrentPageStream: TStream; FCurrentPageIndexInFileList: Integer; FRecordSize: Integer; FCurrentRecord: T; FRecordsInCurrentPage: Int64; FRecordsReadFromCurrentPage: Int64; FGlobalRecordIndex: Int64; procedure ScanDirectoryAndMatchFiles; function LoadPage(ActualFilePath: string): IDataPage; procedure EnsurePageInCache(ActualFilePath: string; IsPreload: Boolean = False); procedure EvictOldestPage; procedure DoPreloadWork(const APathToPreload: string); procedure StartPreloadNextPage; procedure InvalidatePageIfModified(PageToCheck: IDataPage; IsCurrentPage: Boolean); function ActivatePage(PageIndexInFileList: Integer): Boolean; function GetRecordCountForFile(ActualFilePath: string): Int64; function GetCurrentRecord: T; function MoveToNextRecord: Boolean; procedure Reset; function GetCurrentRecordIndex: Int64; function Seek(RecordIndex: Int64): Int64; public constructor Create(const ADirectoryPath: string; const ADataSetName: string; const AExtension: string; AMaxCacheSize: Integer = 2); destructor Destroy; override; end; implementation uses System.DateUtils, System.Types, System.StrUtils, System.Math; { TLocalFilePage } constructor TLocalFilePage.Create(const AActualFilePath: string; ADataStream: TMemoryStream; ALastModifiedTimeOnDisk: TDateTime); begin inherited Create; FActualFilePath := AActualFilePath; FMemoryStream := ADataStream; FMemoryStream.Position := 0; FLastModifiedOnDisk := ALastModifiedTimeOnDisk; end; destructor TLocalFilePage.Destroy; begin FMemoryStream.Free; inherited Destroy; end; function TLocalFilePage.GetStream: TStream; begin FMemoryStream.Position := 0; result := FMemoryStream; end; function TLocalFilePage.GetSourceIdentifier: string; begin result := FActualFilePath; end; function TLocalFilePage.GetLastModificationTime: TDateTime; begin result := FLastModifiedOnDisk; end; class function TLocalFilePage.IsFileModified(const AFileName: string; KnownLastModified: TDateTime): Boolean; var currentModTime: TDateTime; begin if TFile.Exists(AFileName) then begin currentModTime := TFile.GetLastWriteTime(AFileName); result := Abs(MilliSecondsBetween(currentModTime, KnownLastModified)) > 1000; end else begin result := True; end; end; { TStreamingFileRecordReader } constructor TStreamingFileRecordReader.Create(const ADirectoryPath: string; const ADataSetName: string; const AExtension: string; AMaxCacheSize: Integer); begin inherited Create; FLockObject := TObject.Create; if not TDirectory.Exists(ADirectoryPath) then raise EDirectoryNotFoundException.Create('Directory not found: ' + ADirectoryPath); if Trim(ADataSetName) = '' then raise EArgumentException.Create('DataSetName cannot be empty.'); if Trim(AExtension) = '' then raise EArgumentException.Create('Extension cannot be empty.'); FDirectoryPath := IncludeTrailingPathDelimiter(ADirectoryPath); FDataSetName := ADataSetName; FExtension := AExtension; if StartsText('.', FExtension) then FExtension := Copy(FExtension, 2, Length(FExtension) -1); FFilePaths := TStringList.Create; FFilePaths.Sorted := True; FCachedPages := TObjectDictionary.Create([doOwnsValues]); FPageCacheQueue := TQueue.Create; FMaxCacheSize := System.Math.Max(2, AMaxCacheSize); FRecordSize := SizeOf(T); if FRecordSize = 0 then raise EArgumentException.Create('Record size for type T cannot be zero.'); FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FRecordsReadFromCurrentPage := 0; FRecordsInCurrentPage := 0; FCurrentPage := nil; FCurrentPageStream := nil; ScanDirectoryAndMatchFiles; Reset; end; destructor TStreamingFileRecordReader.Destroy; var taskToWaitFor: ITask; begin taskToWaitFor := nil; TMonitor.Enter(FLockObject); try taskToWaitFor := FPreloadPageTask; FPreloadPageTask := nil; finally TMonitor.Exit(FLockObject); end; if (taskToWaitFor <> nil) and (taskToWaitFor.Status in [TTaskStatus.Created, TTaskStatus.WaitingToRun, TTaskStatus.Running]) then begin // Consider taskToWaitFor.Cancel; taskToWaitFor.Wait; end; FFilePaths.Free; FCachedPages.Free; FPageCacheQueue.Free; FLockObject.Free; inherited Destroy; end; procedure TStreamingFileRecordReader.ScanDirectoryAndMatchFiles; var allFiles: TStringDynArray; fileNameOnly, filePath: string; expectedPrefix, baseExtensionPattern, zipExtensionPattern: string; begin TMonitor.Enter(FLockObject); try FFilePaths.Clear; allFiles := TDirectory.GetFiles(FDirectoryPath); expectedPrefix := FDataSetName + '_'; baseExtensionPattern := '.' + FExtension; zipExtensionPattern := baseExtensionPattern + '_zip'; for filePath in allFiles do begin fileNameOnly := ExtractFileName(filePath); if StartsText(expectedPrefix, fileNameOnly) then begin if EndsText(zipExtensionPattern, fileNameOnly.ToLower) or EndsText(baseExtensionPattern, fileNameOnly.ToLower) then begin FFilePaths.Add(filePath); end; end; end; finally TMonitor.Exit(FLockObject); end; end; function TStreamingFileRecordReader.LoadPage(ActualFilePath: string): IDataPage; var dataStream: TMemoryStream; fileLastModified: TDateTime; zipFile: TZipFile; isCompressed: Boolean; fileExtensionOnDisk: string; decompressionStream: TStream; localHeader: TZipHeader; fileNameInZip: string; begin dataStream := TMemoryStream.Create; try fileLastModified := TFile.GetLastWriteTime(ActualFilePath); fileExtensionOnDisk := ExtractFileExt(ActualFilePath); isCompressed := EndsText('_zip', fileExtensionOnDisk.ToLower); if isCompressed then begin zipFile := TZipFile.Create; try zipFile.Open(ActualFilePath, zmRead); if zipFile.FileCount > 0 then begin fileNameInZip := zipFile.FileNames[0]; zipFile.Read(fileNameInZip, decompressionStream, localHeader); try dataStream.CopyFrom(decompressionStream, 0); finally decompressionStream.Free; end; end else raise Exception.Create('Compressed file is empty: ' + ActualFilePath); finally zipFile.Free; end; end else dataStream.LoadFromFile(ActualFilePath); dataStream.Position := 0; result := TLocalFilePage.Create(ActualFilePath, dataStream, fileLastModified); except dataStream.Free; raise; end; end; procedure TStreamingFileRecordReader.EnsurePageInCache(ActualFilePath: string; IsPreload: Boolean); var page: IDataPage; newlyLoadedPage: IDataPage; begin // Assumes FLockObject is held by the caller if not FCachedPages.TryGetValue(ActualFilePath, page) then begin if FCachedPages.Count >= FMaxCacheSize then EvictOldestPage; newlyLoadedPage := LoadPage(ActualFilePath); FCachedPages.Add(ActualFilePath, newlyLoadedPage); FPageCacheQueue.Enqueue(ActualFilePath); end else begin if not IsPreload then InvalidatePageIfModified(page, page = FCurrentPage); end; end; procedure TStreamingFileRecordReader.EvictOldestPage; var oldestFilePath: string; begin // Assumes FLockObject is held by the caller if FPageCacheQueue.Count > 0 then begin oldestFilePath := FPageCacheQueue.Dequeue; if FCachedPages.ContainsKey(oldestFilePath) then FCachedPages.Remove(oldestFilePath); end; end; procedure TStreamingFileRecordReader.InvalidatePageIfModified(PageToCheck: IDataPage; IsCurrentPage: Boolean); var actualFilePath: string; reloadedPage: IDataPage; begin // Assumes FLockObject is held by the caller if PageToCheck = nil then exit; actualFilePath := PageToCheck.GetSourceIdentifier; if TLocalFilePage.IsFileModified(actualFilePath, PageToCheck.GetLastModificationTime) then begin FCachedPages.Remove(actualFilePath); if IsCurrentPage then begin FCurrentPage := nil; FCurrentPageStream := nil; reloadedPage := LoadPage(actualFilePath); FCachedPages.Add(actualFilePath, reloadedPage); FCurrentPage := reloadedPage; FCurrentPageStream := FCurrentPage.GetStream; if (FCurrentPageStream <> nil) and (FCurrentPageStream.Size >= FRecordSize) then FRecordsInCurrentPage := FCurrentPageStream.Size div FRecordSize else FRecordsInCurrentPage := 0; if (FRecordsReadFromCurrentPage * FRecordSize < FCurrentPageStream.Size) and (FRecordsReadFromCurrentPage <= FRecordsInCurrentPage) then FCurrentPageStream.Position := FRecordsReadFromCurrentPage * FRecordSize else begin FCurrentPageStream.Position := 0; FRecordsReadFromCurrentPage := 0; end; end; end; end; procedure TStreamingFileRecordReader.DoPreloadWork(const APathToPreload: string); begin try TMonitor.Enter(FLockObject); try if not FCachedPages.ContainsKey(APathToPreload) then EnsurePageInCache(APathToPreload, True); finally TMonitor.Exit(FLockObject); end; except on E: Exception do begin // Optional: Log error E.Message end; end; end; procedure TStreamingFileRecordReader.StartPreloadNextPage; var nextPageIndexToLoad: Integer; nextFilePath: string; currentTask: ITask; begin TMonitor.Enter(FLockObject); try currentTask := FPreloadPageTask; if (currentTask <> nil) and (currentTask.Status in [TTaskStatus.Created, TTaskStatus.WaitingToRun, TTaskStatus.Running]) then exit; nextPageIndexToLoad := FCurrentPageIndexInFileList + 1; if nextPageIndexToLoad < FFilePaths.Count then begin nextFilePath := FFilePaths[nextPageIndexToLoad]; if FCachedPages.ContainsKey(nextFilePath) then exit; FPreloadPageTask := TTask.Run( procedure begin DoPreloadWork(nextFilePath); end ); end; finally TMonitor.Exit(FLockObject); end; end; function TStreamingFileRecordReader.ActivatePage(PageIndexInFileList: Integer): Boolean; var actualFilePath: string; pageFromCache: IDataPage; begin result := False; if (PageIndexInFileList < 0) or (PageIndexInFileList >= FFilePaths.Count) then begin FCurrentPage := nil; FCurrentPageStream := nil; exit; end; actualFilePath := FFilePaths[PageIndexInFileList]; TMonitor.Enter(FLockObject); try EnsurePageInCache(actualFilePath, False); if not FCachedPages.TryGetValue(actualFilePath, pageFromCache) then begin FCurrentPage := nil; FCurrentPageStream := nil; exit; end; FCurrentPage := pageFromCache; FCurrentPageStream := FCurrentPage.GetStream; FCurrentPageIndexInFileList := PageIndexInFileList; FRecordsReadFromCurrentPage := 0; if (FCurrentPageStream <> nil) and (FCurrentPageStream.Size >= FRecordSize) then FRecordsInCurrentPage := FCurrentPageStream.Size div FRecordSize else FRecordsInCurrentPage := 0; if FRecordsInCurrentPage > 0 then begin result := True; StartPreloadNextPage; end; finally TMonitor.Exit(FLockObject); end; end; function TStreamingFileRecordReader.GetRecordCountForFile(ActualFilePath: string): Int64; var page: IDataPage; stream: TStream; begin result := 0; EnsurePageInCache(ActualFilePath, False); // Assumes FLockObject is held by caller if FCachedPages.TryGetValue(ActualFilePath, page) then begin stream := page.GetStream; if (stream <> nil) and (stream.Size >= FRecordSize) then result := stream.Size div FRecordSize; end; end; function TStreamingFileRecordReader.GetCurrentRecord: T; begin if FGlobalRecordIndex < 0 then Result := System.Default(T) else Result := FCurrentRecord; end; function TStreamingFileRecordReader.GetCurrentRecordIndex: Int64; begin result := FGlobalRecordIndex; end; function TStreamingFileRecordReader.MoveToNextRecord: Boolean; var bytesRead: NativeInt; // ReadData returns NativeInt attemptedNextPageActivation: Boolean; begin result := False; attemptedNextPageActivation := False; TMonitor.Enter(FLockObject); try while True do begin if FCurrentPageStream = nil then begin if attemptedNextPageActivation or (FCurrentPageIndexInFileList >= FFilePaths.Count - 1) then begin FGlobalRecordIndex := -1; exit; end; if not ActivatePage(FCurrentPageIndexInFileList + 1) then begin FGlobalRecordIndex := -1; exit; end; attemptedNextPageActivation := True; end; if FRecordsReadFromCurrentPage >= FRecordsInCurrentPage then begin if attemptedNextPageActivation or (FCurrentPageIndexInFileList >= FFilePaths.Count - 1) then begin FGlobalRecordIndex := -1; exit; end; FCurrentPageStream := nil; if not ActivatePage(FCurrentPageIndexInFileList + 1) then begin FGlobalRecordIndex := -1; exit; end; attemptedNextPageActivation := True; continue; end; FCurrentPageStream.Position := FRecordsReadFromCurrentPage * FRecordSize; if (FCurrentPageStream.Size - FCurrentPageStream.Position) < FRecordSize then begin FRecordsReadFromCurrentPage := FRecordsInCurrentPage; attemptedNextPageActivation := False; FCurrentPageStream := nil; continue; end; // Verwende die generische TStream.ReadData Methode bytesRead := FCurrentPageStream.ReadData(FCurrentRecord); // User line 518 context if bytesRead = FRecordSize then // FRecordSize is SizeOf(T) begin Inc(FRecordsReadFromCurrentPage); if FGlobalRecordIndex < 0 then FGlobalRecordIndex := 0 else Inc(FGlobalRecordIndex); // << User line 518 (E2010), hopefully fixed by ReadData result := True; exit; end else begin FRecordsReadFromCurrentPage := FRecordsInCurrentPage; attemptedNextPageActivation := False; FCurrentPageStream := nil; continue; end; end; finally TMonitor.Exit(FLockObject); end; end; procedure TStreamingFileRecordReader.Reset; var taskToCheck: ITask; begin TMonitor.Enter(FLockObject); try taskToCheck := FPreloadPageTask; if (taskToCheck <> nil) and (taskToCheck.Status in [TTaskStatus.Created, TTaskStatus.WaitingToRun, TTaskStatus.Running]) then begin // Cancellation logic end; FPreloadPageTask := nil; FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FRecordsReadFromCurrentPage := 0; FRecordsInCurrentPage := 0; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); if FFilePaths.Count > 0 then if not ActivatePage(0) then; finally TMonitor.Exit(FLockObject); end; end; function TStreamingFileRecordReader.Seek(RecordIndex: Int64): Int64; var pageIdx: Integer; cumulativeRecordOffset: Int64; recordsInCurrentFile: Int64; recordOffsetInPage: Int64; effectiveTargetIdx: Int64; i: Integer; sumRecordsBeforeThisPage: Int64; bytesRead: NativeInt; // For ReadData begin TMonitor.Enter(FLockObject); try if FFilePaths.Count = 0 then begin FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; effectiveTargetIdx := RecordIndex; if effectiveTargetIdx < 0 then effectiveTargetIdx := 0; cumulativeRecordOffset := 0; for pageIdx := 0 to FFilePaths.Count - 1 do begin recordsInCurrentFile := GetRecordCountForFile(FFilePaths[pageIdx]); if effectiveTargetIdx < cumulativeRecordOffset + recordsInCurrentFile then begin if not ActivatePage(pageIdx) then begin FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; recordOffsetInPage := effectiveTargetIdx - cumulativeRecordOffset; FCurrentPageStream.Position := recordOffsetInPage * FRecordSize; // Verwende die generische TStream.ReadData Methode bytesRead := FCurrentPageStream.ReadData(FCurrentRecord); if bytesRead = FRecordSize then begin FGlobalRecordIndex := effectiveTargetIdx; FRecordsReadFromCurrentPage := recordOffsetInPage + 1; result := FGlobalRecordIndex; exit; end else begin // User line ~604 context (E2008) FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; end; inc(cumulativeRecordOffset, recordsInCurrentFile); end; if cumulativeRecordOffset = 0 then // User line ~616 context (E2029) for assignment after this block begin FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; for pageIdx := FFilePaths.Count - 1 downto 0 do begin recordsInCurrentFile := GetRecordCountForFile(FFilePaths[pageIdx]); if recordsInCurrentFile > 0 then begin if not ActivatePage(pageIdx) then begin FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; recordOffsetInPage := recordsInCurrentFile - 1; FCurrentPageStream.Position := recordOffsetInPage * FRecordSize; // Verwende die generische TStream.ReadData Methode bytesRead := FCurrentPageStream.ReadData(FCurrentRecord); if bytesRead = FRecordSize then begin sumRecordsBeforeThisPage := 0; for i := 0 to pageIdx - 1 do sumRecordsBeforeThisPage := sumRecordsBeforeThisPage + GetRecordCountForFile(FFilePaths[i]); FGlobalRecordIndex := sumRecordsBeforeThisPage + recordOffsetInPage; // User line 616 context (E2029/E2014) FRecordsReadFromCurrentPage := recordOffsetInPage + 1; result := FGlobalRecordIndex; exit; end else begin // User line ~639 context (E2008) FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; end; end; end; FCurrentPageIndexInFileList := -1; FGlobalRecordIndex := -1; FCurrentPage := nil; FCurrentPageStream := nil; FCurrentRecord := System.Default(T); result := -1; exit; finally TMonitor.Exit(FLockObject); end; end; end.