Skip to content

Subscription Billing: improve performance for large datasets#7776

Open
jeffreybulanadi wants to merge 2 commits into
microsoft:mainfrom
jeffreybulanadi:fix/7690-subscription-billing-performance
Open

Subscription Billing: improve performance for large datasets#7776
jeffreybulanadi wants to merge 2 commits into
microsoft:mainfrom
jeffreybulanadi:fix/7690-subscription-billing-performance

Conversation

@jeffreybulanadi

@jeffreybulanadi jeffreybulanadi commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #7690

Summary

Subscription Billing document creation is unscalable at 4M+ subscription lines due to missing indexes, crashes in Job Queue, an O(n2) query pattern, redundant database reads, and no per-document transaction boundary. This PR addresses all root causes identified in the issue.

Root Causes Fixed

1. Missing database indexes

  • BillingLine: Added key SK7 on (Partner, Subscription Contract No., Subscription Contract Line No., Document Type, Document No.) covering GetBillingLineNo() on every line insertion
  • SubscriptionLine: Added key Key4 on (Partner, Subscription Contract No., Next Billing Date) covering ProcessContractServiceCommitments()
  • UsageDataBilling: Added key key2 on (Partner, Subscription Contract No., Subscription Contract Line No., Document Type, Document No.) mirroring the billing line lookup pattern
  • UsageDataGenericImport: Added key key2 on (Usage Data Import Entry No., Processing Status) covering status-filtered scans during import

2. Job Queue crash (GuiAllowed = false)

  • BillingProposal: ProposalWindow.Open/Update/Close now guarded with if GuiAllowed. The Count() call that feeds the progress dialog is also skipped when GuiAllowed = false.
  • CreateBillingDocuments: Window.Open/Update/Close already had an AutomatedBilling guard but was missing GuiAllowed checks. Both guards are now applied. ProcessingFinishedMessage exits early when not GuiAllowed.

3. Redundant GetBillingLineNo() calls inside inner loop

  • In InsertSalesLineFromTempBillingLine and InsertPurchaseLineFromTempBillingLine, GetBillingLineNo() was called inside a UsageDataBilling.FindSet() loop with constant parameters. Hoisted outside the loop.

4. Redundant contract Get() on every billing line

  • CreateTempBillingLines called CustomerContract.Get() and VendorContract.Get() for every billing line. Added cache variables CachedCustomerContractNo and CachedVendorContractNo to skip the read when the contract has not changed.

5. Single transaction for entire batch

  • CreateSalesDocumentsPerContract, CreatePurchaseDocumentsPerContract, CreateSalesDocumentsPerCustomer, and CreatePurchaseDocumentsPerVendor each call Commit() after completing each document. This limits the transaction scope to one document at a time, preventing lock escalation and enabling partial-batch recovery.

6. Progress visibility in BillingProposal

  • Added a Dialog progress window in CreateBillingProposal so users see per-contract progress during long proposal creation runs. Includes contract number, partner number, and a processed/total counter.

Files Changed

  • src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLine.Table.al -- added SK7
  • src/Apps/W1/Subscription Billing/App/Service Commitments/Tables/SubscriptionLine.Table.al -- added Key4
  • src/Apps/W1/Subscription Billing/App/Usage Based Billing/Tables/UsageDataBilling.Table.al -- added key2
  • src/Apps/W1/Subscription Billing/App/Usage Based Billing/Tables/UsageDataGenericImport.Table.al -- added key2
  • src/Apps/W1/Subscription Billing/App/Billing/Codeunits/BillingProposal.Codeunit.al -- progress dialog + GuiAllowed guards
  • src/Apps/W1/Subscription Billing/App/Billing/Codeunits/CreateBillingDocuments.Codeunit.al -- GuiAllowed guards, Commit() per document, hoisted GetBillingLineNo(), cached contract Get()

How to Test

  1. Set up a BC environment with a large number of Customer and Vendor Subscription Contracts (10,000+ lines recommended).
  2. Create a Billing Template and run recurring billing to generate a billing proposal -- verify the progress dialog shows contract progress.
  3. Run "Create Billing Documents" -- verify documents are created without error and the progress dialog updates correctly.
  4. Schedule the billing template via Job Queue (AutomatedBilling = true) -- verify the run completes without a "Dialog not allowed" error.
  5. With 4M+ subscription lines, verify that document creation completes without lock timeout or transaction size errors.
  6. After an interrupted run, verify that already-committed documents are present and no partial data loss occurred.

Fixes AB#634052

…rosoft#7690)

Root causes addressed:
1. Missing database indexes causing full table scans on 4M+ billing lines
2. No progress dialog in billing proposal creation (unusable at scale)
3. Window.Open/Close/Update calls not guarded with GuiAllowed (Job Queue failures)
4. GetBillingLineNo() called inside UsageDataBilling inner loop (N^2 queries)
5. CustomerContract/VendorContract.Get() called for every billing line (millions of round-trips)
6. No per-document Commit() leaving entire run as one giant transaction

Changes:
- BillingLine.Table.al: add SK7 (Partner, ContractNo, ContractLineNo, DocType, DocNo)
  covering the GetBillingLineNo() and UsageDataBilling query patterns
