Tuesday, July 08, 2008

Well, we're just back from vacation (photo blog post to follow) and I've heard that the feature article on Effective Database Maintenance I wrote for the August issue of TechNet Magazine is live on the web. It also includes a 5 minute long screencast I recorded where I demo the effect of database shrink on index fragmentation.

You can get to the article at http://technet.microsoft.com/en-us/magazine/cc671165.aspx. The topics covered are:

  • Managing data and transaction log files
  • Eliminating index fragmentation
  • Ensuring accurate, up-to-date statistics
  • Detected corrupted database pages
  • Establishing an effective backup strategy

It's written around 2-300 level and presents a good overview (well, at least I think so :-)) of the concepts involved.

Also, the August SQL Q&A column is available at http://technet.microsoft.com/en-us/magazine/cc671180(TechNet.10).aspx. This month's topics on the web (more in the print magazine) are:

  • Database version changes with upgrades
  • Benefits of partitioning
  • Consistency checking options for VLDBs

Enjoy!

Tuesday, July 08, 2008 9:35:10 AM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Thursday, June 26, 2008

One problem (the only one!) of going on vacation with Kimberly is that can be hard to banish SQL Server completely from conversation. Over breakfast this morning we were discussing the pros and cons of advising someone to use sp_attach_single_file_db as a way to shrink an out-of-control transaction log - with careful guidance it can be done, but there's a lot of scope for misuse and getting into trouble.

One problem with being on vacation in general is that your mind wanders away from the normal bounds of rational thought (well, at least mine does...) While discussing the merits of shrinking transaction logs I was cutting up my eggs and mused aloud on how much easier it was to divide an egg in half when it was scrambled compared to when it was raw - you can get a nice Euclidian straight edge. After that Kimberly had nothing else to say about transaction logs :-)

Then I wondered how far away we are from the mainland (we're on Maui for a week, then on a live-aboard dive boat out of Kona - the Kona Aggressor - for another week). Luckily the waitress brought the breakfast check so I spent 5 minutes doing the a2 = b2 + c2 calculation (where a was our flight length from Seattle, b is the distance south from Seattle, and c is the distance from the mainland). Figuring about 2700 miles for the flight, and 2000 miles south of Seattle (and no-doubt convincing everyone around us that I needed to use long multiplication, scientific notation, long division, and geometric figures to calculate the tip on the breakfast check), I came up with roughly 1800 miles as the distance of Hawaii from the mainland. In reality, the distance is about 1625 miles - not bad!

This is my first trip to Hawaii (and Kimberly's fourth, but first to Maui) - it's a very cool place. On Tuesday we took a long helicopter tour around the island (courtesy of Blue Hawaiian Helicopters) which gave us some stunning views of the volcanic scenery (we're doing a similar tour of the Big Island after the dive trip). Today we're going to drive to the top of the 10000 foot volcano to watch the sunset and do some bird-watching. Here are a few photos:

 

 

Ok - back to vacation...

Thursday, June 26, 2008 2:14:03 PM (Pacific Standard Time, UTC-08:00)  #    Comments [2]  | 
Monday, June 23, 2008

As well as the usual round of conferences later this year, we've also organized some public classes in the UK after lots of requests. In between these two classes we'll be hopping over to Dublin to do a launch seminar for Microsoft on SQL Server 2008 - more details on that as they become available.

The UK classes are organized with our UK partners SQLKnowHow.com. We haven't taught in the UK since a one-day seminar we did with Tony Rogerson (one of the founders of SQLKnowHow) back in March last year so this is pretty exciting (and the Edinburgh class will be at my old alma-mater, The University of Edinburgh). The complete line-up is below - register now to avoid disappointment as the classes are filling up fast.

Best Practices in Performance and Availability for SQL Server 2005/2008

  • When: 1st to 3rd September, 2008
  • Where: Hatfield, Hertfordshire
  • Who: Paul and Kimberly
  • How much: See here for details, discounts, and early-bird specials
  • What:

    This class has three primary goals (for almost all topics/modules): planning, practice/implementation and post-mortem - with the largest emphasis on designing/implementing the RIGHT solution. Questions that you must ask are: How do you choose technologies to fit requirements and effectively use key features of SQL Server 2005/2008? How does your technology/choice affect workload performance?

    Only after an in-depth plan is developed should you move on to actual implementation. So what are the areas that you need to consider?

    • Architecting for Availability
    • Architecting for Performance
    • Maintaining Performance and Availability

    And just to be clear, this is not a high-level class on planning. This is an intense, in-depth class encompassing structures, internals, technologies and solutions. Planning is a critical part of performance, high-availability, database maintenance and disaster recovery - but the most-often disregarded.

    Performance tuning spans many areas within SQL Server from database creation to database design to the code you execute (ad-hoc or procedural). A single magic bullet does not exist (indexing is the closest thing to a magic bullet for some queries). However, to achieve a truly scalable and reliable database it takes a variety of best practices - from database creation (including file structure and placement) to table design and creation (using vertical and horizontal partitioning techniques) to system architecture (including disaster recovery planning and implementation) to ongoing maintenance. Whether you're trying to achieve high performance for a few users or scale to support thousands, there are numerous areas that you can tune to improve performance - proactively. But, how do you make this a reality?

    SQL Server 2005 and 2008 provide a variety of options to help keep your database more available. However, even in the event of a disaster, are you sure you know the best path for recovery - with the least amount of downtime and/or data loss? Putting a well-thought out plan into practice requires a thorough understanding of the technologies, their pitfalls and the effects of many technologies when combined. In terms of architecture, we will start by discussing the most important part of designing an available solution - requirements. Then we'll show how to use requirements to drive a technology decision - not the other way around, which happens so often and results in an inadequate implementation.

    No matter how much effort you spend on the design of your database, if you don't maintain it in production then it will suffer from performance and manageability problems - and possibly data loss and/or downtime. The key to availability and performance is well thought-out and automated database maintenance. The final part of the course will discuss maintenance strategies required to keep your carefully designed system available and performing well, plus a primer on recovering from disasters.

    If you're planning, or already manage, an enterprise system and want better performance and availability - then this is the place to be!

    Module List:

      1. Foundations - SQL Server structures and algorithms
      2. Architecting for Availability
      3. Architecting for Performance
      4. Maintaining Performance and Availability

Indexing for Performance in SQL Server 200/2005/2008

  • When: 8th to 9th September, 2008
  • Where: Edinburgh
  • Who: Paul and Kimberly
  • How much: See here for details, discounts, and early-bird specials
  • What:

    There are many areas of performance tuning in SQL Server: database design, application design, hardware/software configuration, and many more. But none are as important as indexing. Creating the "right indexes" is the most important thing you can do for performance and scalability. Is proper indexing something your application is missing? Do you realize the impact of your clustering key; forcing your base structure of your tables to be either ordered or unordered. If ordered is chosen, by what type of column(s) should the data be ordered? Is the decision solely based on query performance or are there other factors?

    Whether your system is 24x7 or a small system just trying to setup for future growth and improved performance this course is for you! We will cover the often-overlooked impacts of poorly chosen clustered indexes, where/why clustered indexes help the most and how the type of table and the type/frequency of your queries affect your decisions. Additionally, once the internals, statistics and base table structures have been defined, we will talk about indexing strategies for search arguments (including SQL Server 2008 Filtered Indexes), joins, aggregations and appropriate uses for indexed views. Finally, we'll discuss index maintenance as well as how to evaluate your indexing strategy over time to make sure it remains appropriate as your data and workload changes.

    If you want better performance and excellent insight into the wide range of indexing strategies - as well as how things work internally, this is the place to be!

    Course Modules

    1. Index Internals
    2. Statistics
    3. Indexing Strategies, Part I: SARGs and Joins
    4. Indexing Strategies, Part II: Aggregations and Indexed Views
    5. Index Maintenance
    6. Is Your Indexing Strategy Working?
Monday, June 23, 2008 5:07:12 AM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Thursday, June 19, 2008

TechEd US is done for another year! As I mentioned before, we did a lot of stuff but still found time to chill by the pool a few times in the Speaker Hotel. This was my first US TechEd since leaving Microsoft last year so it was quite interesting seeing the organizational side of things from the outside. I was particularly pleased that my new Surviving Corruption - From Detection To Resolution session clinched a prestigious top-10 rating (#6) for the whole conference - look out for it at all the other conferences I'll be at this year (next post today...)

Edit: Forgot to say - thanks to all those in the Olympia, WA User Group who came out yesterday to see us present the Surviving Corruption session!

We've already started posting scripts from our session demos (see the Past Conferences page) and I'm blogging detailed walkthroughs of my demos from the corruption session in my CHECKDB From Every Angle series. The online panel we did hasn't been released yet on the TechEd Online site - I'll blog when it is.

Now we're off for a couple of weeks of real vacation - flying, diving, bird-watching, and best of all, not working!

I'll leave you with my usual conference wrap-up... thanks to Carlos Santillana for the photos!

Thursday, June 19, 2008 2:30:20 PM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Wednesday, June 11, 2008

Today I presented my brand new session Surviving Corruption: From Detection to Recovery at TechEd. I had a lot of fun putting together the demos, presenting the session, and talking to people afterwards. During the session, I promised to blog each of the demos so that everyone can run through them - here's the first one.

On SQL 2000, it was pretty easy to get into the system tables and manually change them - all you had to do was:

EXEC sp_configure 'allow updates', 1;
GO
RECONFIGURE WITH OVERRIDE;
GO

And then you could insert, update, and delete whatever you wanted in the all the system tables, including the critical three - sysindexes, sysobjects, and syscolumns. The problem was that sometimes people actually did this and messed things up - for instance, by manually deleting an object from sysobjects, but leaving around all the other info about the object - such as indexes and columns. DBCC CHECKCATALOG in SQL 2000 would find this, but DBCC CHECKDB would not - as it didn't run the DBCC CHECKCATALOG code - any most people do not run DBCC CHECKCATALOG at all. Many times now, I've seen databases upgraded to 2005 and suddenly DBCC CHECKDB is reporting metadata corruption errors - all because someone had manually changed the system tables on 2000, and I changed DBCC CHECKDB in 2005 to include the DBCC CHECKCATALOG checks.

This demo is all about that. I created a 2000 database, manually deleted a row in sysobjects and then upgraded the database to 2005. The corrupt database is available in a zip file - DemoCorruptMetadata.zip. If you unzip it into a folder C:\SQLskills then you can attach it using:

RESTORE DATABASE DemoCorruptMetadata FROM DISK = 'C:\SQLskills\DemoCorruptMetadata.bak'
   
WITH MOVE 'DemoCorruptMetadata' TO 'C:\SQLskills\DemoCorruptMetadata.mdf',
   
MOVE 'DemoCorruptMetadata_log' TO 'C:\SQLskills\DemoCorruptMetadata_log.ldf',
   
REPLACE;
GO

So what does the corruption look like on 2005?

DBCC CHECKDB (DemoCorruptMetadata) WITH NO_INFOMSGS, ALL_ERRORMSGS;
GO
Msg 8992, Level 16, State 1, Line 1
Check Catalog Msg 3853, State 1: Attribute (object_id=1977058079) of row (object_id=1977058079,column_id=1) in sys.columns does not have a matching row (object_id=1977058079) in sys.objects.
Msg 8992, Level 16, State 1, Line 1
Check Catalog Msg 3853, State 1: Attribute (object_id=1977058079) of row (object_id=1977058079,column_id=2) in sys.columns does not have a matching row (object_id=1977058079) in sys.objects.
CHECKDB found 0 allocation errors and 2 consistency errors not associated with any single object.
CHECKDB found 0 allocation errors and 2 consistency errors in database 'DemoCorruptMetadata'.

This is what we expect. Notice that there's no recommended repair level at the end of the output - this is because CHECKDB can't repair metadata corruptions. We can't fix this with a backup - unless we have a backup from 2000 from before the manual delete in the system tables. To fix this we'd need to go back to 2000, fix the corruption, and then upgrade again - usually not feasible.

Instead, we're going to fix it by manually altering the system tables in 2005 - something that's purportedly not possible. First let's see what tables there are that could include column information (remembering that the system catalogs were completely rewritten between 2000 and 2005):

SELECT [name] FROM DemoCorruptMetadata.sys.objects WHERE [name] LIKE '%col%';
GO

name
------------------
sysrowsetcolumns
syshobtcolumns
syscolpars
sysiscols

I know that sysrowsetcolumns and syshobtcolumns are involved at low-levels of the Storage Engine and don't contain relational metadata, so let's try syscolpars. I want to see what columns there are to see if one of the looks like an object ID, and another looks like a column ID. This query will just return the table columns, with no rows (because the condition 1=0 is always false:

SELECT * FROM DemoCorruptMetadata.sys.syscolpars WHERE 1 = 0;
GO

Msg 208, Level 16, State 1, Line 1
Invalid object name 'DemoCorruptMetadata.sys.syscolpars'.

I can't bind to internal system tables in 2005. But - I can bind to internal system tables using the Dedicated Admind Connection (or DAC for short). This is documented in Books Online at http://msdn.microsoft.com/en-us/library/ms179503.aspx. You can get to the DAC through SQLCMD using the /A switch. So - assuming I'm now connected through the DAC, I'll try that command again:

C:\Documents and Settings\paul>sqlcmd /A
1> USE DemoCorruptMetadata;
2> GO
Changed database context to 'DemoCorruptMetadata'.
1> SELECT * FROM sys.syscolpars WHERE 1=0;
2> GO
id          number colid       name

xtype utype       length prec scale collationid status      maxinrow xmlns
 dflt        chk         idtval

----------- ------ ----------- -------------------------------------------------
-------------------------------------------------------------------------------
----- ----------- ------ ---- ----- ----------- ----------- -------- -----------
 ----------- ----------- -------------------------------------------------------
-----------

(0 rows affected)
1>

This looks like the table. Now I'll query against it using the object ID from the original corruption message:

1> SELECT colid, name FROM sys.syscolpars WHERE id = 1977058079;
2> GO
colid       name
----------- --------------------------------------------------------------------
------------------------------------------------------------
          1 SalesID
         
2 CustomerID
(2 rows affected)
1>

Cool. So I'll try deleting the orphaned columns:

1> DELETE FROM sys.syscolpars WHERE id = 1977058079;
2> GO
Msg 259, Level 16, State 1, Server ROADRUNNERPR, Line 1
Ad hoc updates to system catalogs are not allowed.
1>

Hmm. And it doesn't help if I set 'allow updates' to 1, or try putting the database into single-user mode.

There IS a way though. You can put the SERVER into single-user mode, then connect with the DAC and you can then update the system tables. This particular twist on using the DAC isn't documented anywhere except in an MSDN forum thread answered by someone from Microsoft (see here).

BEWARE (if I could put little flashing lights around this too then I would...) that this is undocumented and unsupported - misuse will lead to unrepairable corruption of your databases.

The sequence of events to follow is:

  • make a backup of the database just in case something goes wrong
  • shutdown the server
  • go to the binaries directory (e.g. C:\Program Files\Microsoft SQL Server\MSSQL.1\MSSQL\Binn) and start the server in single-user mode using 'sqlservr -m'
  • connect back in using SQLCMD /A, and run the deleta again. This time it will work, but will give an error about metadata cache consistency:

C:\Documents and Settings\paul>sqlcmd /A
1> USE DemoCorruptMetadata;
2> GO
Changed database context to 'DemoCorruptMetadata'.
1> DELETE FROM sys.syscolpars WHERE id = 1977058079;
2> GO

(2 rows affected)
Warning: System table ID 41 has been updated directly in database ID 12 and cache coherence may not have been maintained. SQL Server should be restarted.
1>

  • The system table has been updated, but the in-memory cache of metadata is now out-of-sync with the system tables. So, shutdown the server again as the message suggests and restart it normally
  • run CHECKDB again and you'll see the corruption has been fixed.

Hope this helps some of you. Watch this space for the next demo from TechEd of repairing corruption when no backup is available.

Wednesday, June 11, 2008 5:42:23 PM (Pacific Standard Time, UTC-08:00)  #    Comments [3]  | 
Monday, June 09, 2008

(I'm actually on-stage here at TechEd doing the  DAT track pre-con with Kimberly - she's on now until lunch so I'm catching up on forum problems...)

Here's a question that came up on of the SQLServerCentral.com corruption forums I monitor that I think is worth blogging about. To paraphrase:

I have a bunch of corruptions in a database, that look like they've been there for a while. Repair is my only option - it works but I'd like to know what data is being deleted. How can I do that? Here are some of the errors:

Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168576) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168577) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168578) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168579) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168580) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168581) could not be processed. See other errors for details.
Server: Msg 8928, Level 16, State 1, Line 2
Object ID 645577338, index ID 0: Page (1:168582) could not be processed. See other errors for details.
Server: Msg 8976, Level 16, State 1, Line 2
Table error: Object ID 645577338, index ID 1. Page (1:168576) was not seen in the scan although its parent (1:165809) and previous (1:168575) refer to it. Check any previous errors.
Server: Msg 8978, Level 16, State 1, Line 2
Table error: Object ID 645577338, index ID 1. Page (1:168583) is missing a reference from previous page (1:168582). Possible chain linkage problem.

This is a clustered index that CHECKDB  will repair by deleting pages at the leaf-level - essentially deleting a bunch of records. The pages look to be trashed (there were a bunch more errors that I didn't include here that said the page headers were all corrupted - looked like the IO subsystem trashde a whole 64KB chunk of the disk) so there's nothing much else you can do. As the table has a clustered index, you can use the error messages to find the pages on either 'logical' side of the pages being deleted - and hence figure out the range of records that have been deleted.

The errors show that pages 168576 through 168582 in file 1 are corrupt. There are also errors that say the previous page of 168576 is 168575, and the next page of 168582 is 168583. If you do a DBCC PAGE of these two pages, you can find the lower and upper bound of the clustered index key values that have been lost. Think of three ranges:

  • the lower range of records that are intact, logically before the corrupt pages in the index
  • the range of records that will be deleted by repair
  • the upper range of records that are intact, logically after the corrupt pages in the index

To find the upper bound of the lower range:

DBCC TRACEON (3604); -- allows the output to come to the console
DBCC PAGE ('dbname', 1, 168575, 3);
GO

The key value in the slot at the end of output is the upper bound of the bottom range that's intact.

Then do:

DBCC PAGE ('dbname', 1, 168583, 3);
GO

The key value in the slot at the beginning of the output is the lower bound of the upper range that's intact.

Everything in the middle will be deleted. You could also try a DBCC PAGE on the corrupt pages themselves too - you might be able to see some data in them.

I'll be blogging a bunch more about repair after my corruption session this week at TechEd - watch this space!

Monday, June 09, 2008 7:54:32 AM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Sunday, June 08, 2008

Over the last few weeks I've seen (and helped correct) quite a few myths and misconceptions about index rebuild operations. There's enough now to make it worthwhile doing a blog post (and it's too hot here in Orlando for us to go sit by the pool so we're both sitting here blogging)...

Myth 1:  index rebuild pre-allocates the necessary space

This myth has two variations:

  1. The space for the new copy of the index is pre-allocated
  2. The space for the sort portion of the rebuild is pre-allocated

Neither of these are true. Index rebuild (whether online or offline, and at least as far back as 7.0) will create a new copy of the index before dropping the old copy. The pages and extents required to do this will always be allocated as needed, as with any other operation in SQL Server. The sort phase of an index rebuild, if required (in certain cases it is skipped in 2005), will adhere to the same allocation behavior.

Myth 2: indexes are rebuilt within a single file in a multi-file filegroup

This is a new one that I just heard yesterday - (paraphrasing) "In a two-file filegroup, an index in file 1 will be rebuilt into file 2. The next time it is rebuilt, it will be built in file 1. And so on".

This is untrue. Any time any allocations are done in a multi-file filegroup, the allocations are spread amongst all the files using the allocation system's proportional fill algorithm. In a nutshell, this says that space will be allocated more frequently from larger files with more free space than from smaller files with less free space. There is no concept in SQL Server of limiting allocations to a particular file in a multi-file filegroup.

Myth 3: non-clustered indexes are always rebuilt when a clustered index is rebuilt

This is untrue. The rules are a little complex here but can be summed up as follows:

  • In 2005+, rebuilding a unique or non-unique clustered index (without changing its definition) will NOT rebuild the non-clustered indexes
  • In 2000:
    • Rebuilding a non-unique clustered index WILL rebuild the non-clustered indexes
    • Rebuilding a unique clustered index will NOT rebuild the non-clustered indexes

The first few service packs of 2000 had bugs that changed the behavior of rebuilding unique clustered indexes back and forth - this is the source of much of the confusion around this myth.

For a much more detailed discussion of this, see my blog post from last Fall - Indexes From Every Angle: What happens to non-clustered indexes when the table structure is changed?.

Myth 4: BULK_LOGGED recovery mode decreases the size of the transaction log and log backups for an index rebuild

This myth is partly true.

Switching to the BULK_LOGGED recovery mode while doing an index rebuild operation WILL reduce the amount of transaction log generated, which is very useful for limiting the size of the transaction log file (note I say 'file', not 'files' - you only need one log file).

Switching to the BULK_LOGGED recovery mode while doing an index rebuild will NOT reduce the size of the transaction log BACKUP. Although the operation will be minimally-logged, the next transaction log backup will read all the transaction log since the last backup plus all the extents that were changed by the minimally-logged index rebuild. This will result in a log backup that's almost exactly the same size as for a fully-logged index rebuild. The ONLY time a log backup will contain data extents is when a minimally-logged operation has taken place since the last log backup - see here on MSDN for more info.

If you're considering using the BULK_LOGGED recovery mode, beware that you lose the ability to do point-in-time recovery to ANY point covered by a transaction log backup that contains even a single minimally-logged operation. Make sure that there's nothing else happening in the database that you may need to effectively roll-back with P.I.T. recovery. The operations you should perform if you're going to do this are:

  • In FULL recovery mode, take log backup immediately before switching to BULK_LOGGED
  • Switch to BULK_LOGGED and do the index rebuild
  • Switch back to FULL and immediately take a log backup

This limits the time period in which you can't do P.I.T. recovery.

Myth 5: online index rebuild doesn't take any locks

This myth is untrue. The 'online' in 'online index operations' is a bit of a misnomer.  Online index operations need to take two very short-term table locks. An S (Shared) table lock at the start of the operation to force all write plans that could touch the index to recompile, and a SCH-M (Schema-Modification - think of it as an Exclusive) table lock at the end of operation to force all read and write plans that could touch the index to recompile.

The most recent time this came up on the forums was someone noticing insert queries timing out after an online index rebuild operation had just started. The problem is that the  table lock that online index rebuild needs has to be entered into the grant queue in the lock manager until it can be acquired - and it will stay there until existing transactions that are holding conflicting locks either commit or roll-back. Any transaction that requires a conflicting lock AFTER the index rebuild lock has been queued but not acquired (and then released) will wait behind it in the lock grant queue. If the query timeout is reached before the transaction can get it's lock, it will timeout.

This is still much better than the table lock being held for the entire duration of the index rebuild operation. For more info, checkout this whitepaper on Online Index Operations in SQL Server 2005.

Sunday, June 08, 2008 9:12:56 AM (Pacific Standard Time, UTC-08:00)  #    Comments [6]  | 
Thursday, June 05, 2008

That time has rolled around again and we're flying down to Orlando for TechEd US tomorrow - my first US TechEd since I left Microsoft. We're doing a lot of stuff this year - here's our schedule if you're going to be there:

Monday

  • Full day pre-con seminar: SQL Server 2008 Overview for DBAs

Tuesday

  • 13.15 - 14.30 (Room N230) DAT354 Are Your Indexing Strategies Working?
  • 15.00 - 16.00 (TechEd Online Stage) Panel: Leveraging SQL Server Technologies to Build a Solid High-Availability Strategy
  • 16.00 - 18.00 DAT track booth

Wednesday

  • 10.15 - 11.30 (Room N220D) DAT375 Corruption Survival Techniques: From Detection to Recovery
  • 11.30 - 14.45 DAT track booth
  • 15.00 - 16.00 Blogger's Lounge

Thursday

  • 10.15 - 11.30 (Room S230E) DAT363 Essential Database Maintenance
  • 11.45 - 13.00 Speader Idol judging
  • 14.30 - 18.00 DAT track booth

Hopefully a bunch of you will stop by and say hi - I'm looking forward to seeing some familiar faces and some new ones! I'll try to blog while I'm there on questions I get and I've got some cool demos for the corruption session that I'll be blogging about over the summer.

See you next week...

Thursday, June 05, 2008 1:26:54 PM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Thursday, May 29, 2008

Totally off-topic blog post this time. I haven't blogged in a week as we've been in Chicago and Illinois for the memorial for Kimberly's Father (see here). Everything went really well at the memorial and then the ashes scattering in Lake Michigan - perfect sailing weather! The only fly in the ointment came back to backups again. I bought a very cool video camera to make sure we captured the memorial for posterity (actually I probably went a little over-the-top but the HD picture quality is awesome - Canon XA H1). I video'd the whole memorial, and then out on the boat the next day. The only problem was that I didn't check the tapes before taping on the second day and managed to overwrite half of the memorial video. Should have taken a backup onto my laptop in the evening on the first day but too much rum was drunk in the Columbia Yacht Club in Chicago and I didn't think to check in the morning. Oops. After we've been burned so badly with Kimberly's computer mishaps (see my diatribe here), you'd think we'd have learned by now...

The last few days we've been in Galesburg, IL visiting Kimberly's Mom and Fort Madison, IA visiting her Grandmother. As a bird-watcher, this was paradise as I managed to pick-up eight new bird species for my life-list. Galesburg is the home of the largest railroad switch-yard in the world, and much as I like trains, it seems like most of the 150+ trains per day that go through Galesburg actually go through at night, making lots of noise as the do so - which doesn't make for the best sleep.

Here are some pictures from Galesburg... (click for larger versions)

Okay - so why does the title mention movie plots? And why is this blog post filed under the Bad Advice tag? Well, it would be bad advice for me to recommend you go to see Indiana Jones and the Kingdom of the Crystal Skull, which we saw this evening. We're both *huge* Indiana Jones fans, but this movie was pretty bad. Contrived plot, boring dialog, wooden characters, and a predictable ending. Without giving anything away, the refrigerator scene is totally unbelievable, the accents of the baddies are cliched and awful, and what's with the cutsie gophers at the start? Well, I enjoyed a few bits here and there but I was ready to leave after about half an hour. I can't believe Harrison Ford made this movie... Oh well - I'm sure opinions will vary but I think they should have left the series to end with The Last Crusade back in 1989.

Tomorrow we fly home and next week we're back to work for a little bit before flying out again to TechEd on Friday. And I'll be finishing up some exciting 2008 whitepapers for the SQL teaam and back to blogging about technical stuff.

Thursday, May 29, 2008 4:34:07 PM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 
Thursday, May 22, 2008

Before I start, I want to make it clear that you can only hit this bug if you ALREADY have corruption, that it's quite rare, and that there is a workaround.

I've noticed a few more people in the forums having CHECKDB fail with this particular error in the last month

Msg 8967, Level 16, State 216, Line 1
An internal error occured in DBCC which prevented further processing. Please contact Product Support.

instead of completing properly and listing the corruptions in the database.

Whenever CHECKDB is using a database snapshot, it must check that the page it read through the snapshot does not have an LSN (Log Sequence Number) higher than that when the snapshot was created. If it did, this would mean that the page was modified AFTER the snapshot was created and hence CHECKDB would be working from an inconsistent view of the database. If this case is discovered, CHECKDB stops immediately. When I rewrote CHECKDB for SQL Server 2005, I changed a bunch of code assertions into seperate states of the 8967 error, so that CHECKDB would fail gracefully if some condition occured that indicates a bug or something that should never happen. State 216 is for the bad LSN condition I've just described.

I used to think it was caused by a race condition with the NTFS code that implements sparse files, which is used by the hidden database snapshot that CHECKDB uses by default. However, I've come to learn that this is a bug in CHECKDB (not one of mine I should say :-)) that causes this behavior under certain circumstances when corruption is present. The bug is that if a corrupt page fails auditing inside CHECKDB, the LSN check is still performed. If the corruption affects the LSN stamped in the page header, the 8967 error could be triggered. I've seen this a handful of times in the last few weeks - hence the need for a blog post. I've discussed this with the dev team and hopefully the fix will make it into the next SPs for 2005 and 2008 (too late to fix such a rare problem in such a critical component at this stage of 2008 development). They're going to put a KB article together too - but in the meantime, I wanted to get this on the Internet so Google/Live Search pick it up.

Now let's repro the problem. Starting with a simple database and table, I'll find the first page so I can corrupt it.

CREATE DATABASE TestCheckdbBug;
GO
USE TestCheckdbBug;
GO
CREATE TABLE test (c1 INT, c2 CHAR (5000));
INSERT INTO test VALUES (1, 'a');
GO
EXEC sp_AllocationMetadata 'test';
GO

Object Name  Index ID  Alloc Unit ID      Alloc Unit Type  First Page  Root Page  First IAM Page
-----------  --------  -----------------  ---------------  ----------  ---------  --------------
test         0         72057594042318848  IN_ROW_DATA      (1:143)     (0:0)      (1:152)

Now I'm going to corrupt the page type on page (1:143) to be 255 (an invalid page type), which will guarantee the page fails the audit inside CHECKDB.

DBCC CHECKDB ('TestCheckdbBug') WITH ALL_ERRORMSGS, NO_INFOMSGS;
GO

Msg 8928, Level 16, State 1, Line 1
Object ID 2073058421, index ID 0, partition ID 72057594038321152, alloc unit ID 72057594042318848 (type In-row data): Page (1:143) could not be processed. See other errors for details.
Msg 8939, Level 16, State 6, Line 1
Table error: Object ID 2073058421, index ID 0, partition ID 72057594038321152, alloc unit ID 72057594042318848 (type In-row data), page (1:143). Test ((m_type >= DATA_PAGE && m_type <= UNDOFILE_HEADER_PAGE) || (m_type == UNKNOWN_PAGE && level == BASIC_HEADER)) failed. Values are 255 and 255.
CHECKDB found 0 allocation errors and 2 consistency errors in table 'test' (object ID 2073058421).
CHECKDB found 0 allocation errors and 2 consistency errors in database 'TestCheckdbBug'.
repair_allow_data_loss is the minimum repair level for the errors found by DBCC CHECKDB (TestCheckdbBug).

Now I'm going to corrupt the LSN on that page such that it's guaranteed to be higher than the creation LSN of the database snapshot (basically by filling the first part of the page header LSN field with 0xFF).

DBCC CHECKDB ('TestCheckdbBug') WITH ALL_ERRORMSGS, NO_INFOMSGS;
GO

Msg 8967, Level 16, State 216, Line 1
An internal error occurred in DBCC that prevented further processing. Contact Customer Support Services.
Msg 8921, Level 16, State 1, Line 1
Check terminated. A failure was detected while collecting facts. Possibly tempdb out of space or a system table is inconsistent. Check previous errors.

Bingo! And in the error log, there's some diagnostic information so we can tell which page caused the problem:

2008-05-22 14:55:01.95 spid53   DBCC encountered a page with an LSN greater than the current end of log LSN (31:0:1) for its internal database snapshot. Could not read page (1:143), database 'TestCheckdbBug' (database ID 15), LSN = (-1:65535:18), type = 255, isInSparseFile = 0. Please re-run this DBCC command.
2008-05-22 14:55:01.95 spid53   DBCC CHECKDB (TestCheckdbBug) WITH all_errormsgs, no_infomsgs executed by ROADRUNNERPR\paul terminated abnormally due to error state 1. Elapsed time: 0 hours 0 minutes 0 seconds.

Note the page ID (in black bold above) tells us the bad page and the LSN (in blue bold above) reflects the corruption that I caused. If the page ID field of the header was corrupt, it wouldn't be possible to tell from these diagnostics which page is corrupt.

However, all is not lost. This bug means that under these circumstances the default online behavior of CHECKDB can't run. The workaround is to use the WITH TABLOCK option of CHECKDB, which does offline checking and doesn't need the snapshot - but the trade-off is that an exclusive database lock is required for a short time and then shared table locks for all tables in the database (this is why online is the default). Running this option on my corrupt database gives:

DBCC CHECKDB ('TestCheckdbBug') WITH TABLOCK, ALL_ERRORMSGS, NO_INFOMSGS;
GO

Msg 8928, Level 16, State 1, Line 1
Object ID 2073058421, index ID 0, partition ID 72057594038321152, alloc unit ID 72057594042318848 (type In-row data): Page (1:143) could not be processed. See other errors for details.
Msg 8939, Level 16, State 6, Line 1
Table error: Object ID 2073058421, index ID 0, partition ID 72057594038321152, alloc unit ID 72057594042318848 (type In-row data), page (1:143). Test ((m_type >= DATA_PAGE && m_type <= UNDOFILE_HEADER_PAGE) || (m_type == UNKNOWN_PAGE && level == BASIC_HEADER)) failed. Values are 255 and 255.
CHECKDB found 0 allocation errors and 2 consistency errors in table 'test' (object ID 2073058421).
CHECKDB found 0 allocation errors and 2 consistency errors in database 'TestCheckdbBug'.
repair_allow_data_loss is the minimum repair level for the errors found by DBCC CHECKDB (TestCheckdbBug).

Which are the exact same results we had before I corrupted the LSN field (this is expected, as there is no check of a page's LSN field EXCEPT when running from a database snapshot). Now we can proceed to restore/repair as appropriate.

So - a scary little bug that has caused some people headaches, but I want to stress again - this can only happen if the database is ALREADY corrupt, and that it's quite rare. Hope this helps some of you picking this up from search engines in the future.

Thursday, May 22, 2008 2:20:58 PM (Pacific Standard Time, UTC-08:00)  #    Comments [1]  | 
Monday, May 19, 2008

My first magazine article is in print! I've taken over the bi-monthly SQL Q&A column for TechNet Magazine and I just received the June magazine in the mail today with my first column in it. Topics covered are:

  • Creating corruption and using page checksums
  • The shrink-grow-shrink-grow trap
  • How many databases can be mirrored per instance
  • A tip on changing the default server port, from Jens Suessmeyer

I've also just completed a feature article for either the July or August issue dealing with database maintenance for the 'involuntary' DBA - more details when it gets published.

If you don't get the print version of TechNet Magazine, you can get to this month's SQL Q&A column at http://technet.microsoft.com/en-us/magazine/cc510328.aspx. There may not be anything new if you've been following my blog for a while, but if you've just started, it's worth a quick look.

Enjoy!

PS Let me know if you've got any good questions - I've already completed the August column but I'd like to hear of any questions you may have for later columns.

Monday, May 19, 2008 11:06:59 AM (Pacific Standard Time, UTC-08:00)  #    Comments [4]  | 
Saturday, May 17, 2008

So Seattle weather went from 50 degrees to 85 degrees overnight Friday and we all went from shivering to sweating! It's too hot to be sitting outside so we're both sitting inside getting a little work done. Well, I should really say 'work' as neither of us are actually doing anything productive for the business. Both of us are feverishly scanning.

We've got the Memorial for Kimberly's Dad (see here) coming up next weekend in Chicago so Kimberly's putting together a slide-show of his life. This involves scanning a bunch of very old photos, negatives, and slides and then laboriously touching them up to remove all the evidence of the ravages of time - dust, scratches, discoloration from old paper and mounts when acid-free wasn't the norm. After scanning she's using software called Adobe Elements which can do *incredible* things to restore images.

Many people say that if your house burns down, the only *really* irreplacable things are photos - everything else is just stuff. A few months ago I started to realize that between the two of us, we have an awful lot of film photos - for instance, Kimberly has literally more than 10000 slides from dive trips over the last 10 years - if something were to happen, that's a lot of memories to lose in one go (we estimate we've got 30000 film frames between us).

So - I bought a combo slide/negative scanner. I did lots of research before deciding on the Nikon Super CoolScan 5000ED - a little pricey but the reviews seem to justify the price. I've mostly scanned old (20-50 years) slides and negatives so far and the software the Nikon has to automatically put color back and remove all the imperfections is again just *incredible* with the results it gets. Now that I know the scanner is really top-notch, I've picked up the SF-210 Slide Feeder so I can load 50 of Kimberly's slides at one time and walk away for a few hours. Still - I'm looking at months and months of having the scanner buzzing away next to me while I work.

What's the point of this blog post then? Well, it's a little rambling but after Kimberly's recent corruption nightmares (see here) I started thinking a lot about making sure we had backups of everything we think is important. I realized that not all the data we want to preserve is already in digital format - which makes it impossible to just backup (there's no way to just make a quick copy of negatives). I'm sure a lot of you out there reading this are just like us - you've got a bunch of pre-digital photos that are slowly degrading and need to be scanned to be preserved - and may already be embarked on a months-long or years-long effort to scan them all.

