Skip to content
Open
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
200 changes: 200 additions & 0 deletions Civi/Financeextras/Common/GCManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

namespace Civi\Financeextras\Common;

/**
* Adaptive Garbage Collection Manager for FinanceExtras Extension
*
* Implements industry best practices for memory management during financial operations
*/
class GCManager {

private static $gcStats = [
'calls' => 0,
'effective_calls' => 0,
'last_effective_call' => 0,
// Start conservative.
'interval' => 1000,
'memory_threshold' => 0,
];

/**
* Whether the GC manager has been initialized.
*
* @var bool
*/
private static $initialized = FALSE;

/**
* Initialize GC manager with safe defaults
*/
public static function init() {
if (!self::$initialized) {
if (function_exists('gc_enable')) {
// Enable GC at start
gc_enable();
}

// Set memory threshold to 75% of memory limit or 200MB minimum
$memoryLimit = self::parseMemoryLimit(ini_get('memory_limit'));
self::$gcStats['memory_threshold'] = max(
$memoryLimit * 0.75,
// 200MB minimum.
200 * 1024 * 1024
);

self::$initialized = TRUE;
}
}

/**
* Intelligently decide whether to collect garbage
*
* @param string $operationType Type of operation for tracking
* @return bool TRUE if GC was performed
*/
public static function maybeCollectGarbage($operationType = 'default') {
self::init();

static $counters = [];
if (!isset($counters[$operationType])) {
$counters[$operationType] = 0;
}

$counters[$operationType]++;

// Multiple trigger conditions
$shouldCollect = FALSE;
$reason = '';

// 1. Batch-complete trigger (after each invoice)
if ($operationType === 'invoice_processing') {
$shouldCollect = TRUE;
$reason = 'batch_complete';
}
// 2. Iteration-count trigger (adaptive) for other operations
elseif ($counters[$operationType] >= self::$gcStats['interval']) {
$shouldCollect = TRUE;
$reason = 'iteration_count';
$counters[$operationType] = 0;
}

// 3. Memory-threshold trigger (always check)
$currentMemory = memory_get_usage(TRUE);
if ($currentMemory > self::$gcStats['memory_threshold']) {
$shouldCollect = TRUE;
$reason = 'memory_threshold';
$counters[$operationType] = 0;
}

if ($shouldCollect && function_exists('gc_collect_cycles')) {
$beforeMemory = memory_get_usage(TRUE);
$startTime = microtime(TRUE);

$cycles = gc_collect_cycles();

// ms.
$duration = (microtime(TRUE) - $startTime) * 1000;
$afterMemory = memory_get_usage(TRUE);
$memoryFreed = $beforeMemory - $afterMemory;

self::$gcStats['calls']++;

if ($cycles > 0) {
self::$gcStats['effective_calls']++;
self::$gcStats['last_effective_call'] = self::$gcStats['calls'];
}

// Adaptive interval adjustment
self::adjustInterval($cycles, $reason);

// Log significant collections only.
// 5MB.
if ($cycles > 0 || $memoryFreed > (5 * 1024 * 1024)) {
\Civi::log()->debug(sprintf(
'FinanceExtras GC (%s): %d cycles, %.2fms, %s freed, memory: %s, interval: %d',
$reason,
$cycles,
$duration,
self::formatBytes($memoryFreed),
self::formatBytes($afterMemory),
self::$gcStats['interval']
));
}

return TRUE;
}

return FALSE;
}

/**
* Adjust GC interval based on effectiveness
*/
private static function adjustInterval($cycles, $reason) {
$callsSinceEffective = self::$gcStats['calls'] - self::$gcStats['last_effective_call'];

// If GC returned 0 repeatedly, increase interval (less frequent calls)
if ($cycles === 0 && $callsSinceEffective >= 3) {
self::$gcStats['interval'] = min(self::$gcStats['interval'] * 1.5, 5000);
}

// If GC was effective, maintain or slightly reduce interval
if ($cycles > 0) {
// Many cycles collected - memory pressure exists.
if ($cycles > 10) {
self::$gcStats['interval'] = max(self::$gcStats['interval'] * 0.8, 500);
}
}
}

/**
* Parse PHP memory limit string to bytes
*/
private static function parseMemoryLimit($limit) {
if ($limit === '-1') {
return PHP_INT_MAX;
}

$value = (int) $limit;
$unit = strtolower(substr($limit, -1));

switch ($unit) {
case 'g':
return $value * 1024 * 1024 * 1024;

case 'm':
return $value * 1024 * 1024;

case 'k':
return $value * 1024;

default:
return $value;
}
}

/**
* Format bytes for human-readable logging
*/
private static function formatBytes($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
}
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
}
return $bytes . ' B';
}

