feat(v4): consolidate audited data recovery economic replay and optional archival
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Datenarchiv.php';
|
||||
|
||||
/** Application measurement acquisition + acknowledged durable delivery. No device writes. */
|
||||
trait ManagerNetzfahrplanV4DatenTrait
|
||||
@@ -11,9 +12,13 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
private function registriereNetzfahrplanV4Daten(): void
|
||||
{
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4MessdatenAktiv', false);
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4ArchivAktiv', false);
|
||||
$this->RegisterPropertyString('NetzfahrplanV4Messkonfiguration', '{}');
|
||||
$this->RegisterPropertyString('NetzfahrplanV4Datensatz', '');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4DatenCursor', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteDatenquittung', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4ArchivTag', '');
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199);
|
||||
@@ -27,6 +32,12 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
|
||||
try {
|
||||
$this->v4DatenKonfiguration();
|
||||
if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) {
|
||||
// One retry after the data-only upgrade, never a cursor reset or a device write.
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1);
|
||||
}
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000);
|
||||
} catch (\Throwable $e) {
|
||||
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
|
||||
@@ -60,6 +71,8 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
|
||||
private function v4DatenStatus(array $data): void
|
||||
{
|
||||
$receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
|
||||
$data['lastAcknowledgement']=$receipt;
|
||||
$this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'),
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
@@ -78,11 +91,25 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
return ['value'=>GetValue($id),'updated'=>(int)$v['VariableUpdated'],'changed'=>(int)$v['VariableChanged'],
|
||||
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
|
||||
};
|
||||
$r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time());
|
||||
$dir=$this->v4DatenVerzeichnis();
|
||||
NetzfahrplanV4Messaufnahme::append($dir,$r);
|
||||
$result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']),
|
||||
'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false];
|
||||
$archiveResult=null;
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanV4ArchivAktiv') && $this->ReadAttributeString('NetzfahrplanV4ArchivTag')!==gmdate('Y-m-d')) {
|
||||
try {
|
||||
$archiveCursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
|
||||
$archiveResult=NetzfahrplanV4Datenarchiv::run($dir,$archiveCursor,time());
|
||||
if ($archiveResult['archived']<2 && ($archiveResult['status']??null)!=='writer_busy')$this->WriteAttributeString('NetzfahrplanV4ArchivTag',gmdate('Y-m-d'));
|
||||
} catch (\Throwable $e) { $archiveResult=['status'=>'archive_error','errorType'=>get_class($e),'originalsRetained'=>true]; }
|
||||
}
|
||||
try {
|
||||
$r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time());
|
||||
NetzfahrplanV4Messaufnahme::append($dir,$r);
|
||||
$result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']),
|
||||
'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false];
|
||||
} catch (\Throwable $e) {
|
||||
// Storage/capture failure must not prevent retrying the already durable outbox.
|
||||
$result=['status'=>'capture_error','errorType'=>get_class($e),'dataRetained'=>true];
|
||||
}
|
||||
if ($archiveResult!==null)$result['archive']=$archiveResult;
|
||||
$last=$this->ReadAttributeInteger('NetzfahrplanV4DatenVersuch');
|
||||
$failures=min(5,$this->ReadAttributeInteger('NetzfahrplanV4DatenFehlerzahl'));
|
||||
$retry=min(900,60*(2**$failures));
|
||||
@@ -90,10 +117,16 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch',time());
|
||||
try {
|
||||
$result['delivery']=$this->v4DatenUebertragen($dir);
|
||||
if (($result['delivery']['status']??null)==='acknowledged') {
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteDatenquittung',json_encode($result['delivery']+['receivedAt'=>gmdate('c')],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',0);
|
||||
} catch (\Throwable $e) {
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',$failures+1);
|
||||
$result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e),'dataRetained'=>true];
|
||||
$httpCode=(int)$e->getCode();
|
||||
$result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e),
|
||||
'httpStatus'=>$httpCode>=100&&$httpCode<=599?$httpCode:null,'dataRetained'=>true,
|
||||
'nextAttemptAt'=>gmdate('c',time()+min(900,60*(2**min(5,$failures+1))))];
|
||||
}
|
||||
} else $result['delivery']=['status'=>'scheduled','nextAttemptAt'=>gmdate('c',$last+$retry)];
|
||||
$this->v4DatenStatus($result);
|
||||
@@ -147,13 +180,14 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$token],
|
||||
CURLOPT_POSTFIELDS=>$payload]);
|
||||
$answer=curl_exec($h);$code=(int)curl_getinfo($h,CURLINFO_HTTP_CODE);
|
||||
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged');
|
||||
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code);
|
||||
$ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR);
|
||||
if (!in_array($ack['status']??null,['stored','duplicate'],true)
|
||||
||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz')
|
||||
||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch');
|
||||
$this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR));
|
||||
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime];
|
||||
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime,
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz')];
|
||||
} finally { curl_close($h); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
/** Lossless archival of fully acknowledged OLD outbox days. No device/API calls. */
|
||||
final class NetzfahrplanV4Datenarchiv
|
||||
{
|
||||
public static function run(string $directory, array $cursor, int $now): array
|
||||
{
|
||||
$day=$cursor['day']??'';
|
||||
if ($day==='') return ['archived'=>0];
|
||||
if (!is_string($day)||!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)
|
||||
||!is_int($cursor['offset']??null)||$cursor['offset']<0
|
||||
||is_link($directory)||realpath($directory)!==$directory) {
|
||||
throw new \RuntimeException('Invalid archive cursor/directory');
|
||||
}
|
||||
$cutoff='raw-'.gmdate('Ymd',$now-7*86400).'.jsonl';
|
||||
$archive=$directory.'/archive';
|
||||
if (is_link($archive)) throw new \RuntimeException('Archive symlink refused');
|
||||
if (!is_dir($archive)&&!mkdir($archive,0700)) throw new \RuntimeException('Archive directory unavailable');
|
||||
if ((fileperms($archive)&0007)!==0) throw new \RuntimeException('Archive must remain private');
|
||||
$lockPath=$directory.'/.writer.lock';
|
||||
if (is_link($lockPath)) throw new \RuntimeException('Writer lock symlink refused');
|
||||
$lock=fopen($lockPath,'c+b');
|
||||
if ($lock===false) throw new \RuntimeException('Archive lock unavailable');
|
||||
$done=[];
|
||||
try {
|
||||
if (!flock($lock,LOCK_EX|LOCK_NB)) return ['archived'=>0,'status'=>'writer_busy'];
|
||||
foreach (glob($directory.'/raw-*.jsonl')?:[] as $file) {
|
||||
$name=basename($file);
|
||||
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||$name>=$day||$name>=$cutoff) continue;
|
||||
if (is_link($file)||realpath($file)!==$file||!is_file($file)) throw new \RuntimeException('Unexpected journal');
|
||||
$before=stat($file);
|
||||
if (!$before||$before['size']>67108864) throw new \RuntimeException('Archive input bounds');
|
||||
$digest=hash_file('sha256',$file);
|
||||
if (!is_string($digest)) throw new \RuntimeException('Cannot hash journal');
|
||||
$target=$archive.'/'.$name.'.'.$digest.'.gz';
|
||||
if (is_link($target)) throw new \RuntimeException('Archive target symlink');
|
||||
if (!is_file($target)) {
|
||||
$tmp=tempnam($archive,'.pending-');
|
||||
if ($tmp===false) throw new \RuntimeException('Cannot create archive');
|
||||
try {
|
||||
chmod($tmp,0600);$in=fopen($file,'rb');$gz=gzopen($tmp,'wb6');
|
||||
if ($in===false||$gz===false) {if(is_resource($in))fclose($in);if(is_resource($gz))gzclose($gz);throw new \RuntimeException('Cannot open archive streams');}
|
||||
try {
|
||||
while (!feof($in)) {
|
||||
$block=fread($in,65536);
|
||||
if ($block===false) throw new \RuntimeException('Archive read failed');
|
||||
$offset=0;
|
||||
while($offset<strlen($block)) {$n=gzwrite($gz,substr($block,$offset));if($n===false||$n===0)throw new \RuntimeException('Archive write failed');$offset+=$n;}
|
||||
}
|
||||
} finally {fclose($in);gzclose($gz);}
|
||||
$sync=fopen($tmp,'r+b');
|
||||
if ($sync===false) throw new \RuntimeException('Cannot sync archive');
|
||||
try {if(!fsync($sync))throw new \RuntimeException('Archive sync failed');} finally {fclose($sync);}
|
||||
if (!rename($tmp,$target)) throw new \RuntimeException('Archive rename failed');
|
||||
} finally {if(is_file($tmp))unlink($tmp);}
|
||||
}
|
||||
$gz=gzopen($target,'rb');if($gz===false)throw new \RuntimeException('Cannot verify archive');
|
||||
$hash=hash_init('sha256');$bytes=0;
|
||||
try {while(!gzeof($gz)){$block=gzread($gz,65536);if($block===false)throw new \RuntimeException('Archive verification read failed');$bytes+=strlen($block);if($bytes>$before['size'])throw new \RuntimeException('Oversized decompressed archive');hash_update($hash,$block);}}
|
||||
finally {gzclose($gz);}
|
||||
clearstatcache(true,$file);$after=stat($file);
|
||||
if ($bytes!==$before['size']||hash_final($hash)!==$digest||!$after
|
||||
||$after['size']!==$before['size']||$after['ino']!==$before['ino']||$after['mtime']!==$before['mtime']
|
||||
||hash_file('sha256',$file)!==$digest) throw new \RuntimeException('Archive mismatch; original retained');
|
||||
$directoryHandle=fopen($archive,'r');
|
||||
if ($directoryHandle===false) throw new \RuntimeException('Cannot sync archive directory');
|
||||
try {if(!fsync($directoryHandle))throw new \RuntimeException('Archive directory sync failed');} finally {fclose($directoryHandle);}
|
||||
if (!unlink($file)) throw new \RuntimeException('Verified original could not be retired');
|
||||
$done[]=$name;
|
||||
if (count($done)>=2) break;
|
||||
}
|
||||
} finally {flock($lock,LOCK_UN);fclose($lock);}
|
||||
return ['archived'=>count($done),'compressedOriginalsRetained'=>true,'unacknowledgedDaysTouched'=>false];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user