Apart from the realization that I need to convert all this stuff to digital data to allow backing it up, the question then becomes - how can I be sure that I *really* have a backup of it all in the event of a disaster? Here are the options:

  1. Multiple copies of the data on different hard-drives
  2. Copies of the data on DVDs/USB-drives in a fire-safe
  3. Copies of the data on DVDs/drives in someone else's house
  4. Copies of the data on DVDs/drives in a safe-deposit box
  5. Copies of the data in the 'cloud' somewhere

If I'm really paranoid I'd probably do all of #1 through #4 - and given our experiences over the last few months, I'm sure that's what I'll end up doing!

But should I go with DVDs or hard-drives? Kimberly and I both have 1TB external Maxtor hard-drives that either have failed or show signs of failing (there's a class action lawsuit against Maxtor as I type). We both have multiple 250GB Western Digital USB drives that we travel with - 9 in total when we're together! However hard-drives aren't infallible at all - as Kimberly's in-flight corruption experience (for which I was unjustly blamed :-)) showed us. So what about DVDs? At 9GB each maximum, and with me scanning at 17.8MB per frame for say, 30000 frames, that would be 58 DVDs (to store a total of 521GB of data). Wow! And that's not even including the digital photos we have - Kimberly just reminded me that she took 6000 alone on our drive trip to Indonesia over Christmas 2006.

