Skip to content

Form stat meta (_give_form_earnings / _give_form_sales / _give_form_goal_progress) duplicates in give_formmeta on every recalc when the keys are not mirrored in wp_postmeta #8250

Description

@Oguzhan-Ozpinar

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

  1. 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.
  2. 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.)
  3. 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.
  4. 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/NULLTypeError 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)

  1. De-duplicated give_formmeta, keeping the newest row per (form_id, meta_key).
  2. 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.
  3. 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.
  4. 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

  • A stat recalc (give_update_goal_progress, give_recount_form_income_donation,
    donation completion, daily cron) updates the existing give_formmeta row in place
    and the row count for (form_id, meta_key) stays at 1.
  • Forms whose stat keys are not present in wp_postmeta do not accumulate
    duplicate give_formmeta rows.
  • A migration collapses existing duplicate stat meta for all three keys
    (_give_form_earnings, _give_form_sales, _give_form_goal_progress).
  • The ListDonationForms query does not degrade (no Cartesian blow-up) for forms that
    currently have duplicate meta.
  • Fixing the existence-check/write-table behavior does not change correct in-place
    updates for legacy forms that already have a wp_postmeta mirror.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Staletype: bugExisting functionality is broken

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions