Files and images
File and image columns behave as in Dataverse: the same column types (FileAttributeMetadata, ImageAttributeMetadata),
SDK messages, Web API routes, headers, limits and error codes. Their content is kept in a file store, never in SQL
tables. The one exception is the image thumbnail (see below).
Stores and keys
| Key | Values |
|---|---|
Files:Store |
FileSystem (default), S3Compatible or AzureBlob; any other value makes startup fail |
Files:FileSystem:Root |
default /var/lib/xrmd/files |
Files:S3:ServiceUrl, Files:S3:Bucket, Files:S3:AccessKey, Files:S3:SecretKey |
required with S3Compatible: a missing one makes startup fail, naming it; ServiceUrl is an absolute http(s) URL and the bucket must exist |
Files:S3:Region, Files:S3:ForcePathStyle |
default us-east-1 and true (what MinIO and most self-hosted stores need) |
Files:Azure:Container |
required with AzureBlob; the container must already exist |
Files:Azure:ConnectionString or Files:Azure:ServiceUri |
with AzureBlob, set exactly one: none or both makes startup fail |
- A file, or the full-size image of an image column, is the blob
{organization database}/{fileattachmentid}. Staged upload blocks are{organization database}/_upload/{sessionid}/{blockindex}. Each organization's blobs therefore sit under its database name, in one store shared by every organization. - Docker. The compose files (
docker/docker-compose.ymland the onexrmd server initwrites) mount afilesnamed volume at/var/lib/xrmd/filesand pass theFILES_*values of.envto theFiles__*keys (empty = file-system store). The image creates that directory owned by the aspnet image'sAPP_UID, and xrmd runs as1654:1654. WithS3CompatibleorAzureBlobthe volume is unused. - Each file has a
fileattachmentrow (filename,mimetype,filesizeinbytes,objectid,regardingfieldname). Those rows can be read only through Read on the owning record. They are never created, updated or deleted directly (0x80040203).
Checking the file store
xrmd server doctor (GET /admin/doctor) has a fileStore check that confirms the configured store is usable without
writing any blob. It is ok, warning or error, and its detail names the store and its directory, bucket or container:
- Local directory (
Files:Store=FileSystem, the default): the root must be writable by the xrmd process. The check briefly creates and removes a temporary file. A root that does not exist yet is a warning ("will be created on the first write") when the nearest existing parent directory is writable, since the store creates it on first use; it is an error when it cannot be created (no writable parent, or the path or a parent is a file). The check itself never creates the root. - S3-compatible: the bucket is probed with a HEAD request; a missing bucket or a refusal (wrong keys, no permission) is an error.
- Azure Blob: the container is probed for existence; a missing container is an error (xrmd does not create it).
The check gives the store 10 seconds to answer; after that it is an error. An error message carries the store and the HTTP status with a short hint (for example access denied, wrong region or endpoint), never a connection string, key or SAS token.
The check is not part of /healthz, which stays a cheap liveness probe.
Azure Blob Storage
With Files:Store=AzureBlob every blob is a block blob in one container of an Azure storage account.
- The container must exist. xrmd never creates it (that would need broader rights than reading and writing blobs).
A missing container is not detected at startup or by
/healthz, butxrmd server doctorreports it in itsfileStorecheck (see Checking the file store); without that check the storage service's error (ContainerNotFound) surfaces on the first file operation (upload, download, backup, sweep) and in the logs. Deleting a single file from a missing container may not report an error (the delete is a no-op), while deleting by prefix does. A missingFiles:Azure:Containerstops startup withFiles:Store is 'AzureBlob' but the required setting Files:Azure:Container is missing. - Two ways to sign in. Set exactly one of:
Files:Azure:ConnectionString: an account-key or shared-access-signature connection string. This also covers a local Azurite emulator. It is a secret and is never written to a log or an error message.Files:Azure:ServiceUri: the account's blob endpoint (for examplehttps://myaccount.blob.core.windows.net). xrmd then signs in with Microsoft Entra ID throughDefaultAzureCredential. Inside the container, a system-assigned managed identity of the host works as is; a user-assigned one needsAZURE_CLIENT_ID(forwarded by the shipped compose files, set it in.env);az loginis only for running xrmd directly on a workstation; workload identity also needsAZURE_TENANT_IDandAZURE_FEDERATED_TOKEN_FILE, which the shipped compose files do not forward. The identity needs the role Storage Blob Data Contributor. Assigned at storage-account scope it already allows requesting a user delegation key, which lets the storage service copy blocks itself (next point). If Contributor is scoped to the container only, also assign Storage Blob Delegator at account scope for that. An invalid value stops startup withFiles:Azure:ServiceUri is not a valid absolute https URL.Plainhttpis always refused, because the Azure SDK does not send Entra credentials overhttp; for local tests against Azurite, use a connection string instead.
- Joining the parts of a chunked upload. When a file is committed, its staged blocks are joined into one blob. Where it can, xrmd asks the storage service to copy each part itself, authorizing the source with a read-only signature valid for one hour (a service SAS with an account key, a user delegation SAS with Entra ID), so the data never passes through xrmd. When that is not possible (Contributor scoped to the container without Storage Blob Delegator at account scope, a SAS connection string without a signing key, an emulator that lacks the operation, or more than 50,000 parts), xrmd reads the parts and writes them back in 4 MiB blocks instead. Both ways commit one block list, so the target appears all at once or not at all.
- Known limit. Two writes to the same key at the same time never mix their content, but the slower one can fail with
an
InvalidBlockListerror from the service. xrmd never does that: every key is written by one operation at a time.
Columns
- A file column is a
uniqueidentifierholding thefileattachmentid, plus the read-only companion<column>_name(the file name). It cannot be set with Create or Update: any value other than null is refused (0x80040203, "can only be set with the file upload messages"). Setting it to null on Update deletes the file, asDeleteFiledoes. - An image column holds the thumbnail, plus the companions
<column>id(the image id),<column>_timestamp(UTC ticks of the last change) and<column>_url(/Image/download.aspx?Entity=…&Attribute=…&Id=…&Timestamp=…, null when empty). Setting it to abyte[]on Create or Update stores the thumbnail and, whenCanStoreFullImageis true, the full-size image in the file store; null deletes both. - Thumbnails are stored in the database (
varbinary(max)in the image column itself): the one deliberate exception to "no blobs in SQL". Dataverse returns the thumbnail inline on Retrieve and RetrieveMultiple, and reading it from the store would cost one store read per row; a thumbnail is at most 144×144 pixels, a few tens of KB. Moving thumbnails to the store later would be a data migration. - Thumbnail rules (Dataverse's): when a side is over 144 pixels, the centre square on the shorter side resized to 144×144;
otherwise the centre square on the shorter side, not resized. JPEG in, JPEG out; anything else, PNG. Image columns take
GIF, JPEG, BMP and PNG; anything else, or an image that does not decode completely, is refused with
0x80072553(ProcessImageFailure), never stored as is. - Primary image. The first image column of a table is the primary one (
IsPrimaryImage). As in Dataverse,IsPrimaryImage = trueon a create is ignored when the table already has an image column; the flag moves only with an update of another column to true. Setting it to false on the primary column is refused (0x80040203). Deleting the primary column makes the oldest remaining image column primary. On Create, only the primary image column may be set (0x80090487). - The first file or image column of a table creates the relationship
{table}_FileAttachments(1:N tofileattachmentonobjectid); deleting the last one removes it. Companions are created and dropped with their main column; deleting that relationship or changing thefileattachment.objectidlookup directly is refused (0x80040203). - AllColumns (SDK
ColumnSet(true), FetchXMLall-attributes, a Web API read without$select) does not include image columns, as in Dataverse; their companions are included. Select an image column by name to get its thumbnail.
Messages and routes
- SDK and Web API actions:
InitializeFileBlocksUpload,UploadBlock,CommitFileBlocksUpload,InitializeFileBlocksDownload,DownloadBlock,DeleteFile.IsChunkingSupportedis always true. On an image column the upload messages set the image, and the download messages read the full-size image (4040x80040217when none was kept). - Web API properties on
{entityset}({id})/{column}:PATCHwith anapplication/octet-streambody uploads in one request (at most 128 MB, 413 beyond). The name is thex-ms-file-nameheader (ASCII only) or, for other characters, thex-ms-file-namequery parameter. The MIME type is themimetypeheader, otherwise the one of the file name's extension.PATCHwithx-ms-transfer-mode: chunkedand no body starts a chunked upload: 200 withLocation(?sessiontoken=…) andx-ms-chunk-size: 4194304. Each chunk is aPATCHon thatLocationwithContent-Range: bytes a-b/total, in order: 206 until the last byte, then 204.GET …/$valuedownloads withx-ms-file-size,x-ms-file-name,mimetypeandX-Content-Type-Options: nosniff; aRange: bytes=a-b(orbytes=a-) gives a 206. On an image column it returns the thumbnail, and with?size=fullthe full-size image.GETwithout$valuereturns the property (a file's id, an image's thumbnail as base64);DELETEclears the column.
GET /Image/download.aspx?Entity&Attribute&Id&Timestamp[&Full=true](the<column>_urlof an image) is served by the host. It requires a token and Read on the record;Idis the image id. ItsContent-Typeis the one the image content shows (GIF, JPEG, BMP or PNG), whatever MIME type the upload named, withX-Content-Type-Options: nosniff.- Every upload, download and delete goes through the pipeline: a Web API
PATCHrunsInitializeFileBlocksUpload,UploadBlockandCommitFileBlocksUpload(so plug-in steps on those messages fire, as does theUpdateof the record the commit makes);DELETEruns anUpdateof the column to null. AGET …/$valueof a file or full image (and/Image/download.aspx?…&Full=true) runsInitializeFileBlocksDownloadand oneDownloadBlockper 4 MB, streamed to the response (aRangeis the offset and length of those blocks); a thumbnail is aRetrieveof the image column. - The CLI (
table column add --type file|image, schema files) and solutions create these columns: see Tables, plug-ins, data and schema files. The web admin shows a file or image column on a record's form, with upload, download and delete; on the create form these editors are disabled until the record exists.
Limits (Dataverse's values)
| Limit | Value |
|---|---|
Upload block, chunk (x-ms-chunk-size) |
4 MB (4194304 bytes) |
| Single-request Web API upload | 128 MB |
File MaxSizeInKB |
default 32768, range 1 to 10485760 |
Image MaxSizeInKB |
default 10240, range 1 to 30720 |
Thumbnail (MaxHeight, MaxWidth) |
144×144, read-only |
A file over MaxSizeInKB is refused by the block that crosses it, not only at commit (0x80044a02 "Attachment file size
is too big."; an image 0x80072553), and its staged blocks are deleted.
Security and sessions
- Upload and delete require Write on the record, download Read.
- A continuation token (and the Web API
sessiontoken) is opaque and bound to the caller, the record and the column. A token from another caller, tampered or expired, is refused with0x80040203and the same message whatever the reason, so it does not reveal whether a session exists. Sessions (meta.filesession) survive restarts; they expire 24 hours after their last use. - An hourly cleanup sweep removes expired sessions and their staged blocks, and blobs without a
fileattachmentrow older than 24 hours.
Commit, cascades and failures
- A commit replaces the column's previous file. The new blob is written before the transaction commits and deleted if it
rolls back (for example when a plug-in on the
Updatethrows); the previous blob is deleted after the commit. - Deleting a record deletes its files; dropping a file or image column deletes all its files; deleting a table (also by a cascade solution delete) deletes all the files of that table. Blobs are deleted after the transaction commits, by an in-process background queue (the request does not wait for the store). A failed blob deletion is logged and retried by the sweep, never reported to the caller; so is one that never ran (the queue was full, or the process stopped first).
Note bodies. The attached file of a note (annotation.documentbody, see Activities and notes)
shares this store. Each body is a fileattachment row (objectid the note, regardingfieldname documentbody) and a blob
{organization database}/{fileattachmentid}, written in the note's transaction with the same commit and rollback rules as a
file column. annotation is always a target of fileattachment.objectid (relationship annotation_FileAttachments, our own
name), so the cleanup sweep never takes a body for an orphan; deleting a note, or the record it belongs to, deletes its body
after the commit; backups and restores carry the bodies with the other files. The file messages and routes do not serve
note bodies: write and read documentbody with Create, Update, Retrieve and RetrieveMultiple. A note body's fileattachment
row is readable only through Read on the note.
Backup and restore. org backup writes {db}-{timestamp}.files.tar beside the .bak, and org restore needs it
unless --no-files is passed: see Organizations and backups for the volumes,
Admin:FilesBackupDirectory, the missingFiles report and exit code 7.
Known differences from Dataverse
DownloadBlockreturns at most 4 MB per block: a largerBlockLengthis refused with an explicit0x80040203(Dataverse publishes no limit). Read a large file in several blocks.DownloadBlockwith anOffsetequal to the file size (past the last byte) is refused (0x80040203), except at offset 0 of an empty file.- Read and Write are checked when a session opens, not again for each block: the token is bound to the caller, the record and the column, and stays valid for 24 hours after its last use.
- Images are decoded up to 64 Mi pixels (67,108,864): a larger one is refused with
0x80072553(a server-side bound, not a Dataverse rule). JPEG decodes at a reduced scale; a PNG, GIF or BMP near the bound can take about 256 MB of memory while its thumbnail is made, with at most one decode per processor at a time. EXIF orientation is not applied to thumbnails. - Web API: the property routes
{entityset}({id})/{property}serve file and image columns only. A single-propertyGETorDELETEof any other column (Dataverse supports, for example,GET accounts(<id>)/name) is not supported and is refused with 4000x80040203; an unknown property is a 400 too (Dataverse probably answers 4040x80060888). A malformedRange(several ranges, a suffix range, another unit) is a 400. - A file name that is not printable ASCII, or that contains
%, is percent-encoded (UTF-8) in thex-ms-file-nameresponse header, so decoding the header always gives the name back (100%25.txtis sent as100%2525.txt); any other ASCII name is sent as is. How Dataverse encodes such names is not documented. - An upload session stages at most 50,000 blocks before its commit: a new block beyond that is refused with
0x80040203(a server-side bound, as an Azure block blob's block list; Dataverse publishes none). DeleteFilewith the image id of an image kept without its full-size image (CanStoreFullImagefalse when it was set) answers 4040x80040217: such an image has nofileattachmentrow. Clear it with anUpdateof the column to null. What Dataverse answers there is not documented.- Asynchronous plug-in steps on
UploadBlock,DownloadBlockor theUpdatean image commit makes keep their execution context, block or image bytes included, in theasyncoperationrow until they run: an exception to "no blobs in SQL" (thumbnails being the other one). Register such steps synchronously, or on other messages, to keep file content out of the database. - If the commit of a chunked Web API upload fails after its last chunk was received, sending that chunk again is refused (the chunk is already staged): start a new upload session.
- The Web API's file routes are executed as the file messages, so their plug-in steps fire: a
PATCHupload fires steps onInitializeFileBlocksUpload,UploadBlockandCommitFileBlocksUpload; aGET …/$valueof a file or full image (and/Image/download.aspx?…&Full=true) fires steps onInitializeFileBlocksDownloadandDownloadBlock; a thumbnail download fires steps onRetrieve. Whether Dataverse does the same is not documented. $value?size=full(and/Image/download.aspx?…&Full=true) on an image kept without its full-size image answers 204 with no body. The download messages answer 4040x80040217in the same case.- A
$selectof an empty column leaves the property out of the Web API response, where Dataverse returns"<name>": null. This is general xrmverse Web API behaviour (every column type: null values are omitted when rows are read), not specific to file and image columns; it is on the backlog below. - The
customizations.xmlshape of file and image attributes (Typefile/image,MaxSizeInKB,CanStoreFullImage,IsPrimaryImage,MaxHeight/MaxWidth144) follows the SDK property names and has not been checked against a real Dataverse export. Companion columns and{table}_FileAttachmentsare never exported; an import that finds them tolerates them. fileattachment.objectidis a polymorphic lookup to every table with a file or image column, but$metadataexposes its navigation property for the first target only.$expand=objectid_{table}works for every target (for examplefileattachments(<id>)?$expand=objectid_annotationreturns the note with its body), but only the first target's navigation is declared; in a new organization that isannotation.- Backlog (not yet):
- the hourly cleanup sweep lists the whole
{database}/prefix of the store on each run, which grows with the number of files: a scale limit for very large stores (an incremental or event-driven sweep would remove it); - Web API responses that return
nullfor the empty properties of a$select(see above).
- the hourly cleanup sweep lists the whole
The manual acceptance checklist is
docs/superpowers/acceptance/files-checklist.md.