So it quickly gets a little overwhelming to think about and plan for. However, without any planning and forethought, if a disaster were to happen, we'd lose all our photos.

Same goes for business data in a database - without any planning, without any backups, you lose the lot in the event of a disaster.

Cheers

PS Kimberly just posted a little follow-up (see here) with a FANTASTIC image of her Grandfather sitting on the P-51 that he flew while a fighter-pilot during World-War II.

Saturday, May 17, 2008 5:41:23 PM (Pacific Standard Time, UTC-08:00)  #    Comments [4]  | 
Wednesday, May 14, 2008

Kimberly and I were presenting at our local (Redmond) .Net Developers Association on Monday and the following question came up while Kimberly was talking about missing and extra indexes (paraphrasing):

What's the best non-clustered index to use for the query with a predicate WHERE lastname = 'Randal' AND firstname = 'Paul' AND middleinitial = 'S'?

Kimberly said that the order of the keys (e.g. lastname, firstname, middleinitial; or middleinitial, lastname, firstname; etc) doesn't matter for this case. I thought about it for a second and then argued, saying that the most selective column should come first. We agreed to discuss with the group at the end, but I thought about it some more and realized (and admitted to the group) that she's right - I should know better than to question Kimberly's knowledge of indexing... :-)

She's right because for a pure equality query using AND for multiple predicates, the Storage Engine will seek straight to the first exactly matching record in the index (and then scan for more matches if it's a non-unique index). It doesn't matter what order the index keys are defined because the Storage Engine is looking for an exact match.

