Primary Data Storage
This document describes the core relational document data storage used for data modifications.
The application architecture will have a plugin system that enables others to customize the database storage. The design described below will be implemented with Microsoft SQL Server (MSSQL) and PostgreSQL plugins that come out of the box with the Data Management Service. Other designs could be built and implemented, so long as the REST API implementation remains unchanged.
Problems to solve via DB design
Existence and reference validation
The first problem is existence and reference validation. Unlike Meadowlark, we want to leverage foreign key referential integrity to do this. Note we need to be able to take into account superclass identities, for example the existence of an EducationOrganization can satisfy reference validation for a School reference, so there has to be some mapping here. We would also like reference validation to be tunable in some fashion, meaning you can turn it on or off.
Query support in relational DB
The next problem is query support. We'll need to know what the different fields are that you're allowed to query on, and then have some way to index them into the json.
One question is how performant does this need to be? If/when it becomes too slow, moving to a search engine would be the recommendation because if you want true performance you would use a separate read only store.
Also see Queries Using the Relational Database and DMS Feature: Read-only Search Database
Document Security
The next thing that you need to be able to support is security. Following the ODS/API, we'll want Namespace-based for sure, Education Organization-based probably, and possibly Grade Level-based as well. In some ways this may be similar to query support, as we'll need to know the relevant fields to secure on for each resource.
Also see Relational Support for Client Authorization.
Streaming changes
We'll need the ability to stream out changes. This needs to be something that can be easily integrated with Kafka and potentially other streaming platforms, with a well-defined message format. Change query support is related to this, but we won't prejudge whether it will be based off of streaming.
Simplicity
As maintainability is a primary goal, we want to keep DMS as simple as possible. Meadowlark demonstrated that we can simplify over the ODS/API by avoiding code-per-resource and instead use a generalized design applicable to any resource. While it may not be possible with DMS to do this completely, we would like to do it whenever possible.
Performance
We need to make sure that we design to meet performance goals, and test early and often to ensure that we are able to hit those goals.
Solution
See Design Options for Data Management Service Data Storage for detailed analysis of potential solutions, including drawbacks and mitigating factors.
General Structure
This is very much like the Meadowlark implementation of the PostgreSQL backend, except we want to take
advantage of foreign key constraints for reference validation. We also want to introduce partitioning. All
three tables use the sequential surrogate key pattern with size BIGINT.
Documents Table
The Documents table holds all of the documents for all of the entities. id is the sequential surrogate
primary key. document_uuid is the external GUID expressed in the API as the resource id. It will be indexed
as unique and non-clustered to support both document_uuid uniqueness validation as well as direct access for
GET/UPDATE/DELETE by id operations. document_partition_key is included as part of the primary key. It is
derived from the document_uuid, either as a modulo or by taking low-order bits, and maps to a partition
number. This will allow the index on document_uuid to be partition-aligned.
Documents also includes metadata about the document, such as project name, resource name and resource
version. The table will also include the document itself as edfi_doc, which will be stored compressed and
off-row.
Aliases Table
The Aliases table is a partitioned table that maps documents to their referential id(s). It also has id as
a sequential surrogate primary key.
referential_id is a UUIDv5 (see RFC 4122) with
an Ed-Fi namespace ID as the "namespace" and the resource name + the extracted document identity as the
"name". This concept of a deterministic hash UUID allows DMS to determine both document identities and
document references independent of data in the DB. Each document has at least one referential id. Only
subclass documents have a second referential id, which is the document identity in the form of its superclass.
referential_id will be indexed as unique and non-clustered to support referential_id uniqueness validation.
referential_partition_key is included as part of the primary key. It is derived from the referential_id,
either as a modulo or by taking low-order bits, and maps to a partition number. This will allow the index on
referential_id to be partition-aligned.
Aliases has a foreign key reference back to the document with this referential_id.
Delete attempts from the Aliases table validate that a document is not referenced by another document.
References Table
The References table stores every document reference. It also has id as a sequential surrogate primary
key. It shares document_partition_key as its own partition kay as part of the primary key.
The table is composed of a document_id foreign key reference back to the Documents table for the parent
document of the reference, and a referenced_alias_id foreign key reference back to the Aliases table for
the document being referenced. The purpose of the Aliases foreign key constraint is to perform reference
validation. Insert attempts into this table validate reference existence. document_id will be indexed as
non-unique, non-clustered and partition-aligned to support removal on document deletes and updates.
Why not a table per resource?
The benefit of this design is that by putting all documents in a single table regardless of resource, we can use three simple tables to provide reference validation via referential integrity between any two documents using their extracted referential_id(s). If we split the tables up by resource, we will also require a specific join table for every resource-pair participating in a reference, which (1) greatly increases complexity and (2) is redundant because referential_id already encodes the resource type.
Why partitioning?
This design is for three very large tables. It's important to note that the largest US school district with
positive attendance tracking could have on the order of 450 million attendance records in a school year. Since
a DMS instance will store a lot more that just attendance, we are targeting support on the order of 1
billion rows in the Documents table. If we estimate that each document has on the order of 10 references to
other documents (they can have arrays of references), then we need to be able to support on the order of 10
billion rows in the References table.
Determining a good default number of partitions for each table will require experimentation, but 16 partitions
for Documents and Aliases and 64 partitions for References is probably a good starting point. See
here
for a demo on experimenting with partitioning to find a good size. This will be a hash method of partitioning,
where we compute the partition_key from an appropriate GUID in the table to allow for partition elimination
on queries and partition-aligned indexing.
Query handling
See Queries Using the Relational Database.
Planning ahead for performance considerations
- The primary
Documentstable will use abigint(64 bit integer) for the primary key. - Out of the box, the
Documentswill be split into 16 different partitions. These partitions are in different files, thus reducing contention compared to a single partition in one gigantic file. - A partition key column, based on the primary key, will improve indexing and will help distribute all of the stored records evenly across the available partitions.
- An implementation must be able to modify to more or fewer partitions as desired. In this design, the partition key limits the potential number of partitions to 256.
The development team has executed initial experiments to compare some aspects of
performance between this structure and the ODS database from the ODS/API
Platform v7. Similar records were inserted into three tables: Student,
StudentSchoolAssociation, and StudentSectionAssociation. Running a virtual
machine with SQL Server 2022 and Windows Defender disabled, large numbers of
inserts were run and timed for direct comparison of the insert performance. The
following tables summarize the initial findings with 1 million records:
| Database | Insert Time | Storage (KB) |
|---|---|---|
| DMS | 02:49:42 | 571,528 |
| ODS | 03:22:34 | 1,268,968 |
The techniques used in these experiments do not provide perfect comparisons, but we believe these results are "essentially" comparable. And that comparison is very favorable, supporting the hypothesis of high performance with this database design.
Implementation
The proof-of-concept process helped uncover some details that need to guide the implementation:
- Compared to
varbinary, usingvarcharfor the JSON documents results in higher performance, albeit at the cost of approx 20% higher disk space usage. - The number of partitions can be configured at the application level, although it must not be updated after deployment without downtime to re-organize the existing records. Partition function can be in C#.
- All operations across these tables need to be in a single atomic transaction.
- Open question: should the transaction be opened and closed in the business layer or in the data access layer?
- Thinking ahead to the potential of query tables, they might be handled in a different class than the main logic. In that case, if transactions are handled in the data access layer, then it may make sense to have an internal facade so that the business layer only has one call to make. The facade then hides the complexity of transaction handling and making multiple repository calls.
The following sequence diagram gives a sense of the potential application design:
Insert Operation
From DMS Core:
- JSON Document
- Document Metadata
- Document UUID - generated
- Document Referential Id - extracted
- If a subclass, the Document Referential Id in superclass form
- Referential Ids of Document references - extracted
Transaction:
- UPSERT If the natural key already exists, call the Update stored procedure and return its result.
- Insert the JSON Document, Document Metadata and Document UUID in the
Documentstable.- Derive
document_partition_keyfromdocument_uuid. - Get the sequential id from the insert for the next operation.
- A uniqueness constraint violation on
document_uuidmeans this should be retried as an update.
- Derive
- Insert an entry in the
Aliasestable for the document.- Derive
referential_partition_keyfromreferential_id. document_idis this document's sequential id from theDocumentsinsert.document_partition_keyid also from theDocumentsinsert.- If the document is a subclass, insert a second entry with
referential_idin superclass form. - A uniqueness constraint violation on
referential_idon the first insert means this should be handled as an update. - A uniqueness constraint violation on
referential_idon the superclass insert means failure because there already exists a subclass with the same superclass identity.
- Derive
- Insert each document reference on the document in the
Referencestable.document_idis this document's sequential id from theDocumentsinsert.document_partition_keyid also from theDocumentsinsert.- Determine
referenced_alias_idandreferenced_partition_keyfrom a lookup on theAliasestable index forreferential_id. - A missing
referential_idlookup onAliasesindicates a reference validation failure.
Update Operation (no identity update)
From DMS Core:
- JSON Document
- Document Metadata
- Document UUID
- Referential Ids of Document references - extracted
Transaction:
- Find the document in the
Documentstable- Derive
document_partition_keyfromdocument_uuid. - Find the document in
Documentsusing the index ondocument_uuid.- If the document does not exist, fail the transaction and return immediately.
- This shouldn't happen, but let's make sure to throw a good error if it does.
- Get
idas the document id.
- Derive
- Delete the old document references
- Delete document references on the
Referencestable using the index ondocument_idanddocument_partition_key.
- Delete document references on the
- Insert the new document references
- Insert each document reference on the updated document as in the insert operation.
- Update the JSON document itself on the
Documentstable.
Update Operation (with identity update)
From DMS Core:
- JSON Document
- Document Metadata
- Document UUID
- Document Referential Id - extracted
- If a subclass, the Document Referential Id in superclass form
- Referential Ids of Document references - extracted
Transaction:
- Find the document in the
Documentstable- Derive
document_partition_keyfromdocument_uuid. - Find the document in
Documentsusing the index ondocument_uuid.- If the document does not exist, fail the transaction and return immediately.
- Get
idas the document id.
- Derive
- Get the Aliases table entry for the document (don't worry about superclass entry)
- Find the alias(es) in
Aliasesusing the index ondocument_id. - Delete the original aliases entries for the
document_id- _ Currently no index _
- Add the new alias(es) to the
Aliasestable.
- Find the alias(es) in
- Delete the old document references
- Delete document references on the
Referencestable using the index ondocument_idanddocument_partition_key.
- Delete document references on the
- Insert the new document references
- Insert each document reference on the updated document as in the insert operation.
- Update the JSON document itself on the
Documentstable.
TODO: describe desired cascading behavior.
Delete Operation
From DMS Core:
- Document UUID
Transaction:
- Follow update operation steps through deleting old document references.
- Delete the document's aliases in the
Aliasestable.- A foreign key constraint violation indicates a reference validation failure due to the document having references to it.
- Delete the document in the
Documentstable.
TODO: describe desired cascading behavior.
SQL DDL
Use the SQL statements in the proof-of-concept code. Be sure to use the VARCHAR version
rather than VARBINARY. Do not apply any foreign key cascades in the initial work.