Thursday, September 22, 2016

InventABCUpdate - Very Poor Performance




Recently, our purchasing manager tried to run the InventABCUpdate batch process in our QA environment using the parameters defined below.

 


 

The process took 14 hours to run and all it was doing was trying to identify which ABC code should be assigned to each item in the InventTable (not service items). 

 

In basic terms, what this routine trying to calculate which items contributed to the first 80% of revenue (value 'A'), the items in the band from 80% to 95% of revenue (value 'B') and the remainder (value 'C').  This is a general classification that does not take into account any kind of quantity.  So an item that sold for $20,000.00 during this period and is only sold occasionally would have the same code as an item that had a purchase price of $1,000.00 and sold 20 units during the period.  So, if total sales were $1,000,000.00 during the period, the ABC values would be assigned in the following manner.

 

For each item order by total sales for the period, if it occurs in the band from $0.00 to $800,000.00 incrementing by the sales for the period, it will be assigned a value of A.  If occurs from $800,000.00 to $950,000.00 it will be assigned a B.  Otherwise an item will be assigned a C.

Item Id
Sales for Period
Accumulated Sales
ABC Value
AAA
100,000
100,000
A
BBB
99,000
199,000
A
CCC
98,000
197,000
A
DDD
97,000
194,000
A
...
QQQ
54,000
799,000
A
RRR
53,000
852,000
B
SSS
52,000
904,000
B
...
XXX
3,000
994,000
B
YYY
2,000
996,000
B
ZZZ
1,500
997,500
C
...

 

So why was this job taking 14 hours?  The job begins with InventABCUpdate that processes the parameters, calls the factory method newFromInventABCUpdate to create the correct subclass of InventABC and passes the results to the Calc method in InventABC.  In this case, the subclass instantiated is InventABC_Revenue.

The Calc method has 2 parts, the first is to populate to the TmpABC table to by calling the subclasses’ sumUpValue method.  The second part iterates through each TmpABC record ordered by Amount descending.  Based on the discussion earlier, it derives the correct ABC value, retrieves the InventTable record buffer, assigns the value and updates it.  In our case, we have some 177,000 records in the InventTable involved, so this should take some time but not hours.  Actually, it is about a minute.

The real culprit is the sumUpValue method in the InventABC_Revenue class. The method as written is:

protected void sumUpValue()

{

    InventTable             inventTable;
    InventTable             previousInventTable;
    CustInvoiceTrans        custInvoiceTrans; 

    Query    query = new Query(itemQueryRun.query().pack());
    QueryBuildDataSource    qbdsItem = query.dataSourceTable(tableNum(InventTable));
    QueryBuildDataSource    qbdsTrans = qbdsItem.addDataSource(tableNum(CustInvoiceTrans));
    QueryBuildRange         itemTypeRange = SysQuery::findOrCreateRange(qbdsItem, fieldNum(InventTable, ItemType));
    QueryRun                revenueQueryRun;
    AmountMST               lineAmountMST;

 

    // ensure that results are sorted by item

    qbdsItem.addOrderByField(fieldNum(InventTable, ItemId));
    itemTypeRange.value(SysQuery::valueNot(queryValue(ItemType::Service)));
    qbdsTrans.relations(false);
    qbdsTrans.addLink(fieldNum(InventTable, ItemId), fieldNum(CustInvoiceTrans, ItemId));
    qbdsTrans.joinMode(JoinMode::OuterJoin);
    // filter on dates
    qbdsTrans.addRange(fieldNum(CustInvoiceTrans, InvoiceDate)).value(SysQuery::range(fromDate, toDate));
    revenueQueryRun = new QueryRun(query);

    while (revenueQueryRun.next())
    {
        if (revenueQueryRun.changed(tableNum(InventTable)))
        {
            inventTable = revenueQueryRun.get(tableNum(InventTable)) as InventTable;
            if (previousInventTable.ItemId)
            {
                this.saveAmount(previousInventTable, lineAmountMST);
                lineAmountMST = 0;
            }
            previousInventTable.data(inventTable);
        }
 
        if (revenueQueryRun.changed(tableNum(CustInvoiceTrans)))
        {
            custInvoiceTrans = revenueQueryRun.get(tableNum(CustInvoiceTrans)) as CustInvoiceTrans;
            if (custInvoiceTrans.LineAmountMST)
            {
                lineAmountMST += custInvoiceTrans.LineAmountMST;
            }
        }
    }
    if (previousInventTable.ItemId)
    {
        this.saveAmount(previousInventTable, lineAmountMST);
    }
}

 

The problem is this method is the query.  What it is doing is iterating through each InventTable and each associated record (if any) in CustInvoiceTrans to accumulate its total sales.  On top of this, it is ordering this query by item id.  According to the debugger, the query is written as:

SELECT FORUPDATE * FROM InventTable(InventTable) ORDER BY InventTable.ItemId ASC WHERE ((NOT (ItemType = 2))) OUTER JOIN * FROM CustInvoiceTrans(CustInvoiceTrans_1) ON InventTable.ItemId = CustInvoiceTrans.ItemId AND ((InvoiceDate>={ts '2015-09-01 00:00:00.000'} AND InvoiceDate<={ts '2016-08-31 00:00:00.000'}))

This translates into SQL as:

SELECT * FROM InventTable
LEFT OUTER JOIN CustInvoiceTrans ON InventTable.ItemId = CustInvoiceTrans.ItemId
AND ((InvoiceDate>={ts '2015-09-01 00:00:00.000'}
AND InvoiceDate<={ts '2016-09-01 00:00:00.000'}))
WHERE ((NOT (ItemType = 2)))

 

In our database, this totaled 959, 472 records and took 2 minutes and 48 seconds to run.

A more direct query:

SELECT IT.ITEMID, SUM(CIT.LINEAMOUNTMST) LINEAMOUNTMST
FROOM INVENTTABLE IT
INNER JOIN CUSTINVOICETRANS CIT ON IT.ITEMID = CIT.ITEMID
       --AND IT.DATAAREAID = CIT.DATAAREAID
       --AND IT.PARTITION = CIT.PARTITION
WHERE CIT.INVOICEDATE >= '2015-09-01'
AND CIT.INVOICEDATE <= '2016-09-01'
GROUP BY IT.ITEMID
ORDER BY IT.ITEMID

Returned 77,003 records and took 4 seconds to run.  It also has 12.4x fewer records, thus speeding up the additional processing after this routine.

It was decided to implement a set of changes based on this processing, but segregated from normal processing so it could be removed if Microsoft implements improvements in this area.  Also, I decided that it would be nice to maintain a record of old ABC Revenue values and new along with the other data used in processing. The record would be access via queries in the database as there was no real need for a UI or report.

Step 1) Create a duplicate tmpABC Table.


 

The only differences are 2 name changes: Amount became TotalSales and Value became NewValue.  The next step was to duplicate InventABC_Revenue.  The only difference here is that the sumUpValue method would be rewritten also a new parameter will be added to InventABC, parmContinueAfterSumUpValue.  At the parent class, it will always return True.  In the new InventABC_Revenue derived class, it will return false.

The new method performs the calculate operation in 4 steps

protected void sumUpValue()

{
    this.initializeABCClassification();
    this.assignRevenueForPeriod();
    this.calculateNewClassification();
    this.updateInventoryTable();
}

InitializeABCClassication deletes all records from the new PC_ABCClassification table.  The reason for this is that a simple merge may have left sales values from prior periods and have returned incorrect results.  It could have been written as update all old ABC values with the current values, new ABC values with ‘C’ and set TotalSales to 0 and then add items created since last run.  However, I was thinking that just refreshing the table would be easier and faster.  After truncating the table, all non-service items are added with the appropriate attributive data.
 

while select ItemId, NameAlias, RecId, ABCRevenue
    from inventTable
        where inventTable.ItemType != ItemType::Service
    join ItemGroupId from inventItemGroupItem where inventItemGroupItem.ItemId == inventTable.ItemId
        && inventItemGroupItem.ItemDataAreaId == inventTable.DataAreaId
    {
 
        pcABCClassification.RefRecId     = inventTable.RecId;
        pcABCClassification.ItemGroupId  = inventItemGroupItem.ItemGroupId;
        pcABCClassification.ItemId       = inventTable.ItemId;
        pcABCClassification.OldValue     = inventTable.ABCRevenue;
        pcABCClassification.ItemName     = inventTable.NameAlias;
        pcABCClassification.NewValue     = ABC::C;
        pcABCClassification.insert();
    }

 

Now that the base data is set, it is now easier to collect the sales data directly.

private void assignRevenueForPeriod()

{
    InventTable inventTable;
    CustInvoiceTrans custInvoiceTrans;
    PC_ABCClassification pcABCClassification;
    ;
 
    while select ItemId,sum(LineAmountMST) from custInvoiceTrans
        group by custInvoiceTrans.ItemId
        where custInvoiceTrans.InvoiceDate >= fromDate
        && custInvoiceTrans.InvoiceDate <= toDate
        && custInvoiceTrans.ItemId
        notexists join inventTable
        where inventTable.ItemId == custInvoiceTrans.ItemId
        && inventTable.ItemType == ItemType::Service
    {
        pcABCClassification = PC_ABCClassification::find(custInvoiceTrans.ItemId, true);
        pcABCClassification.TotalSales = custInvoiceTrans.LineAmountMST;
        pcABCClassification.doUpdate();
    }
 
}

 