When I started arguing, I was thinking about a phone book, which is ordered by lastname, firstname, middleinitial. You may think that a phone book is ordered that way because lastname is the most selective. Wrong. It's because the lastname is what most people know - it just happens to be the most selective of the three choices. Most SQL geeks should be able to find Kimberly in a phone book by looking for Tripp, Kimberly. But what if it was ordered by middleinital? I'd have no problem finding Kimberly, but how many of you would remember that her middleinitial is L? Probably a few as we both use our middle initials in our public names. What about if it was ordered by middleNAME? Again, no problem for me but who how many other people know her middle name is Lynn?

Then I started thinking about other queries and how they would play into the index choice to answer to the question above. If I also wanted to support a query with the predicate WHERE lastname = 'Randal', then having the left-most index key be anything other than lastname won't work so well. If the key order was firstname, middleinitial, lastname then all the distinct lastname values would be spread through the index rather than being together. The index might still be used to satisfy the query if it's the lowest cost index to use. However, having lastname be the leading key probably wouldn't work very well for a query with a predicate of WHERE firstname = 'Paul' - that argues for having firstname be the left-most index key.

Which should I choose? I probably I can't have both in the same index, so maybe I'd have TWO non-clustered indexes, to support both queries. The answer depends on how often the various queries are used and the trade-off between how much of a performance gain the non-clustered index would provide against the performance drop of having to maintain it during DML operations.

