//******************************************************************************************************
// MetadataImportAdapter.cs - Gbtc
//
// Copyright © 2018, Grid Protection Alliance. All Rights Reserved.
//
// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
// the NOTICE file distributed with this work for additional information regarding copyright ownership.
// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
// file except in compliance with the License. You may obtain a copy of the License at:
//
// http://opensource.org/licenses/MIT
//
// Unless agreed to in writing, the subject software distributed under the License is distributed on an
// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
// License for the specific language governing permissions and limitations.
//
// Code Modification History:
// ----------------------------------------------------------------------------------------------------
// 02/24/2018 - Stephen C. Wills
// Generated original version of source code.
//
//******************************************************************************************************
using GSF;
using GSF.Collections;
using GSF.Configuration;
using GSF.Data;
using GSF.Diagnostics;
using GSF.Scheduling;
using GSF.Threading;
using GSF.TimeSeries;
using GSF.TimeSeries.Adapters;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.IO;
using System.Linq;
using System.Threading;
namespace MetadataAdapters
{
///
/// Represents an adapter that reads metadata from a file and updates the database.
///
[Description("Metadata Import: Reads metadata from a file and updates the database.")]
public class MetadataImportAdapter : FacileActionAdapterBase
{
#region [ Members ]
// Constants
///
/// Defines the default value for the property.
///
public const string DefaultImportSchedule = "*/5 * * * *";
///
/// Defines the default value for the property.
///
public const bool DefaultUseTransactionForMetadata = true;
///
/// Defines the default value for the property.
///
public const int DefaultMetadataSynchronizationTimeout = 0;
///
/// Defines the default value for the property.
///
public const bool DefaultUseSourcePrefixNames = false;
private const string ScheduleName = nameof(MetadataImportAdapter);
// Fields
private Action m_synchronizeMetadataAction;
private ScheduleManager m_scheduleManager;
private DateTime m_lastMetaDataRefreshTime;
private bool m_disposed;
#endregion
#region [ Constructors ]
///
/// Creates a new instance of the class.
///
public MetadataImportAdapter()
{
m_synchronizeMetadataAction = GetSynchronizeMetadataAction();
m_scheduleManager = new ScheduleManager();
m_scheduleManager.ScheduleDue += ScheduleManager_ScheduleDue;
}
#endregion
#region [ Properties ]
///
/// Gets or sets the path to the file in which the metadata to be imported will be defined.
///
[ConnectionStringParameter]
[Description("Defines the path to the file in which the metadata to be imported will be defined.")]
public string ImportFilePath { get; set; }
///
/// Gets or sets the schedule, using cron syntax, to search for metadata files to import.
///
[ConnectionStringParameter]
[DefaultValue(DefaultImportSchedule)]
[Description("Defines the schedule, using cron syntax, to search for metadata files to import.")]
public string ImportSchedule { get; set; }
///
/// Gets or sets the acronym of the device used as the parent of the devices defined in the metadata.
///
[ConnectionStringParameter]
[DefaultValue("")]
[Description("Defines the acronym of the device used as the parent of the devices defined in the metadata.")]
public string ParentDeviceAcronym { get; set; }
///
/// Gets or sets flag that determines if metadata synchronization should be performed within a transaction.
///
[ConnectionStringParameter]
[DefaultValue(DefaultUseTransactionForMetadata)]
[Description("Defines the flag that determines if metadata synchronization should be performed within a transaction.")]
public bool UseTransactionForMetadata { get; set; }
///
/// Gets or sets the timeout used when executing database queries during metadata synchronization.
///
[ConnectionStringParameter]
[DefaultValue(DefaultMetadataSynchronizationTimeout)]
[Description("Defines the timeout used when exeucting database queries during metadata synchronization.")]
public int MetadataSynchronizationTimeout { get; set; }
///
/// Gets or sets flag that determines if child devices associated with a subscription
/// should be prefixed with the subscription name and an exclamation point to ensure
/// device name uniqueness - recommended value is true.
///
[ConnectionStringParameter]
[DefaultValue(DefaultUseSourcePrefixNames)]
[Description("Defines the flag that determines if child device acronyms should be prefixed with the parent acronym.")]
public bool UseSourcePrefixNames { get; set; }
///
/// Gets the flag indicating if this adapter supports temporal processing.
///
public override bool SupportsTemporalProcessing => false;
///
/// Gets or sets primary keys of input measurements the expects, if any.
///
[EditorBrowsable(EditorBrowsableState.Never)]
public new MeasurementKey[] InputMeasurementKeys
{
get
{
return base.InputMeasurementKeys;
}
set
{
base.InputMeasurementKeys = value;
}
}
///
/// Gets or sets output measurements that the will produce, if any.
///
[EditorBrowsable(EditorBrowsableState.Never)]
public new IMeasurement[] OutputMeasurements
{
get
{
return base.OutputMeasurements;
}
set
{
base.OutputMeasurements = value;
}
}
///
/// Gets or sets the frames per second to be used by the .
///
[EditorBrowsable(EditorBrowsableState.Never)]
public new int FramesPerSecond
{
get
{
return base.FramesPerSecond;
}
set
{
base.FramesPerSecond = value;
}
}
///
/// Gets or sets the allowed past time deviation tolerance, in seconds (can be sub-second).
///
[EditorBrowsable(EditorBrowsableState.Never)]
public new double LagTime
{
get
{
return base.LagTime;
}
set
{
base.LagTime = value;
}
}
///
/// Gets or sets the allowed future time deviation tolerance, in seconds (can be sub-second).
///
[EditorBrowsable(EditorBrowsableState.Never)]
public new double LeadTime
{
get
{
return base.LeadTime;
}
set
{
base.LeadTime = value;
}
}
#endregion
#region [ Methods ]
///
/// Initializes .
///
public override void Initialize()
{
ConnectionStringParser parser = new ConnectionStringParser();
parser.ParseConnectionString(ConnectionString, this);
base.Initialize();
if (string.IsNullOrWhiteSpace(ParentDeviceAcronym))
ParentDeviceAcronym = Name + "!PARENT";
// Default to not using transactions for metadata on SQL server (helps avoid deadlocks)
if (!Settings.ContainsKey(nameof(UseTransactionForMetadata)))
{
using (AdoDataConnection database = new AdoDataConnection("systemSettings"))
{
if (database.IsSQLServer)
UseTransactionForMetadata = false;
}
}
m_scheduleManager.AddSchedule(ScheduleName, ImportSchedule);
}
///
/// Starts the or restarts it if it is already running.
///
public override void Start()
{
base.Start();
m_scheduleManager.Start();
}
///
/// Stops the .
///
public override void Stop()
{
base.Stop();
m_scheduleManager.Stop();
}
///
/// Searches for metadata to be imported and imports the metadata if found.
///
[AdapterCommand("Imports metadata if found at the target location.")]
public void ImportMetadata()
{
if (!File.Exists(ImportFilePath))
return;
const int MaxFailedAttempts = 5;
const int DelayAfterFailure = 2000;
int failedAttempts = 0;
while (true)
{
try
{
DataSet metadata;
using (FileStream stream = File.OpenRead(ImportFilePath))
{
metadata = stream.DeserializeToDataSet();
}
m_synchronizeMetadataAction(metadata);
File.Delete(ImportFilePath);
break;
}
catch (IOException)
{
// Only throw an IOException
// after five failed attempts
failedAttempts++;
if (failedAttempts >= MaxFailedAttempts)
throw;
Thread.Sleep(DelayAfterFailure);
}
}
}
///
/// Gets a short one-line status of this .
///
/// Maximum number of available characters for display.
/// A short one-line summary of the current status of this .
public override string GetShortStatus(int maxLength)
{
string timestampFormat = "HH:mm:ss";
if (DateTime.Now.Subtract(m_lastMetaDataRefreshTime).TotalDays > 1.0D)
timestampFormat = "yyyy-MM-dd HH:mm";
string lastImport = m_lastMetaDataRefreshTime.ToString(timestampFormat);
string shortStatus = $"Last import: {m_lastMetaDataRefreshTime}";
return shortStatus.CenterText(maxLength);
}
///
/// Releases the unmanaged resources used by the object and optionally releases the managed resources.
///
/// true to release both managed and unmanaged resources; false to release only unmanaged resources.
protected override void Dispose(bool disposing)
{
if (!m_disposed)
{
try
{
if (disposing)
{
m_scheduleManager.Stop();
m_scheduleManager.Dispose();
}
}
finally
{
m_disposed = true; // Prevent duplicate dispose.
base.Dispose(disposing); // Call base class Dispose().
}
}
}
///
/// Handles metadata synchronization to local system.
///
private Action GetSynchronizeMetadataAction()
{
DataSet metadataToSynchronize = default(DataSet);
DataSet synchronizedMetadata = default(DataSet);
Guid nodeID = default(Guid);
int virtualProtocolID = default(int);
long syncProgressTotalActions = default(long);
long syncProgressActionsCount = default(long);
long syncProgressUpdateInterval = default(long);
long syncProgressLastMessage = default(long);
Action initSyncProgress = totalActions =>
{
syncProgressTotalActions = totalActions;
syncProgressActionsCount = 0;
// We update user on progress with 5 messages or every 15 seconds
syncProgressUpdateInterval = (long)(totalActions * 0.2D);
syncProgressLastMessage = DateTime.UtcNow.Ticks;
};
Action updateSyncProgress = () =>
{
syncProgressActionsCount++;
if (syncProgressActionsCount % syncProgressUpdateInterval == 0 || DateTime.UtcNow.Ticks - syncProgressLastMessage > 150000000)
{
OnStatusMessage(MessageLevel.Info, $"metadata synchronization is {syncProgressActionsCount / (double)syncProgressTotalActions:0.0%} complete...");
syncProgressLastMessage = DateTime.UtcNow.Ticks;
}
};
Func synchronizedMetadataChanged = newSynchronizedMetadata =>
{
try
{
return !DataSetEqualityComparer.Default.Equals(synchronizedMetadata, newSynchronizedMetadata);
}
catch
{
return true;
}
};
Action synchronizeMetadata = () =>
{
DataSet metadata = metadataToSynchronize;
// Only perform database synchronization if metadata has changed since last update
if (!synchronizedMetadataChanged(metadata))
return;
if ((object)metadata == null)
{
OnStatusMessage(MessageLevel.Error, "metadata synchronization was not performed, deserialized dataset was empty.");
return;
}
// Track total metadata synchronization process time
Ticks startTime = DateTime.UtcNow.Ticks;
DateTime updateTime;
DateTime latestUpdateTime = DateTime.MinValue;
// Open the configuration database using settings found in the config file
using (AdoDataConnection database = new AdoDataConnection("systemSettings"))
using (IDbCommand command = database.Connection.CreateCommand())
{
IDbTransaction transaction = null;
if (UseTransactionForMetadata)
transaction = database.Connection.BeginTransaction(database.DefaultIsolationLevel);
try
{
if ((object)transaction != null)
command.Transaction = transaction;
// Determine the active node ID - we cache this since this value won't change for the lifetime of this class
if (nodeID == Guid.Empty)
nodeID = Guid.Parse(command.ExecuteScalar($"SELECT NodeID FROM IaonActionAdapter WHERE ID = {(int)ID}", MetadataSynchronizationTimeout).ToString());
// Determine the protocol record auto-inc ID value for the gateway transport protocol (GEP) - this value is also cached since it shouldn't change for the lifetime of this class
if (virtualProtocolID == 0)
virtualProtocolID = int.Parse(command.ExecuteScalar("SELECT ID FROM Protocol WHERE Acronym = 'VirtualInput'", MetadataSynchronizationTimeout).ToString());
// Define SQL statement to query if parent device is already defined
string parentDeviceIDSql = database.ParameterizedQueryString("SELECT ID FROM Device WHERE Acronym = {0}", "acronym");
// Define SQL statement to insert new parent device record
string insertParentDeviceSql = database.ParameterizedQueryString("INSERT INTO Device(NodeID, HistorianID, Acronym, Name, ProtocolID, IsConcentrator, Enabled) " +
"VALUES ({0}, {1}, {2}, {3}, {4}, 1, 1)", "nodeID", "historianID", "acronym", "name", "protocolID");
// Query the actual record ID based on the known run-time ID for this subscriber device
object sourceID = command.ExecuteScalar(parentDeviceIDSql, MetadataSynchronizationTimeout, ParentDeviceAcronym);
if (sourceID == null || sourceID == DBNull.Value)
{
// Get a historian ID, but exclude the STAT historian
object randomHistorianID = command.ExecuteScalar($"SELECT ID FROM Historian WHERE Acronym <> 'STAT'", MetadataSynchronizationTimeout);
command.ExecuteNonQuery(insertParentDeviceSql, MetadataSynchronizationTimeout, database.Guid(nodeID), randomHistorianID, ParentDeviceAcronym, ParentDeviceAcronym, virtualProtocolID);
sourceID = command.ExecuteScalar(parentDeviceIDSql, MetadataSynchronizationTimeout, ParentDeviceAcronym);
}
int parentID = Convert.ToInt32(sourceID);
// Validate that the subscriber device is marked as a concentrator (we are about to associate children devices with it)
if (!command.ExecuteScalar($"SELECT IsConcentrator FROM Device WHERE ID = {parentID}", MetadataSynchronizationTimeout).ToString().ParseBoolean())
command.ExecuteNonQuery($"UPDATE Device SET IsConcentrator = 1 WHERE ID = {parentID}", MetadataSynchronizationTimeout);
// Get any historian associated with the subscriber device
object historianID = command.ExecuteScalar($"SELECT HistorianID FROM Device WHERE ID = {parentID}", MetadataSynchronizationTimeout);
// Ascertain total number of actions required for all metadata synchronization so some level feed back can be provided on progress
initSyncProgress(metadata.Tables.Cast().Sum(dataTable => (long)dataTable.Rows.Count) + 3);
// Prefix all children devices with the name of the parent since the same device names could appear in different connections (helps keep device names unique)
string sourcePrefix = UseSourcePrefixNames ? ParentDeviceAcronym + "!" : "";
Dictionary deviceIDs = new Dictionary(StringComparer.OrdinalIgnoreCase);
string deviceAcronym, signalTypeAcronym;
int deviceID;
// Check to see if data for the "DeviceDetail" table was included in the metadata
if (metadata.Tables.Contains("DeviceDetail"))
{
DataTable deviceDetail = metadata.Tables["DeviceDetail"];
List uniqueIDs = new List();
// Define SQL statement to query if this device is already defined (this should always be based on the unique guid-based device ID)
string deviceExistsSql = database.ParameterizedQueryString("SELECT COUNT(*) FROM Device WHERE UniqueID = {0}", "uniqueID");
// Define SQL statement to insert new device record
string insertDeviceSql = database.ParameterizedQueryString("INSERT INTO Device(NodeID, ParentID, HistorianID, Acronym, Name, ProtocolID, FramesPerSecond, AccessID, Longitude, Latitude, ContactList, IsConcentrator, Enabled) " +
"VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}, 0, 1)", "nodeID", "parentID", "historianID", "acronym", "name", "protocolID", "framesPerSecond", "accessID", "longitude", "latitude", "contactList");
// Define SQL statement to update device's guid-based unique ID after insert
string updateDeviceUniqueIDSql = database.ParameterizedQueryString("UPDATE Device SET UniqueID = {0} WHERE Acronym = {1}", "uniqueID", "acronym");
// Define SQL statement to query if a device can be safely updated
string deviceIsUpdatableSql = database.ParameterizedQueryString("SELECT COUNT(*) FROM Device WHERE UniqueID = {0} AND (ParentID <> {1} OR ParentID IS NULL)", "uniqueID", "parentID");
// Define SQL statement to update existing device record
string updateDeviceSql = database.ParameterizedQueryString("UPDATE Device SET Acronym = {0}, Name = {1}, ProtocolID = {2}, FramesPerSecond = {3}, HistorianID = {4}, AccessID = {5}, Longitude = {6}, Latitude = {7}, ContactList = {8} WHERE UniqueID = {9}",
"acronym", "name", "protocolID", "framesPerSecond", "historianID", "accessID", "longitude", "latitude", "contactList", "uniqueID");
// Define SQL statement to retrieve device's auto-inc ID based on its unique guid-based ID
string queryDeviceIDSql = database.ParameterizedQueryString("SELECT ID FROM Device WHERE UniqueID = {0}", "uniqueID");
// Define SQL statement to retrieve all unique device ID's for the current parent to check for mismatches
string queryUniqueDeviceIDsSql = database.ParameterizedQueryString("SELECT UniqueID FROM Device WHERE ParentID = {0}", "parentID");
// Define SQL statement to remove device records that no longer exist in the metadata
string deleteDeviceSql = database.ParameterizedQueryString("DELETE FROM Device WHERE UniqueID = {0}", "uniqueID");
foreach (DataRow row in deviceDetail.Rows)
{
Guid uniqueID = Guid.Parse(row.Field