User Story
As a site administrator running a large GiveWP install (custom tables enabled, many
donation forms), I want form-statistic recalculations to update the existing meta row
in place so that wp_give_formmeta does not accumulate duplicate rows and the Donation
Forms admin list stays fast.
Details
On a production site (GiveWP 4.15.4, custom tables fully migrated) the Donation Forms
admin list became extremely slow and eventually unusable. The single slow query was
ListDonationForms's attachMeta query, which LEFT JOINs wp_give_formmeta once per
meta key (settings, fields, formEarnings, formSales, donationLevels, …) under
SELECT DISTINCT.
Root cause: many forms had hundreds of duplicate rows per (form_id, meta_key) for the
recalculated stat keys:
_give_form_earnings
_give_form_sales
_give_form_goal_progress
(e.g. one form had _give_form_earnings ×221.) With duplicates on several joined keys, the
multi-join becomes a Cartesian product (221 × 221 × …) that DISTINCT must then dedupe,
taking minutes.
We reproduced the duplication and isolated the mechanism:
- For a form whose stat keys are present in
wp_postmeta (older/legacy forms), a recalc
updates in place — no duplicate.
- For a form whose stat keys exist only in
wp_give_formmeta (not mirrored in
wp_postmeta), every recalc INSERTs a new give_formmeta row — duplicates grow
without bound.
This is consistent with GiveWP's meta routing redirecting the write to
give_formmeta, while the "does this meta already exist?" existence check that decides
UPDATE-vs-INSERT is resolved against wp_postmeta. After the custom-tables migration moved
form meta into give_formmeta, ~104 of 109 forms on our site had no wp_postmeta
mirror, so virtually every form accumulated duplicates over time. We confirmed the
determinant empirically: directly inserting a single wp_postmeta row for the key makes the
recalc update the give_formmeta row in place (count stays 1) and the value still updates
correctly (read back via give_get_meta()).
Triggers we confirmed re-create a duplicate row:
give_update_goal_progress( $form_id )
give_recount_form_income_donation( $form_id )
- the daily
give_daily_scheduled_events cron
- donation completion (the earnings/sales/goal recalc path)
Expected Behavior
A stat recalc should update the existing give_formmeta row in place, regardless of
whether the key is also present in wp_postmeta. There should be exactly one row per
(form_id, meta_key) for these singular cached stats; recalcs must never INSERT a second
row.
Steps to Reproduce
- Use a site with custom tables migrated, and a donation form whose
_give_form_goal_progress (or _give_form_earnings/_give_form_sales) exists in
wp_give_formmeta but not in wp_postmeta.
- Run a recalc, e.g.:
give_update_goal_progress( $form_id );
give_recount_form_income_donation( $form_id );
(or complete a donation to that form, or let the daily cron run.)
- Inspect the table:
SELECT meta_id, meta_value
FROM wp_give_formmeta
WHERE form_id = <FORM_ID> AND meta_key = '_give_form_goal_progress';
A new row is added on each recalc (count grows 1 → 2 → 3 …), all with the same value.
- With the duplicates present, open GiveWP → Donation Forms. The
ListDonationForms
query slows dramatically (on our data: ~277 s) or white-screens.
Control: insert one matching row into wp_postmeta for the same (post_id, meta_key),
repeat step 2 — the give_formmeta count now stays at 1.
Visuals
Row count for one form before/after three recalcs (no postmeta mirror):
_give_form_earnings: 1 → 2 → 3 → 4
_give_form_sales: 1 → 2 → 3 → 4
_give_form_goal_progress: 1 → 2 → 3 → 4 (all rows identical value)
Additional Context
Related robustness issues we hit in the same area (separate, but same admin flow):
Campaign::defaultForm() calls
DonationFormsRepository::getById( int $donationFormId ) with $this->defaultFormId,
which can be null for a campaign whose form_id is 0/NULL → TypeError fatals the
campaign/Forms admin. A null-guard returning null would degrade gracefully.
DonationFormQueryData::fromObject() sets $self->campaignId (typed int) from
Campaign::findByFormId( $id )->id; when findByFormId() returns null (a v3 form whose
goalSource is campaign but with no give_campaign_forms relation), assigning null to
the typed int property fatals the Donation Forms list.
What we did (workaround on our site)
- De-duplicated
give_formmeta, keeping the newest row per (form_id, meta_key).
- Added a composite index
(form_id, meta_key) on give_formmeta (the attachMeta
self-joins were also unindexed) → Donation Forms list 277 s → ~0.1 s.
- Stopped the recurrence by inserting one sentinel
wp_postmeta row per
(form_id, stat_key) for forms missing it, so GiveWP's update path takes the UPDATE
branch. Verified the stat value still updates correctly in give_formmeta.
- Added a small daily job to mirror new forms and collapse any stragglers.
This is a workaround, not a fix — the underlying existence-check/write-table mismatch is in
GiveWP core. A proper fix would resolve the existence check against the same custom table
the write targets, plus a migration to collapse existing duplicates (the v3.3.0
donation-forms-remove-duplicate-meta migration only covers _give_form_earnings /
_give_form_sales, not _give_form_goal_progress, and does not prevent recurrence).
System Information
Details
- GiveWP: 4.15.4 (custom tables migrated;
wp_give_migrations 70/70 success)
- Add-ons: Give Mollie, Give Form Field Manager, Give PDF Receipts
- WordPress: 6.x (multisite: no), theme: BigHearts + Elementor
- PHP: 8.2 · MySQL: 8.x
- Scale: ~108,000 donations;
wp_give_donationmeta ~2.8M rows; 109 donation forms
- Hosting: SiteGround
Acceptance Criteria
User Story
As a site administrator running a large GiveWP install (custom tables enabled, many
donation forms), I want form-statistic recalculations to update the existing meta row
in place so that
wp_give_formmetadoes not accumulate duplicate rows and the DonationForms admin list stays fast.
Details
On a production site (GiveWP 4.15.4, custom tables fully migrated) the Donation Forms
admin list became extremely slow and eventually unusable. The single slow query was
ListDonationForms'sattachMetaquery, whichLEFT JOINswp_give_formmetaonce permeta key (
settings,fields,formEarnings,formSales,donationLevels, …) underSELECT DISTINCT.Root cause: many forms had hundreds of duplicate rows per
(form_id, meta_key)for therecalculated stat keys:
_give_form_earnings_give_form_sales_give_form_goal_progress(e.g. one form had
_give_form_earnings×221.) With duplicates on several joined keys, themulti-join becomes a Cartesian product (221 × 221 × …) that
DISTINCTmust then dedupe,taking minutes.
We reproduced the duplication and isolated the mechanism:
wp_postmeta(older/legacy forms), a recalcupdates in place — no duplicate.
wp_give_formmeta(not mirrored inwp_postmeta), every recalc INSERTs a newgive_formmetarow — duplicates growwithout bound.
This is consistent with GiveWP's meta routing redirecting the write to
give_formmeta, while the "does this meta already exist?" existence check that decidesUPDATE-vs-INSERT is resolved against
wp_postmeta. After the custom-tables migration movedform meta into
give_formmeta, ~104 of 109 forms on our site had nowp_postmetamirror, so virtually every form accumulated duplicates over time. We confirmed the
determinant empirically: directly inserting a single
wp_postmetarow for the key makes therecalc update the
give_formmetarow in place (count stays 1) and the value still updatescorrectly (read back via
give_get_meta()).Triggers we confirmed re-create a duplicate row:
give_update_goal_progress( $form_id )give_recount_form_income_donation( $form_id )give_daily_scheduled_eventscronExpected Behavior
A stat recalc should update the existing
give_formmetarow in place, regardless ofwhether the key is also present in
wp_postmeta. There should be exactly one row per(form_id, meta_key)for these singular cached stats; recalcs must never INSERT a secondrow.
Steps to Reproduce
_give_form_goal_progress(or_give_form_earnings/_give_form_sales) exists inwp_give_formmetabut not inwp_postmeta.ListDonationFormsquery slows dramatically (on our data: ~277 s) or white-screens.
Control: insert one matching row into
wp_postmetafor the same(post_id, meta_key),repeat step 2 — the
give_formmetacount now stays at 1.Visuals
Row count for one form before/after three recalcs (no postmeta mirror):
Additional Context
Related robustness issues we hit in the same area (separate, but same admin flow):
Campaign::defaultForm()callsDonationFormsRepository::getById( int $donationFormId )with$this->defaultFormId,which can be
nullfor a campaign whoseform_idis0/NULL→TypeErrorfatals thecampaign/Forms admin. A null-guard returning
nullwould degrade gracefully.DonationFormQueryData::fromObject()sets$self->campaignId(typedint) fromCampaign::findByFormId( $id )->id; whenfindByFormId()returnsnull(a v3 form whosegoalSource is
campaignbut with nogive_campaign_formsrelation), assigningnulltothe typed
intproperty fatals the Donation Forms list.What we did (workaround on our site)
give_formmeta, keeping the newest row per(form_id, meta_key).(form_id, meta_key)ongive_formmeta(theattachMetaself-joins were also unindexed) → Donation Forms list 277 s → ~0.1 s.
wp_postmetarow per(form_id, stat_key)for forms missing it, so GiveWP's update path takes the UPDATEbranch. Verified the stat value still updates correctly in
give_formmeta.This is a workaround, not a fix — the underlying existence-check/write-table mismatch is in
GiveWP core. A proper fix would resolve the existence check against the same custom table
the write targets, plus a migration to collapse existing duplicates (the v3.3.0
donation-forms-remove-duplicate-metamigration only covers_give_form_earnings/_give_form_sales, not_give_form_goal_progress, and does not prevent recurrence).System Information
Details
wp_give_migrations70/70 success)wp_give_donationmeta~2.8M rows; 109 donation formsAcceptance Criteria
give_update_goal_progress,give_recount_form_income_donation,donation completion, daily cron) updates the existing
give_formmetarow in placeand the row count for
(form_id, meta_key)stays at 1.wp_postmetado not accumulateduplicate
give_formmetarows.(
_give_form_earnings,_give_form_sales,_give_form_goal_progress).ListDonationFormsquery does not degrade (no Cartesian blow-up) for forms thatcurrently have duplicate meta.
updates for legacy forms that already have a
wp_postmetamirror.