I hear time and again about people adding a non-clustered index for every column in the table, thinking that this will help - and my thinking is that this is wrong because these indexes can only satisfy a query where the only predicate is the column being indexed. I ran this argument past Kimberly and she added that these indexes could also be used if the column is chosen as the most selective in a multi-predicate query, and no other index has a lower cost than that one (a slim chance usually). Even what I though of as a simple case has caveats!

So what's the point of this post? Well, I wanted to show how indexing for one very simple query is pretty straightforward, but as soon as the number of different queries grows, and the query predicates get more complicated, indexing becomes more complex. You really have to know your workload and your data to know which columns are used, in what combinations, and how often - and then it helps to know how indexes are costed and used so that you can make intelligent choices about which indexes to define.

This thought-exercise has really shown me that I didn't know how much I don't know about indexes - I know precisely how they work at the Storage Engine level but not too much about how they're used by the Query Processor. I have new-found respect for Kimberly's indexing expertise. Luckily she's teaching a class at Microsoft called Indexing For Performance next week - I think I'll attend :-)

Wednesday, May 14, 2008 2:13:44 PM (Pacific Standard Time, UTC-08:00)  #    Comments [7]  | 
Monday, April 28, 2008

We're sitting here in St. Pete Beach in Florida visiting some of Kimberly's family and having some sun-kissed R&R before heading back up to Seattle on Wednesday, and I thought I'd get the next post in my sparse columns mini-series out. Before I start though, Kimberly just posted the resources for the Accidental DBA class we taught at SQL Connections last week and in Iceland at the end of March - see here.

