← All posts

The parts of SQL Server that were never SQL

The parts of SQL Server that were never SQL

For about twenty years, the way you made SQL Server do something outside itself was to reach through it.

You needed an HTTP call, so you instantiated a COM object from T-SQL and drove it by hand. You needed to write a file, so you shelled out. You needed a spreadsheet, so you automated Excel on the server. Mail went through a mail subsystem built into the product. Scheduling went through an agent. All of it worked, some of it worked well, and every single piece of it was Windows.

None of it was SQL. That is the whole story of what had to change.

Everything that had to be rewritten was something SQL Server had been doing outside the database.

That sounds like a tautology, and it very nearly is — which is why it is useful. It means the work could be enumerated in advance instead of discovered. Anything that read or wrote the world beyond the engine was on the list. Anything that only moved data around inside it was not on the list at all, and that is almost everything a business application does.

One procedure, two platforms

The primitive is HTTP, because half of the other cases turn out to be HTTP underneath. Here is the top of the procedure that provides it:

sql
IF EXISTS (SELECT * FROM sys.dm_os_host_info WHERE host_platform = 'Linux')
    BEGIN
        EXEC sp_api_fetch_async @url = @url, @headers = @headers, @method = @method,
             @body = @body, @status = @status OUT, @response = @envelope OUT, @wait = 30000
        -- unwrap the worker's envelope back to the plain response body
        RETURN @status
    END

EXEC sp_OACreate 'MSXML2.ServerXMLHttp.3.0', @Object OUT;

The engine tells you which platform it is on, so you ask it. Below the branch, the original COM body is untouched and still runs on Windows. Above it, Linux hands the request to the asynchronous worker instead.

The important part is not the branch. It is that both branches honour the same contract: @response is the response body, @status is the HTTP status, and @binary_response holds the bytes when you asked for bytes. A caller cannot tell which path ran, and nothing in the framework's HTTP contract changed when a second implementation appeared underneath it.

That is worth being precise about, because it is where this kind of work usually goes wrong. The Linux path does not quite behave like the COM path by default — the worker hands back a wrapper object rather than a bare body, and a stricter HTTP client refuses a GET that carries a body where the COM one silently tolerated it. Left alone, those differences leak into every caller as a series of small inexplicable failures. The fix is not to document them. It is to absorb them inside the primitive so the contract stays true, which is the only reason the rest of this post is short.

The obvious objection

So now every procedure in the system needs an IF Linux in it, and the codebase has doubled. That would be a terrible trade, and it is not what happened.

The part that surprises people

You port the primitive, not the callers.

Somewhere in the framework there is a procedure that validates a VAT number against the European Commission's VIES service. It builds a SOAP envelope, posts it, and parses XML out of the reply. It is exactly the kind of code you would expect to be riddled with platform assumptions — it was originally written against the same COM object as everything else.

It now contains no COM object and no platform branch of any kind. It builds its envelope and calls sp_api_fetch, and it inherits whatever transport that procedure decided on. It works on both operating systems without ever having been told that there are two.

Multiply that. The switch exists in one place. Every caller follows it for free, including callers written years before anyone considered running this on Linux, and including callers we have not read. That is the difference between a port and a rewrite, and it is entirely a consequence of having had one HTTP primitive rather than fifty ad-hoc COM calls scattered through the business logic.

Where a caller did need attention, it was because it had grown its own private copy of the primitive rather than using the shared one. The lesson generalises past this particular migration: a duplicated primitive is a liability that stays invisible until the day the original changes.

The limb that does the rest

HTTP is the interesting case. The rest of the world beyond the engine — sending mail, reading and writing spreadsheets, producing PDFs, rendering templated documents, moving files over SFTP, printing — moved to a single asynchronous worker, reached through one queue and one enqueue procedure.

That worker is itself cross-platform, which produces a pleasing symmetry: the same component serves a Windows host and a Linux host, and on a Windows host it can still drive a local printer queue, because that handler never went away. The platform difference is a configuration detail inside one process rather than a fork in the architecture.

It also removed a dependency that had been quietly constraining things for years. Scheduling no longer needs an agent, which matters more than it sounds: the free edition of SQL Server does not have one. A framework that needs the agent needs a licence. A framework that carries its own scheduler does not.

None of this is visible from a card or an action script. sp_api_email sends mail the same way it always did. What changed is who carries the letter.

There is documentation and a programming reference online, and you can always book a demo.