/**
* Get GC statistics for monitoring
*/
public static function getStats() {
return self::$gcStats;
}

}
147 changes: 129 additions & 18 deletions Civi/Financeextras/Hook/AlterMailParams/InvoiceTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Civi\WorkflowMessage\WorkflowMessage;
use CRM_Financeextras_CustomGroup_ContributionOwnerOrganisation as ContributionOwnerOrganisation;
use Civi\Financeextras\Common\GCManager;

/**
* Provides separate invoicing template and tokens for each
Expand All @@ -17,35 +18,71 @@ class InvoiceTemplate {

private $contributionOwnerCompany;

private static $processedInvoices = 0;
private static $contributionCache = [];
private static $contributionCacheOrder = [];
private static $ownerCompanyCache = [];
private static $ownerCompanyCacheOrder = [];
private static $locationCache = [];
private static $locationCacheOrder = [];
private static $maxCacheSize = 100;

public function __construct(&$templateParams, $context) {
$this->templateParams = &$templateParams;
$this->contributionId = $templateParams['tplParams']['id'];
}

public function handle() {
$this->addTaxConversionTable();
self::$processedInvoices++;

$this->contributionOwnerCompany = ContributionOwnerOrganisation::getOwnerOrganisationCompany($this->contributionId);
if (empty($this->contributionOwnerCompany)) {
return;
}
try {
$this->addTaxConversionTable();

// Get owner company from cache or fetch
$this->contributionOwnerCompany = $this->getOwnerCompanyFromCache($this->contributionId);
if (!$this->contributionOwnerCompany) {
$this->contributionOwnerCompany = ContributionOwnerOrganisation::getOwnerOrganisationCompany($this->contributionId);
// Cache using LRU
$this->addToLRUCache(self::$ownerCompanyCache, self::$ownerCompanyCacheOrder, $this->contributionId, $this->contributionOwnerCompany);
}

if (empty($this->contributionOwnerCompany)) {
return;
}

$this->useContributionOwnerOrganisationInvoiceTemplate();
$this->replaceDomainTokensWithOwnerOrganisationTokens();

$this->useContributionOwnerOrganisationInvoiceTemplate();
$this->replaceDomainTokensWithOwnerOrganisationTokens();
// Adaptive memory management: Batch-complete trigger after each invoice
// Uses conservative approach with memory-threshold backup
GCManager::maybeCollectGarbage('invoice_processing');
} catch (Exception $e) {
// Log error and continue processing other invoices
\Civi::log()->error('InvoiceTemplate processing failed for contribution ' . $this->contributionId . ': ' . $e->getMessage());
throw $e;
}
}

private function addTaxConversionTable() {
$showTaxConversionTable = TRUE;
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addSelect(
'financeextras_currency_exchange_rates.rate_1_unit_tax_currency',
'financeextras_currency_exchange_rates.rate_1_unit_contribution_currency',
'financeextras_currency_exchange_rates.sales_tax_currency',
'financeextras_currency_exchange_rates.vat_text'
)->setLimit(1)
->addWhere('id', '=', $this->contributionId)
->execute()
->first();

// Check LRU cache first
$contribution = $this->getContributionFromCache($this->contributionId);
if (!$contribution) {
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addSelect(
'financeextras_currency_exchange_rates.rate_1_unit_tax_currency',
'financeextras_currency_exchange_rates.rate_1_unit_contribution_currency',
'financeextras_currency_exchange_rates.sales_tax_currency',
'financeextras_currency_exchange_rates.vat_text'
)->setLimit(1)
->addWhere('id', '=', $this->contributionId)
->execute()
->first();

// Cache the result using LRU
$this->addToLRUCache(self::$contributionCache, self::$contributionCacheOrder, $this->contributionId, $contribution);
}
if (empty($contribution['financeextras_currency_exchange_rates.rate_1_unit_tax_currency'])) {
$showTaxConversionTable = FALSE;
}
Expand Down Expand Up @@ -137,7 +174,14 @@ private function replaceDomainTokensWithOwnerOrganisationTokens() {
*/
private function getOwnerOrganisationLocation() {
$ownerOrganisationId = $this->contributionOwnerCompany['contact_id'];
$locationDefaults = \CRM_Core_BAO_Location::getValues(['contact_id' => $ownerOrganisationId]);

// Check LRU cache first
$locationDefaults = $this->getLocationFromCache($ownerOrganisationId);
if (!$locationDefaults) {
$locationDefaults = \CRM_Core_BAO_Location::getValues(['contact_id' => $ownerOrganisationId]);
// Cache using LRU
$this->addToLRUCache(self::$locationCache, self::$locationCacheOrder, $ownerOrganisationId, $locationDefaults);
}

if (!empty($locationDefaults['address'][1]['state_province_id'])) {
$locationDefaults['address'][1]['state_province_abbreviation'] = \CRM_Core_PseudoConstant::stateProvinceAbbreviation($locationDefaults['address'][1]['state_province_id']);
Expand All @@ -156,4 +200,71 @@ private function getOwnerOrganisationLocation() {
return $locationDefaults;
}

/**
* Gets contribution data from LRU cache.
*/
private function getContributionFromCache($contributionId) {
if (isset(self::$contributionCache[$contributionId])) {
$this->updateLRUOrder(self::$contributionCacheOrder, $contributionId);
return self::$contributionCache[$contributionId];
}
return FALSE;
}

/**
* Gets owner company data from LRU cache.
*/
private function getOwnerCompanyFromCache($contributionId) {
if (isset(self::$ownerCompanyCache[$contributionId])) {
$this->updateLRUOrder(self::$ownerCompanyCacheOrder, $contributionId);
return self::$ownerCompanyCache[$contributionId];
}
return FALSE;
}

/**
* Gets location data from LRU cache.
*/
private function getLocationFromCache($contactId) {
if (isset(self::$locationCache[$contactId])) {
$this->updateLRUOrder(self::$locationCacheOrder, $contactId);
return self::$locationCache[$contactId];
}
return FALSE;
}

/**
* Updates LRU order by moving item to end (most recently used).
*/
private function updateLRUOrder(&$orderArray, $key) {
$index = array_search($key, $orderArray);
if ($index !== FALSE) {
unset($orderArray[$index]);
$orderArray = array_values($orderArray); // Re-index array
}
$orderArray[] = $key;
}

/**
* Adds item to LRU cache, evicting least recently used if at capacity.
*/
private function addToLRUCache(&$cache, &$orderArray, $key, $value) {
// If already exists, update value and move to end
if (isset($cache[$key])) {
$cache[$key] = $value;
$this->updateLRUOrder($orderArray, $key);
return;
}

// If at capacity, remove least recently used item
if (count($cache) >= self::$maxCacheSize) {
$lruKey = array_shift($orderArray);
unset($cache[$lruKey]);
}

// Add new item
$cache[$key] = $value;
$orderArray[] = $key;
}

}
Loading
Loading