Part 5 of 5

Three Hundred Files

The Rebrand That Left Both Versions in Place

3dsmaxforum.com became digitalartsfront.com because the site had outgrown its name. It was never only about one piece of software, and calling it after one made it look like a support forum for a tool rather than a place artists showed work.

The rename came with a rewrite, and the rewrite is visible in the file listing.

Thirteen Files With a Version in the Name

Sitting in the web root next to index.php is index.v3.php. Next to portfolio.php is portfolio.v3.php. There are thirteen of these pairs, covering the home page, browsing, portfolios, images, challenges, chat, news, registration, profile editing, referrals, and the FAQ.

Nothing references them. Searching the entire codebase for a link, an include or a redirect to any .v3.php file returns nothing at all.

Diffing a pair says which way round they go. index.php carries a feature index.v3.php does not, restoring a member's saved tabs on the home page, and it has a different tagline. So .php is the newer file and .v3.php is the previous generation, kept beside it rather than deleted.

This is the same instinct as leaving a superseded implementation in a source file below the one that replaced it, which shows up repeatedly in my older code. The mechanism here is different and the consequence is worse. Two implementations in one function are at least both compiled, so a change to a shared type breaks both and the stale one gets noticed. Two implementations in two files, where nothing links to the second, are invisible. The old file cannot break, so nothing reports that it has rotted, and it sits in the deployed web root, reachable to anyone who types its name.

That last part is the real cost. registration.v3.php was a live URL for years. It ran the previous version's registration flow, against the current database, with none of the current version's changes to it.

The Rename Never Finished

The recovered history has five snapshots, four of them the site itself, and one of the signals used to order them is how many files mention each domain. Counted with git grep -l at each of the four, mentions of digitalartsfront rise from 14 to 27 and then hold. Mentions of 3dsmaxforum stay at 13 the whole way through, including in the final snapshot from 2014, four years after the rebrand.

The second number is the one that matters, and it is the stable one. Change the counting rule (fold case, or exclude editor backups like index.php.bak2) and the digitalartsfront figures shift, which is what a count of an incidental label does. The 3dsmaxforum figure shifts too, but it stays flat across all four snapshots under every rule tried. The old name was never being removed, only stopped being added.

So the new name was added rather than substituted. Thirteen files still named the old domain on the day the site was last touched, and the reason is the ordinary one: renaming is easy where the string is a label and hard where it is an identifier. A page title changes freely. A hardcoded absolute URL in an email template, a cookie domain, a PayPal return address, all of those matter, and each one is a small risk taken for no visible benefit.

The site kept both names working, which was the correct operational answer and is why the codebase never converged on one.

What Was Paying For It

Under all of this sat an advertising platform, and it is the most complete single feature in the codebase. Not an AdSense tag. A self-serve system where somebody bought a placement, uploaded creative, and got reporting.

It is also switched off. In all five surviving snapshots, from the earliest in 2010 to the last in 2014, the delivery half of it sits inside an if(0) block in inc/adsense_msod.inc.php, with a Project Wonderful ad box above it and a Google AdSense leaderboard as the fallback. So the code below ran, but not in any state the archive can see. The adImpressions table weighs 33 MB, which is not what an unused table weighs, so the switch was thrown some time before the earliest snapshot survives. Read what follows as a description of a system that worked and was retired, rather than one that was serving.

Ad selection is one query:

SELECT * FROM ads
WHERE authorised = 1
AND adDateStart < NOW()
AND (adDateEnd = '0000-00-00 00:00:00' OR adDateEnd > NOW())
AND (adImpressions < adMaxImpressions OR adClicks < adMaxClicks)
ORDER BY RAND() LIMIT 1

Every clause in that is a product decision. Ads are manually approved before they run. Campaigns have a start and an optional open-ended finish. And a campaign can be capped on impressions or on clicks, so an advertiser could buy either a number of views or a number of visits, with the campaign ending when whichever they bought ran out.

ORDER BY RAND() is the one line to change. MySQL cannot use an index for it, so serving a single ad meant assigning a random number to every eligible row and sorting the lot, on every page view that reached this include. It is fine at a dozen campaigns and it is the first thing that falls over at a thousand. Picking a random offset with a second query, or storing a random column and selecting the first row above a random value, both get the same behavior with an index.

Delivery is capped per viewer as well:

$sql = "SELECT * FROM adImpressions WHERE adId = '$adId' AND REMOTE_ADDR = '$host'
        AND timestamp > DATE_SUB(NOW(), INTERVAL 15 MINUTE)";
$results = $DB->query($sql);
if(count($results) > 1){
  $showAd = false;
}

The intent is that a given viewer sees a given ad at most once in fifteen minutes, which protects the advertiser from paying for the same eyeballs repeatedly. The comparison is wrong. > 1 means the suppression only begins after two prior impressions are already recorded, so a viewer saw each ad twice per window rather than once. That is a defect rather than a switch, it would have inflated delivered impressions by roughly a factor of two against the stated cap, and it would have been invisible from the reporting, because the reporting counts what was served. Whether it ever did is unknowable from here, since the block was already disabled by the time the first snapshot was taken.

Two Places to Record the Same Thing

Clicks are recorded twice, deliberately:

$sql = "UPDATE ads SET adClicks = adClicks + 1 WHERE adId = '$adId'";

and then, in the same request, a row:

$sql = "INSERT INTO adClicks SET $httpData timestamp = NOW(), userId = '$userId', adId = '$adId'";

where $httpData carries the remote address, the user agent and the referrer, and $userId is the member if one is signed in and zero otherwise.

The counter on the campaign is what the selection query reads on every page load, so it has to be one indexed integer. The detail row is what the reporting reads, and reporting can afford to aggregate. Denormalizing the hot path and keeping the detail beside it is the right shape, and the pair of writes is not redundancy, it is two different questions being answered at two different rates.

There is no reconciliation between them anywhere, which is where this design usually goes wrong. Any request that fails between the update and the insert leaves the counter ahead of the rows, and nothing ever notices. A nightly job comparing SELECT COUNT(*) against the counter is cheap and would have made the drift visible.

What Transfers

Delete the old version or move it out of the deployment. A superseded file with nothing linking to it is still a served URL. Keeping the previous implementation for reference is reasonable, and the place for it is a commit, not the web root.

A rename is two jobs, and only one of them is easy. Labels change for free. Identifiers, and anything a third party holds a copy of, do not. Expect to run both names for a long time and decide up front which one the code is allowed to know about.

ORDER BY RAND() is a landmine on a hot path. It is the shortest way to express the intent and it scans the table. Every alternative is slightly more code and uses an index.

When the same event is written twice, add the check that compares them. A counter for the hot path and rows for the detail is a good design. Without a job that reconciles the two, it silently becomes two different answers, and the one used for billing is the one nobody can audit.

The site was disabled on the eighteenth of July, 2016. What is left is 307 files, 62 tables and 2.3 GB of other people's conversations, which is a great deal more than most side projects leave behind.