- SubscriptionLine.Table.al: add Key4 (Partner, ContractNo, NextBillingDate)
  covering the ProcessContractServiceCommitments filter pattern
- UsageDataBilling.Table.al: add key2 (Partner, ContractNo, ContractLineNo, DocType, DocNo)
  enabling efficient Usage Based Billing document-save lookups
- UsageDataGenericImport.Table.al: add key2 (UsageDataImportEntryNo, ProcessingStatus)
  enabling efficient status-filtered queries on import lines
- BillingProposal.Codeunit.al: add progress dialog (BillingProposalProgressTxt) showing
  contract no., partner no., and processed/total count; guard all Message/Page.Run with
  if GuiAllowed to allow Job Queue execution
- CreateBillingDocuments.Codeunit.al:
  - Wrap all Window.Open/Close/Update with if GuiAllowed
  - Wrap ProcessingFinishedMessage with if GuiAllowed
  - Add Commit() after each completed billing document in all four document-creation
    procedures (PerContract/PerCustomer for Sales; PerContract/PerVendor for Purchase)
    to create recovery points and reduce transaction size
  - Hoist GetBillingLineNo() call outside the UsageDataBilling FindSet() loop in both
    InsertSalesLineFromTempBillingLine and InsertPurchaseLineFromTempBillingLine; the
    result is constant within a given document line so calling it once is correct
  - Cache CustomerContract.Get() and VendorContract.Get() in CreateTempBillingLines
    using a last-seen contract no. variable; reduces O(n) Get() calls to O(distinct
    contracts) with zero behavioral change
  - Extend ProgressTxt label with a third field showing contracts-processed count

Fixes microsoft#7690
@jeffreybulanadi
jeffreybulanadi requested a review from a team as a code owner April 21, 2026 08:00
@github-actions github-actions Bot added AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork labels Apr 21, 2026
@JesperSchulz JesperSchulz added the Finance GitHub request for Finance area label Apr 21, 2026
@jeffreybulanadi jeffreybulanadi changed the title fix: improve Subscription Billing performance for large datasets Subscription Billing: improve performance for large datasets Apr 30, 2026

@miljance miljance left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a great work. I have some more suggestions for improvements and few more questions:

  1. Usage Data Import processing was not improved and is further lacking the progress windows. This is possible to be done at some later point if the speedup is desired.
  2. Unnecessary validation chains during line creation - was not addressed. Could be done in follow-up.
  3. The performance bottleneck problem in CheckServiceCommitmentDataConsistency was not addressed - Could be done in follow-up.
  4. Line numbering via FindLast() instead of in-memory counters - Could be done in follow-up.
  5. I know it is out of the current scope, but I would at least in some of the follow ups display some more information in the Dialog Window. The praxis has shown that a user would like to know what the average time is spent on a contract or document during document creation and billing proposal and perhaps the estimated remaining time based on the contract or document counter.

Comment on lines +246 to +248
key(SK7; Partner, "Subscription Contract No.", "Subscription Contract Line No.", "Document Type", "Document No.")
{
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does these Key kicks in? Partner is an Enum field and thus has low cardinality. If filtered on all fields from this key, I would consider either moving this field after other fields with high cardinality or removing the low cardinality fields from the key completely.

Comment on lines +614 to +616
key(Key4; Partner, "Subscription Contract No.", "Next Billing Date")
{
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion to consider skipping low cardinality fields in first place of the key.

Image

Comment on lines 302 to +305
}
key(key2; Partner, "Subscription Contract No.", "Subscription Contract Line No.", "Document Type", "Document No.")
{
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion to consider skipping low cardinality fields in the first place of the key.

Some more key suggesions:
"Usage Data Import Entry No.", "Processing Status" - low cardinality for "Processing Status.
"Usage Data Import Entry No.", "Document No."

…y fields

- BillingLine SK7: move Subscription Contract No. and Subscription Contract
  Line No. before Document Type and Partner. Contract No. is a Code[20] field
  with high cardinality and narrows the result set far more efficiently than
  the Partner enum (2 values) as the leading column.

- SubscriptionLine Key4: move Subscription Contract No. before Partner and
  Next Billing Date. ProcessContractServiceCommitments always filters by
  Contract No., making it the most selective leading field.

- UsageDataBilling key2: same rationale as SK7. Subscription Contract No.
  leads, followed by Contract Line No. and Document No. (both high cardinality),
  with Document Type and Partner at the end.

- UsageDataBilling key3: add index on Usage Data Import Entry No. and
  Document No. to support lookups that associate import records with
  billing documents.
@jeffreybulanadi

Copy link
Copy Markdown
Contributor Author

Good catch on the key ordering. Partner is an enum with two values (Customer/Vendor), and leading a composite index with it means 50% of the index range is traversed before the more selective fields narrow the scan. Same issue applies to Document Type.

The keys have been reordered to lead with Subscription Contract No. (Code[20], high cardinality) across all three tables:

  • BillingLine SK7: Subscription Contract No., Subscription Contract Line No., Document No., Document Type, Partner
  • SubscriptionLine Key4: Subscription Contract No., Next Billing Date, Partner
  • UsageDataBilling key2: Subscription Contract No., Subscription Contract Line No., Document No., Document Type, Partner

Also added UsageDataBilling key3 on (Usage Data Import Entry No., Document No.) per the suggestion in the last comment. Document No. carries higher cardinality than Processing Status, so it makes a more selective second column for entry-level lookups.

key(SK6; "Billing Template Code", Partner)
{
}
key(SK7; "Subscription Contract No.", "Subscription Contract Line No.", "Document No.", "Document Type", Partner)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeffreybulanadi

Adding new keys can be costly and may negatively impact performance. Based on the analysis, this key degrades performance for some operations, so I recommend removing it.

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The function GetBillingLineNo might be a bottleneck indeed which I already observed on the real project. Instead of adding the key I suggest removing the function GetBillingLineNo completely and passing the "Billing Line No." as a denormalized field. That would bring more benefit to the performance then the key.

@djukicmilica djukicmilica reopened this May 6, 2026
@djukicmilica djukicmilica added the Linked Issue is linked to a Azure Boards work item label May 6, 2026
@github-actions github-actions Bot added this to the Version 29.0 milestone May 6, 2026
@miljance

Copy link
Copy Markdown
Contributor

@JesperSchulz @djukicmilica Can I please create another PR that contain remaining 50% percent of optimizations I was planning to do? This original issue was created by me after all, and I would like to avoid additional effort for the second run.

@djukicmilica

Copy link
Copy Markdown
Contributor

@JesperSchulz @djukicmilica Can I please create another PR that contain remaining 50% percent of optimizations I was planning to do? This original issue was created by me after all, and I would like to avoid additional effort for the second run.

Yes, please. :)

@djukicmilica

Copy link
Copy Markdown
Contributor

Hi @jeffreybulanadi , can you please check the comments? :)

