Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions settings/Config.Example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ events_api_url = "https://events.helioviewer.org"
; Timeout in seconds for Events API requests
events_api_timeout = 10

; Number of timestamps per Events API batch request when building a movie.
; Smaller = faster individual requests, more requests total; larger = fewer
; round-trips but slower per request. 50 is a balanced default.
events_api_events_per_frame_chunksize = 50

; Upstream-imposed cap on timestamps per Events API batch request.
; Any chunk size larger than this gets clamped down to this value.
events_api_events_per_frame_max_chunk_size = 150

; Upstream-imposed cap on path-prefix selections per frames_with_selections
; request. Exceeding this raises EventsApiException before any HTTP call.
events_api_events_per_frame_max_selections = 200

[movie_params]
; FFmpeg location
ffmpeg = ffmpeg
Expand Down
9 changes: 7 additions & 2 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ class Config {

private $_bools = array('disable_cache', 'enable_statistics_collection', 'db_events','sentry_enabled');
private $_ints = array('build_num', 'ffmpeg_max_threads',
'max_jpx_frames', 'max_movie_frames');
'max_jpx_frames', 'max_movie_frames',
'events_api_events_per_frame_chunksize',
'events_api_events_per_frame_max_chunk_size',
'events_api_events_per_frame_max_selections');
private $_floats = array('events_api_timeout');
private $config;

Expand Down Expand Up @@ -86,7 +89,9 @@ private function _fixTypes() {

// integers
foreach ($this->_ints as $int) {
$this->config[$int] = (int)$this->config[$int];
if (isset($this->config[$int])) {
$this->config[$int] = (int)$this->config[$int];
}
}

// floats
Expand Down
130 changes: 128 additions & 2 deletions src/Event/Api/EventsApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,32 @@ class EventsApi implements EventsApiInterface {
/** Known event sources */
public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI'];

/** Fallback used when the HV_* config constant is not defined. */
private const DEFAULT_MAX_CHUNK_SIZE = 150;
private const DEFAULT_MAX_SELECTIONS = 200;

/**
* Upstream-imposed cap on timestamps per batch request. Driven by
* HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE in Config.ini.
*/
public static function maxChunkSize(): int
{
return defined('HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE')
? (int) HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE
: self::DEFAULT_MAX_CHUNK_SIZE;
}

/**
* Upstream-imposed cap on selections per frames_with_selections request.
* Driven by HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS in Config.ini.
*/
public static function maxSelections(): int
{
return defined('HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS')
? (int) HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS
: self::DEFAULT_MAX_SELECTIONS;
}

private ClientInterface $client;
private SentryClientInterface $sentry;
private LegacyEventsInterface $legacyEvents;
Expand Down Expand Up @@ -156,7 +182,7 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest
}

/** {@inheritdoc} */
public function getEventsBatch(array $timestamps, array $sources): array
public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array
{
// Only allow known sources
$validSources = self::filterSources($sources);
Expand All @@ -166,9 +192,16 @@ public function getEventsBatch(array $timestamps, array $sources): array
if (empty($timestamps)) {
return [];
}
if ($chunkSize < 1) {
$chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50;
}
$maxChunk = self::maxChunkSize();
if ($chunkSize > $maxChunk) {
$chunkSize = $maxChunk;
}

$sourcesParam = implode('::', $validSources);
$chunks = array_chunk($timestamps, 150);
$chunks = array_chunk($timestamps, $chunkSize);
$url = "/helioviewer/events/{$sourcesParam}/observations";

// Closure to fetch a single chunk of timestamps
Expand All @@ -193,19 +226,112 @@ public function getEventsBatch(array $timestamps, array $sources): array
}
};

$logChunk = function (int $i, int $total, int $size, int $elapsedMs) use ($logLabel) {
if ($logLabel === '') {
return;
}
error_log(sprintf(
"[%s] EventsApi chunk %d/%d (%d timestamps) took %dms",
$logLabel, $i + 1, $total, $size, $elapsedMs
));
};

// First chunk returns full response (event_types + events + observations)
$start = microtime(true);
$merged = $fetchChunk($chunks[0]);
$logChunk(0, count($chunks), count($chunks[0]), (int) round((microtime(true) - $start) * 1000));

// Subsequent chunks only add new observations (event_types and events are the same)
for ($i = 1; $i < count($chunks); $i++) {
$start = microtime(true);
$chunk = $fetchChunk($chunks[$i]);
$logChunk($i, count($chunks), count($chunks[$i]), (int) round((microtime(true) - $start) * 1000));
$merged['observations'] += $chunk['observations'];
}

// Convert deduplicated response to legacy format per timestamp
return $this->legacyEvents->convertAll($merged);
}

/** {@inheritdoc} */
public function getEventsForFramesWithSelections(
array $timestamps,
array $selections,
int $chunkSize = 50,
string $logLabel = ''
): array {
if (empty($selections)) {
throw new EventsApiException("No selections given. At least one path-prefix selection is required.");
}
$maxSelections = self::maxSelections();
if (count($selections) > $maxSelections) {
throw new EventsApiException("Too many selections: " . count($selections) . ". Upstream limit is " . $maxSelections . ".");
}
if (empty($timestamps)) {
return [];
}
if ($chunkSize < 1) {
$chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50;
}
$maxChunk = self::maxChunkSize();
if ($chunkSize > $maxChunk) {
$chunkSize = $maxChunk;
}

$url = "/helioviewer/events/frames_with_selections";
$chunks = array_chunk($timestamps, $chunkSize);

$fetchChunk = function (array $chunkTimestamps) use ($url, $selections) {
$this->sentry->setContext('EventsApi', [
'endpoint' => $url,
'timestamp_count' => count($chunkTimestamps),
'selection_count' => count($selections),
]);

try {
$response = $this->client->request('POST', $url, [
'json' => [
'timestamps' => $chunkTimestamps,
'selections' => $selections,
],
]);
return $this->parseResponse($response);
} catch (\Throwable $e) {
$this->sentry->setContext('EventsApi', [
'error' => $e->getMessage(),
]);
$exception = new EventsApiException("Failed to fetch frames_with_selections: " . $e->getMessage(), 0, $e);
$this->sentry->capture($exception);
throw $exception;
}
};

$logChunk = function (int $i, int $total, int $size, int $elapsedMs) use ($logLabel) {
if ($logLabel === '') {
return;
}
error_log(sprintf(
"[%s] EventsApi frames_with_selections chunk %d/%d (%d timestamps) took %dms",
$logLabel, $i + 1, $total, $size, $elapsedMs
));
};

$start = microtime(true);
$merged = $fetchChunk($chunks[0]);
$logChunk(0, count($chunks), count($chunks[0]), (int) round((microtime(true) - $start) * 1000));

for ($i = 1; $i < count($chunks); $i++) {
$start = microtime(true);
$chunk = $fetchChunk($chunks[$i]);
$logChunk($i, count($chunks), count($chunks[$i]), (int) round((microtime(true) - $start) * 1000));
// events keyed by uuid; timestamps keyed by datetime string. + does first-wins union.
$merged['events'] = ($merged['events'] ?? []) + ($chunk['events'] ?? []);
$merged['timestamps'] = ($merged['timestamps'] ?? []) + ($chunk['timestamps'] ?? []);
}

return $merged;
}

/**
* Parse the HTTP response body as JSON.
* Validates that the response is valid JSON and returns an array.
Expand Down
31 changes: 30 additions & 1 deletion src/Event/Api/EventsApiInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,37 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest
*
* @param string[] $timestamps Array of observation datetime strings
* @param string[] $sources Array of source names (e.g. ['HEK', 'CCMC', 'RHESSI'])
* @param int $chunkSize Max timestamps per upstream POST request
* @param string $logLabel Optional label prepended to per-chunk error_log lines (e.g. "Movie:Xp66n")
* @return array Keyed by timestamp, each value is legacy-format event categories
* @throws EventsApiException on API errors or unexpected responses
*/
public function getEventsBatch(array $timestamps, array $sources): array;
public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array;

/**
* Fetch events for multiple observation timestamps filtered by a list of
* path-prefix selections. Posts to /helioviewer/events/frames_with_selections.
*
* Returns the RAW merged response (no legacy conversion):
* [
* 'events' => [ <uuid> => {path, label, start, end, hv_hpc_x, hv_hpc_y, footprint, type, pin} ],
* 'timestamps' => [ <ts> => { <uuid> => {hv_hpc_x, hv_hpc_y} } ],
* ]
*
* Timestamps are paginated by $chunkSize (capped at the upstream limit of
* 150). Selections are sent verbatim with every request (limit: 200).
*
* @param string[] $timestamps Array of observation datetime strings
* @param string[] $selections Path-prefix strings (e.g. ['HEK>>Flare', 'CCMC>>DONKI>>CME']); max 200
* @param int $chunkSize Max timestamps per upstream POST request (capped at 150)
* @param string $logLabel Optional label prepended to per-chunk error_log lines
* @return array Merged raw response with 'events' and 'timestamps' keys
* @throws EventsApiException on API errors, empty selections, or selections > 200
*/
public function getEventsForFramesWithSelections(
array $timestamps,
array $selections,
int $chunkSize = 50,
string $logLabel = ''
): array;
}
Loading
Loading