This is the method that now traverses only 77.000 records instead of 959,000 records.  By using the group by clause, the subroutine now require 1/12 the processing of before.
Now calculating the new ABC classification is tweaked so that it iterates a little more quickly.  I could have used the old method of classifying, but decided to try this instead.  The only difference is items that have the same sales amount and are on the border between classifications may not get the same code.  This is a highly improbable scenario in our business, so it can safely be ignored.


private void calculateNewClassification()
{
    AmountCur categoryARev;
    AmountCur categoryBRev;
    AmountCur usedAmount = 0;
 
    InventTable inventTable;
    CustInvoiceTrans custInvoiceTrans;
    PC_ABCClassification pcABCClassification;
    ;
    select sum(LineAmountMST) from custInvoiceTrans
        where custInvoiceTrans.InvoiceDate >= fromDate
        && custInvoiceTrans.InvoiceDate < toDate
        && custInvoiceTrans.ItemId
        notexists join ItemId, ItemType from inventTable
        where inventTable.ItemId == custInvoiceTrans.ItemId
        && inventTable.ItemType == ItemType::Service;
 
    totalAmount = custInvoiceTrans.LineAmountMST;
    categoryARev = totalAmount * categoryA / 100;
    categoryBRev = categoryARev + totalAmount * categoryB / 100;
 
    while select forUpdate pcABCClassification
        order by pcABCClassification.TotalSales desc
    {
        if (usedAmount < categoryARev)
        {
            pcABCClassification.NewValue = ABC::A;
        }
        else
        {
            if (usedAmount < categoryBRev)
            {
                pcABCClassification.NewValue = ABC::B;
            }
            else
            {
                pcABCClassification.NewValue = ABC::C;
            }
        }
        pcABCClassification.doUpdate();
        usedAmount = usedAmount + pcABCClassification.TotalSales;
    }
}
 

First, the total sales for the period are determined. With this, the band amounts for A and B classifications are made.  Once the B threshold is passed, then all items regardless of sales volumes (if any) are assigned a value of C.

The final step, iterates through the new PC_ABCClassification table and assigns the NewValue to the ABCRevenue field in the associated InventTableRecord.

private void updateInventoryTable()
{
    InventTable inventTable;
    PC_ABCClassification pcABCClassification;
    ;
    while select forupdate inventTable
        join NewValue from pcABCClassification where pcABCClassification.ItemId == InventTable.ItemId
    {
        this.setCategory(inventTable, pcABCClassification.NewValue);
        inventTable.doUpdate();
    }
}

 

The last steps involved a modification to InventABC method newFromInventABCUpdate to instantiate a new Revenue class object instead of the old version and modify the calc method to exit if parmContinueAAfterSumUpValue is false.

static public InventABC newFromInventABCUpdate(InventABCUpdate _abcUpdate)
{
    ABCModel    model;
    InventABC   inventABC;


    if (! _abcUpdate)
    {
       throw error(strFmt("@SYS22533",classId2Name(classNum(InventABC)),classId2Name(classNum(InventABCUpdate))));
    }


    model = _abcUpdate.parmModel();
 
    switch (model)
    {
        case ABCModel::Revenue  :
 
            //inventABC = InventABC_Revenue::construct();
            /*
                DG: Override current behavior with new class.
            */
            inventABC = PC_InventABC_Revenue::construct();
            break;
        case ABCModel::ContributionMargin:
            inventABC = InventABC_ContributionMargin::construct();
            break;
        case ABCModel::Value:
            inventABC = InventABC_InventValue::construct();
            break;
        case ABCModel::Link   :
            inventABC = InventABC_CarrCost::construct();
            break;
        default:
            throw error(strFmt("@SYS22532",model));
    }
    inventABC.initFromInventABCUpdate(_abcUpdate);
    return inventABC;
}
 

public void calc()
{
    InventTable     inventTable;
    Amount          accAmount;
    Amount          lastAmount;
    Percent         proportion;
    Percent         proportionBefore;
    ABC             category;
    ABC             lastCategory;
 
    setPrefix("@SYS19424");
 
    this.sumUpValue();
 
    /*
    DG: In order to make this routine more efficient, the assignment of
    new abc codes for the PC_InvenABC_Revenue class was done internally.
    */
    if (!this.parmContinueAfterSumUpValue())
        return;
 
    if (! totalAmount)
    {
        throw error(strFmt("@SYS22534",CompanyInfo::standardCurrency()));
    }
 
Total run time is now about 3-5 minutes.


1 comment:

  1. Dear Junkmonger, Useful post. Can you help me with SQL query to get the Total Sales and Cost Group by Item Type for a given Period...

    ReplyDelete