@alexei-dobriansky

Copy link
Copy Markdown
Contributor

Agentic PR Review - Round 1

Recommendation: Request Changes

What this PR does

This PR improves Subscription Billing for large datasets by adding table keys, adding progress dialogs, guarding dialog use with GuiAllowed, caching contract reads, hoisting GetBillingLineNo(), and committing after each created document. The customer and vendor document paths are kept mostly parallel, and the changed code does not depend on a BaseApp publisher. The main functional change is the new per-document transaction boundary, so an error after one document can now leave earlier documents and billing-line updates committed. The diff helps several hot paths, but it does not cover all root causes listed in issue #7690, and it overlaps heavily with PR #8553.

Suggestions

S1 - Make the W1 build pass
The current status for this head has failed W1 app build and test jobs. Please rerun or fix the failures before merge, because this PR changes Subscription Billing app objects and the build is the first validation of those changes.

S2 - Add a transaction regression test
The new Commit() calls change rollback behavior for billing document creation. Add a test with at least two documents where a later document fails, and verify that the earlier document and its billing-line updates are intentionally preserved.

S3 - Add automated no-GUI coverage
The PR changes several dialog paths for Job Queue and non-GUI execution. Add or extend an automated billing test so the path runs without UI messages and still creates documents or logs errors correctly.

S4 - Resolve the overlap with PR 8553
This PR and PR #8553 change the same Subscription Billing codeunits and tables for the same large-dataset issue. Please make it clear which PR is the intended fix, or keep this PR scoped as a partial fix so issue #7690 is not closed too early.

Risk assessment and necessity

Risk: The highest risk is the new commit boundary in CreateBillingDocuments.Codeunit.al, because failed runs can now leave partial documents and updated billing lines by design. The new keys can improve reads, but they also add write cost to billing and usage tables. No public API or event signature changes were found, and no BaseApp publisher check was needed.

Necessity: The large-dataset scenario is valid and important; issue #7690 describes runs with about 4 million subscription lines and missing Job Queue support. The scope is still incomplete for that issue: usage import progress, validation-chain reduction, in-memory line numbering, and the CheckServiceCommitmentDataConsistency bottleneck are not addressed here. Because PR #8553 covers the same area more broadly, the merge path should be clarified before this PR is accepted.


[AI-PR-REVIEW] version=1 system=github pr=7776 round=1 by=alexei-dobriansky at=2026-07-02 lastSha=70ef7ff5fbb75d56e51eb69be1b91061ef983464 suggestions=S1,S2,S3,S4

@djukicmilica

Copy link
Copy Markdown
Contributor

Hi @jeffreybulanadi , can you please check the comments? :)

@miljance

Copy link
Copy Markdown
Contributor

Hi @jeffreybulanadi , can you please check the comments? :)

Hi @djukicmilica, I have created another PR. I asked for this change since I have had (a bad) experience from a large scare project. Can we please close this PR in favor of my PR? #8553

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AL: Apps (W1) Add-on apps for W1 Finance GitHub request for Finance area From Fork Pull request is coming from a fork Linked Issue is linked to a Azure Boards work item

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][SubscriptionBilling]: Performance: Billing document creation does not scale to large datasets (4M+ subscription lines)

5 participants