In my first post on sparse columns (see here) I introduced the concepts and explained why sparse columns are a useful feature. In this post I'm going to use the example I gave - a document repository with 50 document types and 20 unique attributes per document-type. Yes, it's a contrived example, but scale it up be a factor of 100+ (think Sharepoint Server) and methods like normalization no longer apply.

I'm using a CTP-6 VPC on a Lenovo T60P laptop with 4GB, a dual-core 2.2GHz CPU, and the VPC is running off a 6200RPM drive. Your mileage may vary for run-times of the example scripts. The VPC is the one we gave out at SQL Connections and Iceland, and you can download the scripts and a VPC (maybe only CTP-5) from the Microsoft JumpStart site (see here for details).

The first test I'll do is just creating the schema necessary to store the 1000+ columns of information in the test scenario. I'll do one with sparse columns and one without:

-- Create two tables, one with 1000 columns and one with 1000 columns but 997 sparse.
CREATE TABLE TableWithoutSparseColumns (
   DocID INT IDENTITY, DocName VARCHAR (100), DocType INT,
   c0004 INT NULL, c0005 INT NULL, c0006 INT NULL, c0007 INT NULL, c0008 INT NULL, c0009 INT NULL,
   ...
   c0994 INT NULL, c0995 INT NULL, c0996 INT NULL, c0997 INT NULL, c0998 INT NULL, c0999 INT NULL,
   c1000 INT NULL);
GO

CREATE TABLE TableWithSparseColumns (
   DocID INT IDENTITY, DocName VARCHAR (100), DocType INT,
   c0004 INT SPARSE NULL, c0005 INT SPARSE NULL, c0006 INT SPARSE NULL, c0007 INT SPARSE NULL,
   ...
   c0996 INT SPARSE NULL, c0997 INT SPARSE NULL, c0998 INT SPARSE NULL, c0999 INT SPARSE NULL,
   c1000 INT SPARSE NULL);
GO

I won't list all the column names for the sake of brevity. Next I'll insert some values into each table (the same values in each table):

-- Insert a few rows in each
INSERT INTO TableWithSparseColumns (DocName, Doctype) VALUES ('aaaa', 1);
INSERT INTO TableWithSparseColumns (DocName, Doctype, c0945) VALUES ('bbbb', 2, 46);
INSERT INTO TableWithSparseColumns (DocName, Doctype, c0334) VALUES ('cccc', 3, 44);
INSERT INTO TableWithSparseColumns (DocName, Doctype, c0233, c0234) VALUES ('dddd', 4, 12, 34);
INSERT INTO TableWithSparseColumns (DocName, Doctype, c0233, c0234,c0235,c0236) VALUES ('eeee', 4, 12, 34, 46, 66);
GO

INSERT INTO TableWithoutSparseColumns (DocName, Doctype) VALUES ('aaaa', 1);
INSERT INTO TableWithoutSparseColumns (DocName, Doctype, c0945) VALUES ('bbbb', 2, 46);
INSERT INTO TableWithoutSparseColumns (DocName, Doctype, c0334) VALUES ('cccc', 3, 44);
INSERT INTO TableWithoutSparseColumns (DocName, Doctype, c0233, c0234) VALUES ('dddd', 4, 12, 34);
INSERT INTO TableWithoutSparseColumns (DocName, Doctype, c0233, c0234,c0235,c0236) VALUES ('eeee', 4, 12, 34, 46, 66);
GO

Now let's see how big each table is:

-- Now lets see how big the rows are
SELECT [avg_record_size_in_bytes], [page_count] FROM sys.dm_db_index_physical_stats (
   DB_ID ('SparseColumnsTest'), OBJECT_ID ('TableWithoutSparseColumns'), NULL, NULL, 'DETAILED');

SELECT [avg_record_size_in_bytes], [page_count] FROM sys.dm_db_index_physical_stats (
   
DB_ID ('SparseColumnsTest'), OBJECT_ID ('TableWithSparseColumns'), NULL, NULL, 'DETAILED');
GO

avg_record_size_in_bytes page_count
------------------------ --------------------
4135                     5

(1 row(s) affected)

avg_record_size_in_bytes page_count
------------------------ --------------------
40.6                     1

(1 row(s) affected)

Ok - so that's not a huge difference in page count (because we've only got 5 rows), but it's a *massive* difference in average record size. Scaled up to hundreds of thousands or millions of records, the space savings will be astronomical!

Now let's try selecting the data back using results-to-grid mode and a simple SELECT * statement. It takes 20 seconds to return - solely because the client still has to retrieve the metadata for 1000+ columns. Even though the columns are still defined as SPARSE, they show up in a SELECT * resultset, and that makes extracting out the non-NULL values pretty difficult...

Time for another new feature - column sets. There's a new column type available for use with sparse columns - an XML COLUMN_SET. This is a column that is only materialized when selected, and will return all the non-NULL sparse columns in a row as an XML BLOB. It will also change the behavior of a SELECT * operation - removing all the sparse columns from the resultset and replacing them with itself, representing all the non-NULL sparse columns. Redefining our TableWithSparseColumns to have an XML COLUMN_SET column called SparseColumns (using the syntax 'SparseColumns XML COLUMN_SET FOR ALL_SPARSE_COLUMNS'), and re-inserting the same values then gives the following results for a SELECT * operation:

DocID   DocName   DocType   SparseColumns
------- --------- --------- ---------------
1       aaaa      1         NULL
2       bbbb      2         <c0945>46</c0945>
3       cccc      3         <c0334>44</c0334>
4       dddd      4         <c0233>12</c0233><c0234>34</c0234>
5       eeee      4         <c0233>12</c0233><c0234>34</c0234><c0235>46</c0235><c0236>66</c0236>

Pretty cool - and it returns virtually instantaneously (obviously scaling up to hundreds of thousands or millions of rows would take longer due to the time necessary to read the pages into the buffer pool). One downside is that the XML blob only returns the column name and value - not the datatype - but if your application can cope with that then not having to wade through hundreds (or thousands by RTM) of NULL columns values is great.

Next time I'll discuss the internals of how sparse columns are stored.

Monday, April 28, 2008 3:05:37 PM (Pacific Standard Time, UTC-08:00)  #    Comments [0]  | 

Theme design by Jelle Druyts

Pick a theme: